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