Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:33:33.238Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

icon_theme_selector.rs

519 lines · 16.8 KB · rust
1use fs::Fs;
2use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
3use gpui::{
4    App, Context, DismissEvent, Entity, EventEmitter, Focusable, Render, UpdateGlobal, WeakEntity,
5    Window,
6};
7use picker::{Picker, PickerDelegate};
8use settings::{Settings as _, SettingsStore, update_settings_file};
9use std::sync::Arc;
10use theme::{Appearance, SystemAppearance, ThemeMeta, ThemeRegistry};
11use theme_settings::{IconThemeName, IconThemeSelection, ThemeSettings};
12use ui::{ListItem, ListItemSpacing, prelude::*, v_flex};
13use util::ResultExt;
14use workspace::{ModalView, ui::HighlightedLabel};
15use zed_actions::{ExtensionCategoryFilter, Extensions};
16
17pub(crate) struct IconThemeSelector {
18    picker: Entity<Picker<IconThemeSelectorDelegate>>,
19}
20
21impl EventEmitter<DismissEvent> for IconThemeSelector {}
22
23impl Focusable for IconThemeSelector {
24    fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
25        self.picker.focus_handle(cx)
26    }
27}
28
29impl ModalView for IconThemeSelector {
30    fn on_before_dismiss(
31        &mut self,
32        _window: &mut Window,
33        cx: &mut Context<Self>,
34    ) -> workspace::DismissDecision {
35        self.picker.update(cx, |picker, cx| {
36            picker.delegate.revert_theme(cx);
37        });
38        workspace::DismissDecision::Dismiss(true)
39    }
40}
41
42impl IconThemeSelector {
43    pub fn new(
44        delegate: IconThemeSelectorDelegate,
45        window: &mut Window,
46        cx: &mut Context<Self>,
47    ) -> Self {
48        let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
49        Self { picker }
50    }
51}
52
53impl Render for IconThemeSelector {
54    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
55        v_flex()
56            .key_context("IconThemeSelector")
57            .w(rems(34.))
58            .child(self.picker.clone())
59    }
60}
61
62pub(crate) struct IconThemeSelectorDelegate {
63    fs: Arc<dyn Fs>,
64    themes: Vec<ThemeMeta>,
65    matches: Vec<StringMatch>,
66    original_theme: IconThemeName,
67    selection_completed: bool,
68    selected_theme: Option<IconThemeName>,
69    selected_index: usize,
70    selector: WeakEntity<IconThemeSelector>,
71}
72
73impl IconThemeSelectorDelegate {
74    pub fn new(
75        selector: WeakEntity<IconThemeSelector>,
76        fs: Arc<dyn Fs>,
77        themes_filter: Option<&Vec<String>>,
78        cx: &mut Context<IconThemeSelector>,
79    ) -> Self {
80        let theme_settings = ThemeSettings::get_global(cx);
81        let original_theme = theme_settings
82            .icon_theme
83            .name(SystemAppearance::global(cx).0);
84
85        let registry = ThemeRegistry::global(cx);
86        let mut themes = registry
87            .list_icon_themes()
88            .into_iter()
89            .filter(|meta| {
90                if let Some(theme_filter) = themes_filter {
91                    theme_filter.contains(&meta.name.to_string())
92                } else {
93                    true
94                }
95            })
96            .collect::<Vec<_>>();
97
98        themes.sort_unstable_by(|a, b| {
99            a.appearance
100                .is_light()
101                .cmp(&b.appearance.is_light())
102                .then(a.name.cmp(&b.name))
103        });
104        let matches = themes
105            .iter()
106            .map(|meta| StringMatch {
107                candidate_id: 0,
108                score: 0.0,
109                positions: Default::default(),
110                string: meta.name.to_string(),
111            })
112            .collect();
113        let mut this = Self {
114            fs,
115            themes,
116            matches,
117            original_theme: original_theme.clone(),
118            selected_index: 0,
119            selected_theme: None,
120            selection_completed: false,
121            selector,
122        };
123
124        this.select_if_matching(&original_theme.0);
125        this
126    }
127
128    fn show_selected_theme(
129        &mut self,
130        cx: &mut Context<Picker<IconThemeSelectorDelegate>>,
131    ) -> Option<IconThemeName> {
132        let mat = self.matches.get(self.selected_index)?;
133        let name = IconThemeName(mat.string.clone().into());
134        Self::set_icon_theme(name.clone(), cx);
135        Some(name)
136    }
137
138    fn select_if_matching(&mut self, theme_name: &str) {
139        self.selected_index = self
140            .matches
141            .iter()
142            .position(|mat| mat.string == theme_name)
143            .unwrap_or(self.selected_index);
144    }
145
146    fn revert_theme(&mut self, cx: &mut App) {
147        if !self.selection_completed {
148            Self::set_icon_theme(self.original_theme.clone(), cx);
149            self.selection_completed = true;
150        }
151    }
152
153    fn set_icon_theme(name: IconThemeName, cx: &mut App) {
154        SettingsStore::update_global(cx, |store, _| {
155            let mut theme_settings = store.get::<ThemeSettings>(None).clone();
156            theme_settings.icon_theme = IconThemeSelection::Static(name);
157            store.override_global(theme_settings);
158        });
159    }
160}
161
162impl PickerDelegate for IconThemeSelectorDelegate {
163    type ListItem = ui::ListItem;
164
165    fn name() -> &'static str {
166        "icon theme selector"
167    }
168
169    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
170        "Select Icon Theme...".into()
171    }
172
173    fn match_count(&self) -> usize {
174        self.matches.len()
175    }
176
177    fn confirm(
178        &mut self,
179        _: bool,
180        window: &mut Window,
181        cx: &mut Context<Picker<IconThemeSelectorDelegate>>,
182    ) {
183        self.selection_completed = true;
184
185        let theme_settings = ThemeSettings::get_global(cx);
186        let theme_name = theme_settings
187            .icon_theme
188            .name(SystemAppearance::global(cx).0);
189
190        telemetry::event!(
191            "Settings Changed",
192            setting = "icon_theme",
193            value = theme_name
194        );
195
196        let appearance = Appearance::from(window.appearance());
197
198        update_settings_file(self.fs.clone(), cx, move |settings, _| {
199            theme_settings::set_icon_theme(settings, theme_name, appearance);
200        });
201
202        self.selector
203            .update(cx, |_, cx| {
204                cx.emit(DismissEvent);
205            })
206            .ok();
207    }
208
209    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<IconThemeSelectorDelegate>>) {
210        self.revert_theme(cx);
211
212        self.selector
213            .update(cx, |_, cx| cx.emit(DismissEvent))
214            .log_err();
215    }
216
217    fn selected_index(&self) -> usize {
218        self.selected_index
219    }
220
221    fn set_selected_index(
222        &mut self,
223        ix: usize,
224        _: &mut Window,
225        cx: &mut Context<Picker<IconThemeSelectorDelegate>>,
226    ) {
227        self.selected_index = ix;
228        self.selected_theme = self.show_selected_theme(cx);
229    }
230
231    fn update_matches(
232        &mut self,
233        query: String,
234        window: &mut Window,
235        cx: &mut Context<Picker<IconThemeSelectorDelegate>>,
236    ) -> gpui::Task<()> {
237        let background = cx.background_executor().clone();
238        let candidates = self
239            .themes
240            .iter()
241            .enumerate()
242            .map(|(id, meta)| StringMatchCandidate::new(id, &meta.name))
243            .collect::<Vec<_>>();
244
245        cx.spawn_in(window, async move |this, cx| {
246            let matches = if query.is_empty() {
247                candidates
248                    .into_iter()
249                    .enumerate()
250                    .map(|(index, candidate)| StringMatch {
251                        candidate_id: index,
252                        string: candidate.string,
253                        positions: Vec::new(),
254                        score: 0.0,
255                    })
256                    .collect()
257            } else {
258                match_strings(
259                    &candidates,
260                    &query,
261                    false,
262                    true,
263                    100,
264                    &Default::default(),
265                    background,
266                )
267                .await
268            };
269
270            this.update(cx, |this, cx| {
271                this.delegate.matches = matches;
272                if query.is_empty() && this.delegate.selected_theme.is_none() {
273                    this.delegate.selected_index = this
274                        .delegate
275                        .selected_index
276                        .min(this.delegate.matches.len().saturating_sub(1));
277                } else if let Some(selected) = this.delegate.selected_theme.as_ref() {
278                    this.delegate.selected_index = this
279                        .delegate
280                        .matches
281                        .iter()
282                        .enumerate()
283                        .find(|(_, mtch)| mtch.string.as_str() == selected.0.as_ref())
284                        .map(|(ix, _)| ix)
285                        .unwrap_or_default();
286                } else {
287                    this.delegate.selected_index = 0;
288                }
289                // Preserve the previously selected theme when the filter yields no results.
290                if let Some(theme) = this.delegate.show_selected_theme(cx) {
291                    this.delegate.selected_theme = Some(theme);
292                }
293            })
294            .log_err();
295        })
296    }
297
298    fn render_match(
299        &self,
300        ix: usize,
301        selected: bool,
302        _window: &mut Window,
303        _cx: &mut Context<Picker<Self>>,
304    ) -> Option<Self::ListItem> {
305        let theme_match = &self.matches.get(ix)?;
306        let is_original_theme = theme_match.string.as_str() == self.original_theme.0.as_ref();
307
308        Some(
309            ListItem::new(ix)
310                .inset(true)
311                .spacing(ListItemSpacing::Sparse)
312                .toggle_state(selected)
313                .child(HighlightedLabel::new(
314                    theme_match.string.clone(),
315                    theme_match.positions.clone(),
316                ))
317                .when(is_original_theme, |this| {
318                    this.end_slot(Icon::new(IconName::Check).color(Color::Muted))
319                }),
320        )
321    }
322
323    fn render_footer(
324        &self,
325        _window: &mut Window,
326        cx: &mut Context<Picker<Self>>,
327    ) -> Option<gpui::AnyElement> {
328        Some(
329            h_flex()
330                .p_2()
331                .w_full()
332                .justify_between()
333                .gap_2()
334                .border_t_1()
335                .border_color(cx.theme().colors().border_variant)
336                .child(
337                    Button::new("docs", "View Icon Theme Docs")
338                        .end_icon(
339                            Icon::new(IconName::ArrowUpRight)
340                                .size(IconSize::Small)
341                                .color(Color::Muted),
342                        )
343                        .on_click(|_event, _window, cx| {
344                            cx.open_url("https://zed.dev/docs/icon-themes");
345                        }),
346                )
347                .child(
348                    Button::new("more-icon-themes", "Install Icon Themes").on_click(
349                        move |_event, window, cx| {
350                            window.dispatch_action(
351                                Box::new(Extensions {
352                                    category_filter: Some(ExtensionCategoryFilter::IconThemes),
353                                    id: None,
354                                }),
355                                cx,
356                            );
357                        },
358                    ),
359                )
360                .into_any_element(),
361        )
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use std::collections::HashMap;
369
370    use gpui::{TestAppContext, VisualTestContext};
371    use project::Project;
372    use serde_json::json;
373    use theme::{ChevronIcons, DirectoryIcons, IconTheme, ThemeRegistry};
374    use util::path;
375    use workspace::MultiWorkspace;
376
377    fn init_test(cx: &mut TestAppContext) -> Arc<workspace::AppState> {
378        cx.update(|cx| {
379            let app_state = workspace::AppState::test(cx);
380            settings::init(cx);
381            theme::init(theme::LoadThemes::JustBase, cx);
382            editor::init(cx);
383            crate::init(cx);
384            app_state
385        })
386    }
387
388    fn register_test_icon_themes(cx: &mut TestAppContext) {
389        cx.update(|cx| {
390            let registry = ThemeRegistry::global(cx);
391            let make_icon_theme = |name: &str, appearance: Appearance| IconTheme {
392                id: name.to_lowercase().replace(' ', "-"),
393                name: SharedString::from(name.to_string()),
394                appearance,
395                directory_icons: DirectoryIcons {
396                    collapsed: None,
397                    expanded: None,
398                },
399                named_directory_icons: HashMap::default(),
400                chevron_icons: ChevronIcons {
401                    collapsed: None,
402                    expanded: None,
403                },
404                file_icons: HashMap::default(),
405                file_stems: HashMap::default(),
406                file_suffixes: HashMap::default(),
407            };
408            registry.register_test_icon_themes([
409                make_icon_theme("Test Icons A", Appearance::Dark),
410                make_icon_theme("Test Icons B", Appearance::Dark),
411            ]);
412        });
413    }
414
415    async fn setup_test(cx: &mut TestAppContext) -> Arc<workspace::AppState> {
416        let app_state = init_test(cx);
417        register_test_icon_themes(cx);
418        app_state
419            .fs
420            .as_fake()
421            .insert_tree(path!("/test"), json!({}))
422            .await;
423        app_state
424    }
425
426    fn open_icon_theme_selector(
427        workspace: &Entity<workspace::Workspace>,
428        cx: &mut VisualTestContext,
429    ) -> Entity<Picker<IconThemeSelectorDelegate>> {
430        cx.dispatch_action(zed_actions::icon_theme_selector::Toggle {
431            themes_filter: None,
432        });
433        cx.run_until_parked();
434        workspace.update(cx, |workspace, cx| {
435            workspace
436                .active_modal::<IconThemeSelector>(cx)
437                .expect("icon theme selector should be open")
438                .read(cx)
439                .picker
440                .clone()
441        })
442    }
443
444    fn selected_theme_name(
445        picker: &Entity<Picker<IconThemeSelectorDelegate>>,
446        cx: &mut VisualTestContext,
447    ) -> String {
448        picker.read_with(cx, |picker, _| {
449            picker
450                .delegate
451                .matches
452                .get(picker.delegate.selected_index)
453                .expect("selected index should point to a match")
454                .string
455                .clone()
456        })
457    }
458
459    fn previewed_theme_name(
460        _picker: &Entity<Picker<IconThemeSelectorDelegate>>,
461        cx: &mut VisualTestContext,
462    ) -> String {
463        cx.read(|cx| {
464            ThemeSettings::get_global(cx)
465                .icon_theme
466                .name(SystemAppearance::global(cx).0)
467                .0
468                .to_string()
469        })
470    }
471
472    #[gpui::test]
473    async fn test_icon_theme_selector_preserves_selection_on_empty_filter(cx: &mut TestAppContext) {
474        let app_state = setup_test(cx).await;
475        let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await;
476        let (multi_workspace, cx) =
477            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
478        let workspace =
479            multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone());
480        let picker = open_icon_theme_selector(&workspace, cx);
481
482        let target_index = picker.read_with(cx, |picker, _| {
483            picker
484                .delegate
485                .matches
486                .iter()
487                .position(|m| m.string == "Test Icons A")
488                .unwrap()
489        });
490        picker.update_in(cx, |picker, window, cx| {
491            picker.set_selected_index(target_index, None, true, window, cx);
492        });
493        cx.run_until_parked();
494
495        assert_eq!(previewed_theme_name(&picker, cx), "Test Icons A");
496
497        picker.update_in(cx, |picker, window, cx| {
498            picker.update_matches("zzz".to_string(), window, cx);
499        });
500        cx.run_until_parked();
501
502        picker.update_in(cx, |picker, window, cx| {
503            picker.update_matches("".to_string(), window, cx);
504        });
505        cx.run_until_parked();
506
507        assert_eq!(
508            selected_theme_name(&picker, cx),
509            "Test Icons A",
510            "selected icon theme should be preserved after clearing an empty filter"
511        );
512        assert_eq!(
513            previewed_theme_name(&picker, cx),
514            "Test Icons A",
515            "previewed icon theme should be preserved after clearing an empty filter"
516        );
517    }
518}
519
Served at tenant.openagents/omega Member data and write actions are omitted.