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 placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
166 "Select Icon Theme...".into()
167 }
168
169 fn match_count(&self) -> usize {
170 self.matches.len()
171 }
172
173 fn confirm(
174 &mut self,
175 _: bool,
176 window: &mut Window,
177 cx: &mut Context<Picker<IconThemeSelectorDelegate>>,
178 ) {
179 self.selection_completed = true;
180
181 let theme_settings = ThemeSettings::get_global(cx);
182 let theme_name = theme_settings
183 .icon_theme
184 .name(SystemAppearance::global(cx).0);
185
186 telemetry::event!(
187 "Settings Changed",
188 setting = "icon_theme",
189 value = theme_name
190 );
191
192 let appearance = Appearance::from(window.appearance());
193
194 update_settings_file(self.fs.clone(), cx, move |settings, _| {
195 theme_settings::set_icon_theme(settings, theme_name, appearance);
196 });
197
198 self.selector
199 .update(cx, |_, cx| {
200 cx.emit(DismissEvent);
201 })
202 .ok();
203 }
204
205 fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<IconThemeSelectorDelegate>>) {
206 self.revert_theme(cx);
207
208 self.selector
209 .update(cx, |_, cx| cx.emit(DismissEvent))
210 .log_err();
211 }
212
213 fn selected_index(&self) -> usize {
214 self.selected_index
215 }
216
217 fn set_selected_index(
218 &mut self,
219 ix: usize,
220 _: &mut Window,
221 cx: &mut Context<Picker<IconThemeSelectorDelegate>>,
222 ) {
223 self.selected_index = ix;
224 self.selected_theme = self.show_selected_theme(cx);
225 }
226
227 fn update_matches(
228 &mut self,
229 query: String,
230 window: &mut Window,
231 cx: &mut Context<Picker<IconThemeSelectorDelegate>>,
232 ) -> gpui::Task<()> {
233 let background = cx.background_executor().clone();
234 let candidates = self
235 .themes
236 .iter()
237 .enumerate()
238 .map(|(id, meta)| StringMatchCandidate::new(id, &meta.name))
239 .collect::<Vec<_>>();
240
241 cx.spawn_in(window, async move |this, cx| {
242 let matches = if query.is_empty() {
243 candidates
244 .into_iter()
245 .enumerate()
246 .map(|(index, candidate)| StringMatch {
247 candidate_id: index,
248 string: candidate.string,
249 positions: Vec::new(),
250 score: 0.0,
251 })
252 .collect()
253 } else {
254 match_strings(
255 &candidates,
256 &query,
257 false,
258 true,
259 100,
260 &Default::default(),
261 background,
262 )
263 .await
264 };
265
266 this.update(cx, |this, cx| {
267 this.delegate.matches = matches;
268 if query.is_empty() && this.delegate.selected_theme.is_none() {
269 this.delegate.selected_index = this
270 .delegate
271 .selected_index
272 .min(this.delegate.matches.len().saturating_sub(1));
273 } else if let Some(selected) = this.delegate.selected_theme.as_ref() {
274 this.delegate.selected_index = this
275 .delegate
276 .matches
277 .iter()
278 .enumerate()
279 .find(|(_, mtch)| mtch.string.as_str() == selected.0.as_ref())
280 .map(|(ix, _)| ix)
281 .unwrap_or_default();
282 } else {
283 this.delegate.selected_index = 0;
284 }
285 // Preserve the previously selected theme when the filter yields no results.
286 if let Some(theme) = this.delegate.show_selected_theme(cx) {
287 this.delegate.selected_theme = Some(theme);
288 }
289 })
290 .log_err();
291 })
292 }
293
294 fn render_match(
295 &self,
296 ix: usize,
297 selected: bool,
298 _window: &mut Window,
299 _cx: &mut Context<Picker<Self>>,
300 ) -> Option<Self::ListItem> {
301 let theme_match = &self.matches.get(ix)?;
302
303 Some(
304 ListItem::new(ix)
305 .inset(true)
306 .spacing(ListItemSpacing::Sparse)
307 .toggle_state(selected)
308 .child(HighlightedLabel::new(
309 theme_match.string.clone(),
310 theme_match.positions.clone(),
311 )),
312 )
313 }
314
315 fn render_footer(
316 &self,
317 _window: &mut Window,
318 cx: &mut Context<Picker<Self>>,
319 ) -> Option<gpui::AnyElement> {
320 Some(
321 h_flex()
322 .p_2()
323 .w_full()
324 .justify_between()
325 .gap_2()
326 .border_t_1()
327 .border_color(cx.theme().colors().border_variant)
328 .child(
329 Button::new("docs", "View Icon Theme Docs")
330 .end_icon(
331 Icon::new(IconName::ArrowUpRight)
332 .size(IconSize::Small)
333 .color(Color::Muted),
334 )
335 .on_click(|_event, _window, cx| {
336 cx.open_url("https://zed.dev/docs/icon-themes");
337 }),
338 )
339 .child(
340 Button::new("more-icon-themes", "Install Icon Themes").on_click(
341 move |_event, window, cx| {
342 window.dispatch_action(
343 Box::new(Extensions {
344 category_filter: Some(ExtensionCategoryFilter::IconThemes),
345 id: None,
346 }),
347 cx,
348 );
349 },
350 ),
351 )
352 .into_any_element(),
353 )
354 }
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360 use std::collections::HashMap;
361
362 use gpui::{TestAppContext, VisualTestContext};
363 use project::Project;
364 use serde_json::json;
365 use theme::{ChevronIcons, DirectoryIcons, IconTheme, ThemeRegistry};
366 use util::path;
367 use workspace::MultiWorkspace;
368
369 fn init_test(cx: &mut TestAppContext) -> Arc<workspace::AppState> {
370 cx.update(|cx| {
371 let app_state = workspace::AppState::test(cx);
372 settings::init(cx);
373 theme::init(theme::LoadThemes::JustBase, cx);
374 editor::init(cx);
375 crate::init(cx);
376 app_state
377 })
378 }
379
380 fn register_test_icon_themes(cx: &mut TestAppContext) {
381 cx.update(|cx| {
382 let registry = ThemeRegistry::global(cx);
383 let make_icon_theme = |name: &str, appearance: Appearance| IconTheme {
384 id: name.to_lowercase().replace(' ', "-"),
385 name: SharedString::from(name.to_string()),
386 appearance,
387 directory_icons: DirectoryIcons {
388 collapsed: None,
389 expanded: None,
390 },
391 named_directory_icons: HashMap::default(),
392 chevron_icons: ChevronIcons {
393 collapsed: None,
394 expanded: None,
395 },
396 file_icons: HashMap::default(),
397 file_stems: HashMap::default(),
398 file_suffixes: HashMap::default(),
399 };
400 registry.register_test_icon_themes([
401 make_icon_theme("Test Icons A", Appearance::Dark),
402 make_icon_theme("Test Icons B", Appearance::Dark),
403 ]);
404 });
405 }
406
407 async fn setup_test(cx: &mut TestAppContext) -> Arc<workspace::AppState> {
408 let app_state = init_test(cx);
409 register_test_icon_themes(cx);
410 app_state
411 .fs
412 .as_fake()
413 .insert_tree(path!("/test"), json!({}))
414 .await;
415 app_state
416 }
417
418 fn open_icon_theme_selector(
419 workspace: &Entity<workspace::Workspace>,
420 cx: &mut VisualTestContext,
421 ) -> Entity<Picker<IconThemeSelectorDelegate>> {
422 cx.dispatch_action(zed_actions::icon_theme_selector::Toggle {
423 themes_filter: None,
424 });
425 cx.run_until_parked();
426 workspace.update(cx, |workspace, cx| {
427 workspace
428 .active_modal::<IconThemeSelector>(cx)
429 .expect("icon theme selector should be open")
430 .read(cx)
431 .picker
432 .clone()
433 })
434 }
435
436 fn selected_theme_name(
437 picker: &Entity<Picker<IconThemeSelectorDelegate>>,
438 cx: &mut VisualTestContext,
439 ) -> String {
440 picker.read_with(cx, |picker, _| {
441 picker
442 .delegate
443 .matches
444 .get(picker.delegate.selected_index)
445 .expect("selected index should point to a match")
446 .string
447 .clone()
448 })
449 }
450
451 fn previewed_theme_name(
452 _picker: &Entity<Picker<IconThemeSelectorDelegate>>,
453 cx: &mut VisualTestContext,
454 ) -> String {
455 cx.read(|cx| {
456 ThemeSettings::get_global(cx)
457 .icon_theme
458 .name(SystemAppearance::global(cx).0)
459 .0
460 .to_string()
461 })
462 }
463
464 #[gpui::test]
465 async fn test_icon_theme_selector_preserves_selection_on_empty_filter(cx: &mut TestAppContext) {
466 let app_state = setup_test(cx).await;
467 let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await;
468 let (multi_workspace, cx) =
469 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
470 let workspace =
471 multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone());
472 let picker = open_icon_theme_selector(&workspace, cx);
473
474 let target_index = picker.read_with(cx, |picker, _| {
475 picker
476 .delegate
477 .matches
478 .iter()
479 .position(|m| m.string == "Test Icons A")
480 .unwrap()
481 });
482 picker.update_in(cx, |picker, window, cx| {
483 picker.set_selected_index(target_index, None, true, window, cx);
484 });
485 cx.run_until_parked();
486
487 assert_eq!(previewed_theme_name(&picker, cx), "Test Icons A");
488
489 picker.update_in(cx, |picker, window, cx| {
490 picker.update_matches("zzz".to_string(), window, cx);
491 });
492 cx.run_until_parked();
493
494 picker.update_in(cx, |picker, window, cx| {
495 picker.update_matches("".to_string(), window, cx);
496 });
497 cx.run_until_parked();
498
499 assert_eq!(
500 selected_theme_name(&picker, cx),
501 "Test Icons A",
502 "selected icon theme should be preserved after clearing an empty filter"
503 );
504 assert_eq!(
505 previewed_theme_name(&picker, cx),
506 "Test Icons A",
507 "previewed icon theme should be preserved after clearing an empty filter"
508 );
509 }
510}