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