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}