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