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::{
 13    Appearance, SystemAppearance, Theme, ThemeAppearanceMode, ThemeMeta, ThemeName, ThemeRegistry,
 14    ThemeSelection, ThemeSettings,
 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::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        ThemeAppearanceMode::from(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::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                this.delegate.selected_theme = this.delegate.show_selected_theme(cx);
459            })
460            .log_err();
461        })
462    }
463
464    fn render_match(
465        &self,
466        ix: usize,
467        selected: bool,
468        _window: &mut Window,
469        _cx: &mut Context<Picker<Self>>,
470    ) -> Option<Self::ListItem> {
471        let theme_match = &self.matches.get(ix)?;
472
473        Some(
474            ListItem::new(ix)
475                .inset(true)
476                .spacing(ListItemSpacing::Sparse)
477                .toggle_state(selected)
478                .child(HighlightedLabel::new(
479                    theme_match.string.clone(),
480                    theme_match.positions.clone(),
481                )),
482        )
483    }
484
485    fn render_footer(
486        &self,
487        _: &mut Window,
488        cx: &mut Context<Picker<Self>>,
489    ) -> Option<gpui::AnyElement> {
490        Some(
491            h_flex()
492                .p_2()
493                .w_full()
494                .justify_between()
495                .gap_2()
496                .border_t_1()
497                .border_color(cx.theme().colors().border_variant)
498                .child(
499                    Button::new("docs", "View Theme Docs")
500                        .icon(IconName::ArrowUpRight)
501                        .icon_position(IconPosition::End)
502                        .icon_size(IconSize::Small)
503                        .icon_color(Color::Muted)
504                        .on_click(cx.listener(|_, _, _, cx| {
505                            cx.open_url("https://zed.dev/docs/themes");
506                        })),
507                )
508                .child(
509                    Button::new("more-themes", "Install Themes").on_click(cx.listener({
510                        move |_, _, window, cx| {
511                            window.dispatch_action(
512                                Box::new(Extensions {
513                                    category_filter: Some(ExtensionCategoryFilter::Themes),
514                                    id: None,
515                                }),
516                                cx,
517                            );
518                        }
519                    })),
520                )
521                .into_any_element(),
522        )
523    }
524}