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}