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}