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 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}