Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T01:32:26.430Z 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

theme_selector.rs

721 lines · 23.8 KB · rust
1mod icon_theme_selector;
2
3use fs::Fs;
4use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
5use gpui::{
6    App, Context, DismissEvent, Entity, EventEmitter, Focusable, Render, UpdateGlobal, WeakEntity,
7    Window, actions,
8};
9use picker::{Picker, PickerDelegate};
10use settings::{Settings, SettingsStore, update_settings_file};
11use std::sync::Arc;
12use theme::{Appearance, SystemAppearance, Theme, ThemeMeta, ThemeRegistry};
13use theme_settings::{
14    ThemeAppearanceMode, ThemeName, ThemeSelection, ThemeSettings, appearance_to_mode,
15};
16use ui::{ListItem, ListItemSpacing, prelude::*, v_flex};
17use util::ResultExt;
18use workspace::{ModalView, Workspace, ui::HighlightedLabel, with_active_or_new_workspace};
19use zed_actions::{ExtensionCategoryFilter, Extensions};
20
21use crate::icon_theme_selector::{IconThemeSelector, IconThemeSelectorDelegate};
22
23actions!(
24    theme_selector,
25    [
26        /// Reloads all themes from disk.
27        Reload
28    ]
29);
30
31pub fn init(cx: &mut App) {
32    cx.on_action(|action: &zed_actions::theme_selector::Toggle, cx| {
33        let action = action.clone();
34        with_active_or_new_workspace(cx, move |workspace, window, cx| {
35            toggle_theme_selector(workspace, &action, window, cx);
36        });
37    });
38    cx.on_action(|action: &zed_actions::icon_theme_selector::Toggle, cx| {
39        let action = action.clone();
40        with_active_or_new_workspace(cx, move |workspace, window, cx| {
41            toggle_icon_theme_selector(workspace, &action, window, cx);
42        });
43    });
44}
45
46fn toggle_theme_selector(
47    workspace: &mut Workspace,
48    toggle: &zed_actions::theme_selector::Toggle,
49    window: &mut Window,
50    cx: &mut Context<Workspace>,
51) {
52    let fs = workspace.app_state().fs.clone();
53    workspace.toggle_modal(window, cx, |window, cx| {
54        let delegate = ThemeSelectorDelegate::new(
55            cx.entity().downgrade(),
56            fs,
57            toggle.themes_filter.as_ref(),
58            cx,
59        );
60        ThemeSelector::new(delegate, window, cx)
61    });
62}
63
64fn toggle_icon_theme_selector(
65    workspace: &mut Workspace,
66    toggle: &zed_actions::icon_theme_selector::Toggle,
67    window: &mut Window,
68    cx: &mut Context<Workspace>,
69) {
70    let fs = workspace.app_state().fs.clone();
71    workspace.toggle_modal(window, cx, |window, cx| {
72        let delegate = IconThemeSelectorDelegate::new(
73            cx.entity().downgrade(),
74            fs,
75            toggle.themes_filter.as_ref(),
76            cx,
77        );
78        IconThemeSelector::new(delegate, window, cx)
79    });
80}
81
82impl ModalView for ThemeSelector {
83    fn on_before_dismiss(
84        &mut self,
85        _window: &mut Window,
86        cx: &mut Context<Self>,
87    ) -> workspace::DismissDecision {
88        self.picker.update(cx, |picker, cx| {
89            picker.delegate.revert_theme(cx);
90        });
91        workspace::DismissDecision::Dismiss(true)
92    }
93}
94
95struct ThemeSelector {
96    picker: Entity<Picker<ThemeSelectorDelegate>>,
97}
98
99impl EventEmitter<DismissEvent> for ThemeSelector {}
100
101impl Focusable for ThemeSelector {
102    fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
103        self.picker.focus_handle(cx)
104    }
105}
106
107impl Render for ThemeSelector {
108    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
109        v_flex()
110            .key_context("ThemeSelector")
111            .w(rems(34.))
112            .child(self.picker.clone())
113    }
114}
115
116impl ThemeSelector {
117    pub fn new(
118        delegate: ThemeSelectorDelegate,
119        window: &mut Window,
120        cx: &mut Context<Self>,
121    ) -> Self {
122        let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
123        Self { picker }
124    }
125}
126
127struct ThemeSelectorDelegate {
128    fs: Arc<dyn Fs>,
129    themes: Vec<ThemeMeta>,
130    matches: Vec<StringMatch>,
131    /// The theme that was selected before the `ThemeSelector` menu was opened.
132    ///
133    /// We use this to return back to theme that was set if the user dismisses the menu.
134    original_theme_settings: ThemeSettings,
135    /// The current system appearance.
136    original_system_appearance: Appearance,
137    /// The id of the original theme in the list of themes.
138    /// Using `Option<usize>` instead of `usize` because it's possible that the
139    /// original theme is not present in the list of themes when it is first
140    /// built, depending on the provided `themes_filter`. For example, when a
141    /// theme is installed, the `themes_filter` is set to the new theme names
142    /// and, if we used `unwrap_or(0)` as a fallback, the first theme in the
143    /// list would be shown as "active".
144    original_theme_id: Option<usize>,
145    /// The currently selected new theme.
146    new_theme: Arc<Theme>,
147    selection_completed: bool,
148    selected_theme: Option<Arc<Theme>>,
149    selected_index: usize,
150    selector: WeakEntity<ThemeSelector>,
151}
152
153impl ThemeSelectorDelegate {
154    fn new(
155        selector: WeakEntity<ThemeSelector>,
156        fs: Arc<dyn Fs>,
157        themes_filter: Option<&Vec<String>>,
158        cx: &mut Context<ThemeSelector>,
159    ) -> Self {
160        let original_theme = cx.theme().clone();
161        let original_theme_settings = ThemeSettings::get_global(cx).clone();
162        let original_system_appearance = SystemAppearance::global(cx).0;
163
164        let registry = ThemeRegistry::global(cx);
165        let mut themes = registry
166            .list()
167            .into_iter()
168            .filter(|meta| {
169                if let Some(theme_filter) = themes_filter {
170                    theme_filter.contains(&meta.name.to_string())
171                } else {
172                    true
173                }
174            })
175            .collect::<Vec<_>>();
176
177        // Sort by dark vs light, then by name.
178        themes.sort_unstable_by(|a, b| {
179            a.appearance
180                .is_light()
181                .cmp(&b.appearance.is_light())
182                .then(a.name.cmp(&b.name))
183        });
184
185        let original_theme_id = themes
186            .iter()
187            .position(|meta| meta.name == original_theme.name);
188
189        let matches: Vec<StringMatch> = themes
190            .iter()
191            .enumerate()
192            .map(|(id, meta)| StringMatch {
193                candidate_id: id,
194                score: 0.0,
195                positions: Default::default(),
196                string: meta.name.to_string(),
197            })
198            .collect();
199
200        // The current theme is likely in this list, so default to first showing that.
201        let selected_index = matches
202            .iter()
203            .position(|mat| mat.string == original_theme.name)
204            .unwrap_or(0);
205
206        Self {
207            fs,
208            themes,
209            matches,
210            original_theme_settings,
211            original_system_appearance,
212            original_theme_id,
213            new_theme: original_theme, // Start with the original theme.
214            selected_index,
215            selection_completed: false,
216            selected_theme: None,
217            selector,
218        }
219    }
220
221    fn is_original_theme(&self, index: usize) -> bool {
222        self.matches
223            .get(index)
224            .zip(self.original_theme_id)
225            .is_some_and(|(mat, original_theme_id)| mat.candidate_id == original_theme_id)
226    }
227
228    fn show_selected_theme(
229        &mut self,
230        cx: &mut Context<Picker<ThemeSelectorDelegate>>,
231    ) -> Option<Arc<Theme>> {
232        if let Some(mat) = self.matches.get(self.selected_index) {
233            let registry = ThemeRegistry::global(cx);
234
235            match registry.get(&mat.string) {
236                Ok(theme) => {
237                    self.set_theme(theme.clone(), cx);
238                    Some(theme)
239                }
240                Err(error) => {
241                    log::error!("error loading theme {}: {}", mat.string, error);
242                    None
243                }
244            }
245        } else {
246            None
247        }
248    }
249
250    fn revert_theme(&mut self, cx: &mut App) {
251        if !self.selection_completed {
252            SettingsStore::update_global(cx, |store, _| {
253                store.override_global(self.original_theme_settings.clone());
254            });
255            self.selection_completed = true;
256        }
257    }
258
259    fn set_theme(&mut self, new_theme: Arc<Theme>, cx: &mut App) {
260        // Update the global (in-memory) theme settings.
261        SettingsStore::update_global(cx, |store, _| {
262            override_global_theme(
263                store,
264                &new_theme,
265                &self.original_theme_settings.theme,
266                self.original_system_appearance,
267            )
268        });
269
270        self.new_theme = new_theme;
271    }
272}
273
274/// Overrides the global (in-memory) theme settings.
275///
276/// Note that this does **not** update the user's `settings.json` file (see the
277/// [`ThemeSelectorDelegate::confirm`] method and [`theme_settings::set_theme`] function).
278fn override_global_theme(
279    store: &mut SettingsStore,
280    new_theme: &Theme,
281    original_theme: &ThemeSelection,
282    system_appearance: Appearance,
283) {
284    let theme_name = ThemeName(new_theme.name.clone().into());
285    let new_appearance = new_theme.appearance();
286    let new_theme_is_light = new_appearance.is_light();
287
288    let mut curr_theme_settings = store.get::<ThemeSettings>(None).clone();
289
290    match (original_theme, &curr_theme_settings.theme) {
291        // Override the currently selected static theme.
292        (ThemeSelection::Static(_), ThemeSelection::Static(_)) => {
293            curr_theme_settings.theme = ThemeSelection::Static(theme_name);
294        }
295
296        // If the current theme selection is dynamic, then only override the global setting for the
297        // specific mode (light or dark).
298        (
299            ThemeSelection::Dynamic {
300                mode: original_mode,
301                light: original_light,
302                dark: original_dark,
303            },
304            ThemeSelection::Dynamic { .. },
305        ) => {
306            let new_mode = update_mode_if_new_appearance_is_different_from_system(
307                original_mode,
308                system_appearance,
309                new_appearance,
310            );
311
312            let updated_theme = retain_original_opposing_theme(
313                new_theme_is_light,
314                new_mode,
315                theme_name,
316                original_light,
317                original_dark,
318            );
319
320            curr_theme_settings.theme = updated_theme;
321        }
322
323        // The theme selection mode changed while selecting new themes (someone edited the settings
324        // file on disk while we had the dialogue open), so don't do anything.
325        _ => return,
326    };
327
328    store.override_global(curr_theme_settings);
329}
330
331/// Helper function for determining the new [`ThemeAppearanceMode`] for the new theme.
332///
333/// If the the original theme mode was [`System`] and the new theme's appearance matches the system
334/// appearance, we don't need to change the mode setting.
335///
336/// Otherwise, we need to change the mode in order to see the new theme.
337///
338/// [`System`]: ThemeAppearanceMode::System
339fn update_mode_if_new_appearance_is_different_from_system(
340    original_mode: &ThemeAppearanceMode,
341    system_appearance: Appearance,
342    new_appearance: Appearance,
343) -> ThemeAppearanceMode {
344    if original_mode == &ThemeAppearanceMode::System && system_appearance == new_appearance {
345        ThemeAppearanceMode::System
346    } else {
347        appearance_to_mode(new_appearance)
348    }
349}
350
351/// Helper function for updating / displaying the [`ThemeSelection`] while using the theme selector.
352///
353/// We want to retain the alternate theme selection of the original settings (before the menu was
354/// opened), not the currently selected theme (which likely has changed multiple times while the
355/// menu has been open).
356fn retain_original_opposing_theme(
357    new_theme_is_light: bool,
358    new_mode: ThemeAppearanceMode,
359    theme_name: ThemeName,
360    original_light: &ThemeName,
361    original_dark: &ThemeName,
362) -> ThemeSelection {
363    if new_theme_is_light {
364        ThemeSelection::Dynamic {
365            mode: new_mode,
366            light: theme_name,
367            dark: original_dark.clone(),
368        }
369    } else {
370        ThemeSelection::Dynamic {
371            mode: new_mode,
372            light: original_light.clone(),
373            dark: theme_name,
374        }
375    }
376}
377
378impl PickerDelegate for ThemeSelectorDelegate {
379    type ListItem = ui::ListItem;
380
381    fn name() -> &'static str {
382        "theme selector"
383    }
384
385    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
386        "Select Theme...".into()
387    }
388
389    fn match_count(&self) -> usize {
390        self.matches.len()
391    }
392
393    fn confirm(
394        &mut self,
395        _secondary: bool,
396        _window: &mut Window,
397        cx: &mut Context<Picker<ThemeSelectorDelegate>>,
398    ) {
399        self.selection_completed = true;
400
401        let theme_name: Arc<str> = self.new_theme.name.as_str().into();
402        let theme_appearance = self.new_theme.appearance;
403        let system_appearance = SystemAppearance::global(cx).0;
404
405        telemetry::event!("Settings Changed", setting = "theme", value = theme_name);
406
407        update_settings_file(self.fs.clone(), cx, move |settings, _| {
408            theme_settings::set_theme(settings, theme_name, theme_appearance, system_appearance);
409        });
410
411        self.selector
412            .update(cx, |_, cx| {
413                cx.emit(DismissEvent);
414            })
415            .ok();
416    }
417
418    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<ThemeSelectorDelegate>>) {
419        self.revert_theme(cx);
420
421        self.selector
422            .update(cx, |_, cx| cx.emit(DismissEvent))
423            .log_err();
424    }
425
426    fn selected_index(&self) -> usize {
427        self.selected_index
428    }
429
430    fn set_selected_index(
431        &mut self,
432        ix: usize,
433        _: &mut Window,
434        cx: &mut Context<Picker<ThemeSelectorDelegate>>,
435    ) {
436        self.selected_index = ix;
437        self.selected_theme = self.show_selected_theme(cx);
438    }
439
440    fn update_matches(
441        &mut self,
442        query: String,
443        window: &mut Window,
444        cx: &mut Context<Picker<ThemeSelectorDelegate>>,
445    ) -> gpui::Task<()> {
446        let background = cx.background_executor().clone();
447        let candidates = self
448            .themes
449            .iter()
450            .enumerate()
451            .map(|(id, meta)| StringMatchCandidate::new(id, &meta.name))
452            .collect::<Vec<_>>();
453
454        cx.spawn_in(window, async move |this, cx| {
455            let matches = if query.is_empty() {
456                candidates
457                    .into_iter()
458                    .enumerate()
459                    .map(|(index, candidate)| StringMatch {
460                        candidate_id: index,
461                        string: candidate.string,
462                        positions: Vec::new(),
463                        score: 0.0,
464                    })
465                    .collect()
466            } else {
467                match_strings(
468                    &candidates,
469                    &query,
470                    false,
471                    true,
472                    100,
473                    &Default::default(),
474                    background,
475                )
476                .await
477            };
478
479            this.update(cx, |this, cx| {
480                this.delegate.matches = matches;
481                if query.is_empty() && this.delegate.selected_theme.is_none() {
482                    this.delegate.selected_index = this
483                        .delegate
484                        .selected_index
485                        .min(this.delegate.matches.len().saturating_sub(1));
486                } else if let Some(selected) = this.delegate.selected_theme.as_ref() {
487                    this.delegate.selected_index = this
488                        .delegate
489                        .matches
490                        .iter()
491                        .enumerate()
492                        .find(|(_, mtch)| mtch.string == selected.name)
493                        .map(|(ix, _)| ix)
494                        .unwrap_or_default();
495                } else {
496                    this.delegate.selected_index = 0;
497                }
498                // Preserve the previously selected theme when the filter yields no results.
499                if let Some(theme) = this.delegate.show_selected_theme(cx) {
500                    this.delegate.selected_theme = Some(theme);
501                }
502            })
503            .log_err();
504        })
505    }
506
507    fn render_match(
508        &self,
509        ix: usize,
510        selected: bool,
511        _window: &mut Window,
512        _cx: &mut Context<Picker<Self>>,
513    ) -> Option<Self::ListItem> {
514        let theme_match = &self.matches.get(ix)?;
515        let is_original_theme = self.is_original_theme(ix);
516
517        Some(
518            ListItem::new(ix)
519                .inset(true)
520                .spacing(ListItemSpacing::Sparse)
521                .toggle_state(selected)
522                .child(HighlightedLabel::new(
523                    theme_match.string.clone(),
524                    theme_match.positions.clone(),
525                ))
526                .when(is_original_theme, |this| {
527                    this.end_slot(Icon::new(IconName::Check).color(Color::Muted))
528                }),
529        )
530    }
531
532    fn render_footer(
533        &self,
534        _: &mut Window,
535        cx: &mut Context<Picker<Self>>,
536    ) -> Option<gpui::AnyElement> {
537        Some(
538            h_flex()
539                .p_2()
540                .w_full()
541                .justify_between()
542                .gap_2()
543                .border_t_1()
544                .border_color(cx.theme().colors().border_variant)
545                .child(
546                    Button::new("docs", "View Theme Docs")
547                        .end_icon(
548                            Icon::new(IconName::ArrowUpRight)
549                                .size(IconSize::Small)
550                                .color(Color::Muted),
551                        )
552                        .on_click(cx.listener(|_, _, _, cx| {
553                            cx.open_url("https://zed.dev/docs/themes");
554                        })),
555                )
556                .child(
557                    Button::new("more-themes", "Install Themes").on_click(cx.listener({
558                        move |_, _, window, cx| {
559                            window.dispatch_action(
560                                Box::new(Extensions {
561                                    category_filter: Some(ExtensionCategoryFilter::Themes),
562                                    id: None,
563                                }),
564                                cx,
565                            );
566                        }
567                    })),
568                )
569                .into_any_element(),
570        )
571    }
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577    use gpui::{TestAppContext, VisualTestContext};
578    use project::Project;
579    use serde_json::json;
580    use theme::{Appearance, ThemeFamily, ThemeRegistry, default_color_scales};
581    use util::path;
582    use workspace::MultiWorkspace;
583
584    fn init_test(cx: &mut TestAppContext) -> Arc<workspace::AppState> {
585        cx.update(|cx| {
586            let app_state = workspace::AppState::test(cx);
587            settings::init(cx);
588            theme::init(theme::LoadThemes::JustBase, cx);
589            editor::init(cx);
590            super::init(cx);
591            app_state
592        })
593    }
594
595    fn register_test_themes(cx: &mut TestAppContext) {
596        cx.update(|cx| {
597            let registry = ThemeRegistry::global(cx);
598            let base_theme = registry.get("One Dark").unwrap();
599
600            let mut test_light = (*base_theme).clone();
601            test_light.id = "test-light".to_string();
602            test_light.name = "Test Light".into();
603            test_light.appearance = Appearance::Light;
604
605            let mut test_dark_a = (*base_theme).clone();
606            test_dark_a.id = "test-dark-a".to_string();
607            test_dark_a.name = "Test Dark A".into();
608
609            let mut test_dark_b = (*base_theme).clone();
610            test_dark_b.id = "test-dark-b".to_string();
611            test_dark_b.name = "Test Dark B".into();
612
613            registry.register_test_themes([ThemeFamily {
614                id: "test-family".to_string(),
615                name: "Test Family".into(),
616                author: "test".into(),
617                themes: vec![test_light, test_dark_a, test_dark_b],
618                scales: default_color_scales(),
619            }]);
620        });
621    }
622
623    async fn setup_test(cx: &mut TestAppContext) -> Arc<workspace::AppState> {
624        let app_state = init_test(cx);
625        register_test_themes(cx);
626        app_state
627            .fs
628            .as_fake()
629            .insert_tree(path!("/test"), json!({}))
630            .await;
631        app_state
632    }
633
634    fn open_theme_selector(
635        workspace: &Entity<workspace::Workspace>,
636        cx: &mut VisualTestContext,
637    ) -> Entity<Picker<ThemeSelectorDelegate>> {
638        cx.dispatch_action(zed_actions::theme_selector::Toggle {
639            themes_filter: None,
640        });
641        cx.run_until_parked();
642        workspace.update(cx, |workspace, cx| {
643            workspace
644                .active_modal::<ThemeSelector>(cx)
645                .expect("theme selector should be open")
646                .read(cx)
647                .picker
648                .clone()
649        })
650    }
651
652    fn selected_theme_name(
653        picker: &Entity<Picker<ThemeSelectorDelegate>>,
654        cx: &mut VisualTestContext,
655    ) -> String {
656        picker.read_with(cx, |picker, _| {
657            picker
658                .delegate
659                .matches
660                .get(picker.delegate.selected_index)
661                .expect("selected index should point to a match")
662                .string
663                .clone()
664        })
665    }
666
667    fn previewed_theme_name(
668        picker: &Entity<Picker<ThemeSelectorDelegate>>,
669        cx: &mut VisualTestContext,
670    ) -> String {
671        picker.read_with(cx, |picker, _| picker.delegate.new_theme.name.to_string())
672    }
673
674    #[gpui::test]
675    async fn test_theme_selector_preserves_selection_on_empty_filter(cx: &mut TestAppContext) {
676        let app_state = setup_test(cx).await;
677        let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await;
678        let (multi_workspace, cx) =
679            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
680        let workspace =
681            multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone());
682        let picker = open_theme_selector(&workspace, cx);
683
684        let target_index = picker.read_with(cx, |picker, _| {
685            picker
686                .delegate
687                .matches
688                .iter()
689                .position(|m| m.string == "Test Light")
690                .unwrap()
691        });
692        picker.update_in(cx, |picker, window, cx| {
693            picker.set_selected_index(target_index, None, true, window, cx);
694        });
695        cx.run_until_parked();
696
697        assert_eq!(previewed_theme_name(&picker, cx), "Test Light");
698
699        picker.update_in(cx, |picker, window, cx| {
700            picker.update_matches("zzz".to_string(), window, cx);
701        });
702        cx.run_until_parked();
703
704        picker.update_in(cx, |picker, window, cx| {
705            picker.update_matches("".to_string(), window, cx);
706        });
707        cx.run_until_parked();
708
709        assert_eq!(
710            selected_theme_name(&picker, cx),
711            "Test Light",
712            "selected theme should be preserved after clearing an empty filter"
713        );
714        assert_eq!(
715            previewed_theme_name(&picker, cx),
716            "Test Light",
717            "previewed theme should be preserved after clearing an empty filter"
718        );
719    }
720}
721
Served at tenant.openagents/omega Member data and write actions are omitted.