theme_selector.rs

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