Skip to repository content519 lines · 16.8 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T01:33:19.914Z 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
icon_theme_selector.rs
1use fs::Fs;
2use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
3use gpui::{
4 App, Context, DismissEvent, Entity, EventEmitter, Focusable, Render, UpdateGlobal, WeakEntity,
5 Window,
6};
7use picker::{Picker, PickerDelegate};
8use settings::{Settings as _, SettingsStore, update_settings_file};
9use std::sync::Arc;
10use theme::{Appearance, SystemAppearance, ThemeMeta, ThemeRegistry};
11use theme_settings::{IconThemeName, IconThemeSelection, ThemeSettings};
12use ui::{ListItem, ListItemSpacing, prelude::*, v_flex};
13use util::ResultExt;
14use workspace::{ModalView, ui::HighlightedLabel};
15use zed_actions::{ExtensionCategoryFilter, Extensions};
16
17pub(crate) struct IconThemeSelector {
18 picker: Entity<Picker<IconThemeSelectorDelegate>>,
19}
20
21impl EventEmitter<DismissEvent> for IconThemeSelector {}
22
23impl Focusable for IconThemeSelector {
24 fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
25 self.picker.focus_handle(cx)
26 }
27}
28
29impl ModalView for IconThemeSelector {
30 fn on_before_dismiss(
31 &mut self,
32 _window: &mut Window,
33 cx: &mut Context<Self>,
34 ) -> workspace::DismissDecision {
35 self.picker.update(cx, |picker, cx| {
36 picker.delegate.revert_theme(cx);
37 });
38 workspace::DismissDecision::Dismiss(true)
39 }
40}
41
42impl IconThemeSelector {
43 pub fn new(
44 delegate: IconThemeSelectorDelegate,
45 window: &mut Window,
46 cx: &mut Context<Self>,
47 ) -> Self {
48 let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
49 Self { picker }
50 }
51}
52
53impl Render for IconThemeSelector {
54 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
55 v_flex()
56 .key_context("IconThemeSelector")
57 .w(rems(34.))
58 .child(self.picker.clone())
59 }
60}
61
62pub(crate) struct IconThemeSelectorDelegate {
63 fs: Arc<dyn Fs>,
64 themes: Vec<ThemeMeta>,
65 matches: Vec<StringMatch>,
66 original_theme: IconThemeName,
67 selection_completed: bool,
68 selected_theme: Option<IconThemeName>,
69 selected_index: usize,
70 selector: WeakEntity<IconThemeSelector>,
71}
72
73impl IconThemeSelectorDelegate {
74 pub fn new(
75 selector: WeakEntity<IconThemeSelector>,
76 fs: Arc<dyn Fs>,
77 themes_filter: Option<&Vec<String>>,
78 cx: &mut Context<IconThemeSelector>,
79 ) -> Self {
80 let theme_settings = ThemeSettings::get_global(cx);
81 let original_theme = theme_settings
82 .icon_theme
83 .name(SystemAppearance::global(cx).0);
84
85 let registry = ThemeRegistry::global(cx);
86 let mut themes = registry
87 .list_icon_themes()
88 .into_iter()
89 .filter(|meta| {
90 if let Some(theme_filter) = themes_filter {
91 theme_filter.contains(&meta.name.to_string())
92 } else {
93 true
94 }
95 })
96 .collect::<Vec<_>>();
97
98 themes.sort_unstable_by(|a, b| {
99 a.appearance
100 .is_light()
101 .cmp(&b.appearance.is_light())
102 .then(a.name.cmp(&b.name))
103 });
104 let matches = themes
105 .iter()
106 .map(|meta| StringMatch {
107 candidate_id: 0,
108 score: 0.0,
109 positions: Default::default(),
110 string: meta.name.to_string(),
111 })
112 .collect();
113 let mut this = Self {
114 fs,
115 themes,
116 matches,
117 original_theme: original_theme.clone(),
118 selected_index: 0,
119 selected_theme: None,
120 selection_completed: false,
121 selector,
122 };
123
124 this.select_if_matching(&original_theme.0);
125 this
126 }
127
128 fn show_selected_theme(
129 &mut self,
130 cx: &mut Context<Picker<IconThemeSelectorDelegate>>,
131 ) -> Option<IconThemeName> {
132 let mat = self.matches.get(self.selected_index)?;
133 let name = IconThemeName(mat.string.clone().into());
134 Self::set_icon_theme(name.clone(), cx);
135 Some(name)
136 }
137
138 fn select_if_matching(&mut self, theme_name: &str) {
139 self.selected_index = self
140 .matches
141 .iter()
142 .position(|mat| mat.string == theme_name)
143 .unwrap_or(self.selected_index);
144 }
145
146 fn revert_theme(&mut self, cx: &mut App) {
147 if !self.selection_completed {
148 Self::set_icon_theme(self.original_theme.clone(), cx);
149 self.selection_completed = true;
150 }
151 }
152
153 fn set_icon_theme(name: IconThemeName, cx: &mut App) {
154 SettingsStore::update_global(cx, |store, _| {
155 let mut theme_settings = store.get::<ThemeSettings>(None).clone();
156 theme_settings.icon_theme = IconThemeSelection::Static(name);
157 store.override_global(theme_settings);
158 });
159 }
160}
161
162impl PickerDelegate for IconThemeSelectorDelegate {
163 type ListItem = ui::ListItem;
164
165 fn name() -> &'static str {
166 "icon theme selector"
167 }
168
169 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
170 "Select Icon Theme...".into()
171 }
172
173 fn match_count(&self) -> usize {
174 self.matches.len()
175 }
176
177 fn confirm(
178 &mut self,
179 _: bool,
180 window: &mut Window,
181 cx: &mut Context<Picker<IconThemeSelectorDelegate>>,
182 ) {
183 self.selection_completed = true;
184
185 let theme_settings = ThemeSettings::get_global(cx);
186 let theme_name = theme_settings
187 .icon_theme
188 .name(SystemAppearance::global(cx).0);
189
190 telemetry::event!(
191 "Settings Changed",
192 setting = "icon_theme",
193 value = theme_name
194 );
195
196 let appearance = Appearance::from(window.appearance());
197
198 update_settings_file(self.fs.clone(), cx, move |settings, _| {
199 theme_settings::set_icon_theme(settings, theme_name, appearance);
200 });
201
202 self.selector
203 .update(cx, |_, cx| {
204 cx.emit(DismissEvent);
205 })
206 .ok();
207 }
208
209 fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<IconThemeSelectorDelegate>>) {
210 self.revert_theme(cx);
211
212 self.selector
213 .update(cx, |_, cx| cx.emit(DismissEvent))
214 .log_err();
215 }
216
217 fn selected_index(&self) -> usize {
218 self.selected_index
219 }
220
221 fn set_selected_index(
222 &mut self,
223 ix: usize,
224 _: &mut Window,
225 cx: &mut Context<Picker<IconThemeSelectorDelegate>>,
226 ) {
227 self.selected_index = ix;
228 self.selected_theme = self.show_selected_theme(cx);
229 }
230
231 fn update_matches(
232 &mut self,
233 query: String,
234 window: &mut Window,
235 cx: &mut Context<Picker<IconThemeSelectorDelegate>>,
236 ) -> gpui::Task<()> {
237 let background = cx.background_executor().clone();
238 let candidates = self
239 .themes
240 .iter()
241 .enumerate()
242 .map(|(id, meta)| StringMatchCandidate::new(id, &meta.name))
243 .collect::<Vec<_>>();
244
245 cx.spawn_in(window, async move |this, cx| {
246 let matches = if query.is_empty() {
247 candidates
248 .into_iter()
249 .enumerate()
250 .map(|(index, candidate)| StringMatch {
251 candidate_id: index,
252 string: candidate.string,
253 positions: Vec::new(),
254 score: 0.0,
255 })
256 .collect()
257 } else {
258 match_strings(
259 &candidates,
260 &query,
261 false,
262 true,
263 100,
264 &Default::default(),
265 background,
266 )
267 .await
268 };
269
270 this.update(cx, |this, cx| {
271 this.delegate.matches = matches;
272 if query.is_empty() && this.delegate.selected_theme.is_none() {
273 this.delegate.selected_index = this
274 .delegate
275 .selected_index
276 .min(this.delegate.matches.len().saturating_sub(1));
277 } else if let Some(selected) = this.delegate.selected_theme.as_ref() {
278 this.delegate.selected_index = this
279 .delegate
280 .matches
281 .iter()
282 .enumerate()
283 .find(|(_, mtch)| mtch.string.as_str() == selected.0.as_ref())
284 .map(|(ix, _)| ix)
285 .unwrap_or_default();
286 } else {
287 this.delegate.selected_index = 0;
288 }
289 // Preserve the previously selected theme when the filter yields no results.
290 if let Some(theme) = this.delegate.show_selected_theme(cx) {
291 this.delegate.selected_theme = Some(theme);
292 }
293 })
294 .log_err();
295 })
296 }
297
298 fn render_match(
299 &self,
300 ix: usize,
301 selected: bool,
302 _window: &mut Window,
303 _cx: &mut Context<Picker<Self>>,
304 ) -> Option<Self::ListItem> {
305 let theme_match = &self.matches.get(ix)?;
306 let is_original_theme = theme_match.string.as_str() == self.original_theme.0.as_ref();
307
308 Some(
309 ListItem::new(ix)
310 .inset(true)
311 .spacing(ListItemSpacing::Sparse)
312 .toggle_state(selected)
313 .child(HighlightedLabel::new(
314 theme_match.string.clone(),
315 theme_match.positions.clone(),
316 ))
317 .when(is_original_theme, |this| {
318 this.end_slot(Icon::new(IconName::Check).color(Color::Muted))
319 }),
320 )
321 }
322
323 fn render_footer(
324 &self,
325 _window: &mut Window,
326 cx: &mut Context<Picker<Self>>,
327 ) -> Option<gpui::AnyElement> {
328 Some(
329 h_flex()
330 .p_2()
331 .w_full()
332 .justify_between()
333 .gap_2()
334 .border_t_1()
335 .border_color(cx.theme().colors().border_variant)
336 .child(
337 Button::new("docs", "View Icon Theme Docs")
338 .end_icon(
339 Icon::new(IconName::ArrowUpRight)
340 .size(IconSize::Small)
341 .color(Color::Muted),
342 )
343 .on_click(|_event, _window, cx| {
344 cx.open_url("https://zed.dev/docs/icon-themes");
345 }),
346 )
347 .child(
348 Button::new("more-icon-themes", "Install Icon Themes").on_click(
349 move |_event, window, cx| {
350 window.dispatch_action(
351 Box::new(Extensions {
352 category_filter: Some(ExtensionCategoryFilter::IconThemes),
353 id: None,
354 }),
355 cx,
356 );
357 },
358 ),
359 )
360 .into_any_element(),
361 )
362 }
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368 use std::collections::HashMap;
369
370 use gpui::{TestAppContext, VisualTestContext};
371 use project::Project;
372 use serde_json::json;
373 use theme::{ChevronIcons, DirectoryIcons, IconTheme, ThemeRegistry};
374 use util::path;
375 use workspace::MultiWorkspace;
376
377 fn init_test(cx: &mut TestAppContext) -> Arc<workspace::AppState> {
378 cx.update(|cx| {
379 let app_state = workspace::AppState::test(cx);
380 settings::init(cx);
381 theme::init(theme::LoadThemes::JustBase, cx);
382 editor::init(cx);
383 crate::init(cx);
384 app_state
385 })
386 }
387
388 fn register_test_icon_themes(cx: &mut TestAppContext) {
389 cx.update(|cx| {
390 let registry = ThemeRegistry::global(cx);
391 let make_icon_theme = |name: &str, appearance: Appearance| IconTheme {
392 id: name.to_lowercase().replace(' ', "-"),
393 name: SharedString::from(name.to_string()),
394 appearance,
395 directory_icons: DirectoryIcons {
396 collapsed: None,
397 expanded: None,
398 },
399 named_directory_icons: HashMap::default(),
400 chevron_icons: ChevronIcons {
401 collapsed: None,
402 expanded: None,
403 },
404 file_icons: HashMap::default(),
405 file_stems: HashMap::default(),
406 file_suffixes: HashMap::default(),
407 };
408 registry.register_test_icon_themes([
409 make_icon_theme("Test Icons A", Appearance::Dark),
410 make_icon_theme("Test Icons B", Appearance::Dark),
411 ]);
412 });
413 }
414
415 async fn setup_test(cx: &mut TestAppContext) -> Arc<workspace::AppState> {
416 let app_state = init_test(cx);
417 register_test_icon_themes(cx);
418 app_state
419 .fs
420 .as_fake()
421 .insert_tree(path!("/test"), json!({}))
422 .await;
423 app_state
424 }
425
426 fn open_icon_theme_selector(
427 workspace: &Entity<workspace::Workspace>,
428 cx: &mut VisualTestContext,
429 ) -> Entity<Picker<IconThemeSelectorDelegate>> {
430 cx.dispatch_action(zed_actions::icon_theme_selector::Toggle {
431 themes_filter: None,
432 });
433 cx.run_until_parked();
434 workspace.update(cx, |workspace, cx| {
435 workspace
436 .active_modal::<IconThemeSelector>(cx)
437 .expect("icon theme selector should be open")
438 .read(cx)
439 .picker
440 .clone()
441 })
442 }
443
444 fn selected_theme_name(
445 picker: &Entity<Picker<IconThemeSelectorDelegate>>,
446 cx: &mut VisualTestContext,
447 ) -> String {
448 picker.read_with(cx, |picker, _| {
449 picker
450 .delegate
451 .matches
452 .get(picker.delegate.selected_index)
453 .expect("selected index should point to a match")
454 .string
455 .clone()
456 })
457 }
458
459 fn previewed_theme_name(
460 _picker: &Entity<Picker<IconThemeSelectorDelegate>>,
461 cx: &mut VisualTestContext,
462 ) -> String {
463 cx.read(|cx| {
464 ThemeSettings::get_global(cx)
465 .icon_theme
466 .name(SystemAppearance::global(cx).0)
467 .0
468 .to_string()
469 })
470 }
471
472 #[gpui::test]
473 async fn test_icon_theme_selector_preserves_selection_on_empty_filter(cx: &mut TestAppContext) {
474 let app_state = setup_test(cx).await;
475 let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await;
476 let (multi_workspace, cx) =
477 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
478 let workspace =
479 multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone());
480 let picker = open_icon_theme_selector(&workspace, cx);
481
482 let target_index = picker.read_with(cx, |picker, _| {
483 picker
484 .delegate
485 .matches
486 .iter()
487 .position(|m| m.string == "Test Icons A")
488 .unwrap()
489 });
490 picker.update_in(cx, |picker, window, cx| {
491 picker.set_selected_index(target_index, None, true, window, cx);
492 });
493 cx.run_until_parked();
494
495 assert_eq!(previewed_theme_name(&picker, cx), "Test Icons A");
496
497 picker.update_in(cx, |picker, window, cx| {
498 picker.update_matches("zzz".to_string(), window, cx);
499 });
500 cx.run_until_parked();
501
502 picker.update_in(cx, |picker, window, cx| {
503 picker.update_matches("".to_string(), window, cx);
504 });
505 cx.run_until_parked();
506
507 assert_eq!(
508 selected_theme_name(&picker, cx),
509 "Test Icons A",
510 "selected icon theme should be preserved after clearing an empty filter"
511 );
512 assert_eq!(
513 previewed_theme_name(&picker, cx),
514 "Test Icons A",
515 "previewed icon theme should be preserved after clearing an empty filter"
516 );
517 }
518}
519