Add icon theme selector (#23976)

Marshall Bowers created

This PR adds an icon theme selector for switching between icon themes:


https://github.com/user-attachments/assets/2cdc7ab7-d9f4-4968-a2e9-724e8ad4ef4d

Release Notes:

- N/A

Change summary

crates/theme/src/registry.rs                     |  13 
crates/theme_selector/src/icon_theme_selector.rs | 274 ++++++++++++++++++
crates/theme_selector/src/theme_selector.rs      |  35 +
crates/zed_actions/src/lib.rs                    |  14 
4 files changed, 330 insertions(+), 6 deletions(-)

Detailed changes

crates/theme/src/registry.rs 🔗

@@ -212,6 +212,19 @@ impl ThemeRegistry {
         self.get_icon_theme(DEFAULT_ICON_THEME_NAME)
     }
 
+    /// Returns the metadata of all icon themes in the registry.
+    pub fn list_icon_themes(&self) -> Vec<ThemeMeta> {
+        self.state
+            .read()
+            .icon_themes
+            .values()
+            .map(|theme| ThemeMeta {
+                name: theme.name.clone(),
+                appearance: theme.appearance,
+            })
+            .collect()
+    }
+
     /// Returns the icon theme with the specified name.
     pub fn get_icon_theme(&self, name: &str) -> Result<Arc<IconTheme>> {
         self.state

crates/theme_selector/src/icon_theme_selector.rs 🔗

@@ -0,0 +1,274 @@
+use fs::Fs;
+use fuzzy::{match_strings, StringMatch, StringMatchCandidate};
+use gpui::{
+    App, Context, DismissEvent, Entity, EventEmitter, Focusable, Render, UpdateGlobal, WeakEntity,
+    Window,
+};
+use picker::{Picker, PickerDelegate};
+use settings::{update_settings_file, Settings as _, SettingsStore};
+use std::sync::Arc;
+use theme::{IconTheme, ThemeMeta, ThemeRegistry, ThemeSettings};
+use ui::{prelude::*, v_flex, ListItem, ListItemSpacing};
+use util::ResultExt;
+use workspace::{ui::HighlightedLabel, ModalView};
+
+pub(crate) struct IconThemeSelector {
+    picker: Entity<Picker<IconThemeSelectorDelegate>>,
+}
+
+impl EventEmitter<DismissEvent> for IconThemeSelector {}
+
+impl Focusable for IconThemeSelector {
+    fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
+        self.picker.focus_handle(cx)
+    }
+}
+
+impl ModalView for IconThemeSelector {}
+
+impl IconThemeSelector {
+    pub fn new(
+        delegate: IconThemeSelectorDelegate,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
+        Self { picker }
+    }
+}
+
+impl Render for IconThemeSelector {
+    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
+        v_flex().w(rems(34.)).child(self.picker.clone())
+    }
+}
+
+pub(crate) struct IconThemeSelectorDelegate {
+    fs: Arc<dyn Fs>,
+    themes: Vec<ThemeMeta>,
+    matches: Vec<StringMatch>,
+    original_theme: Arc<IconTheme>,
+    selection_completed: bool,
+    selected_index: usize,
+    selector: WeakEntity<IconThemeSelector>,
+}
+
+impl IconThemeSelectorDelegate {
+    pub fn new(
+        selector: WeakEntity<IconThemeSelector>,
+        fs: Arc<dyn Fs>,
+        themes_filter: Option<&Vec<String>>,
+        cx: &mut Context<IconThemeSelector>,
+    ) -> Self {
+        let theme_settings = ThemeSettings::get_global(cx);
+        let original_theme = theme_settings.active_icon_theme.clone();
+
+        let registry = ThemeRegistry::global(cx);
+        let mut themes = registry
+            .list_icon_themes()
+            .into_iter()
+            .filter(|meta| {
+                if let Some(theme_filter) = themes_filter {
+                    theme_filter.contains(&meta.name.to_string())
+                } else {
+                    true
+                }
+            })
+            .collect::<Vec<_>>();
+
+        themes.sort_unstable_by(|a, b| {
+            a.appearance
+                .is_light()
+                .cmp(&b.appearance.is_light())
+                .then(a.name.cmp(&b.name))
+        });
+        let matches = themes
+            .iter()
+            .map(|meta| StringMatch {
+                candidate_id: 0,
+                score: 0.0,
+                positions: Default::default(),
+                string: meta.name.to_string(),
+            })
+            .collect();
+        let mut this = Self {
+            fs,
+            themes,
+            matches,
+            original_theme: original_theme.clone(),
+            selected_index: 0,
+            selection_completed: false,
+            selector,
+        };
+
+        this.select_if_matching(&original_theme.name);
+        this
+    }
+
+    fn show_selected_theme(&mut self, cx: &mut Context<Picker<IconThemeSelectorDelegate>>) {
+        if let Some(mat) = self.matches.get(self.selected_index) {
+            let registry = ThemeRegistry::global(cx);
+            match registry.get_icon_theme(&mat.string) {
+                Ok(theme) => {
+                    Self::set_icon_theme(theme, cx);
+                }
+                Err(err) => {
+                    log::error!("error loading icon theme {}: {err}", mat.string);
+                }
+            }
+        }
+    }
+
+    fn select_if_matching(&mut self, theme_name: &str) {
+        self.selected_index = self
+            .matches
+            .iter()
+            .position(|mat| mat.string == theme_name)
+            .unwrap_or(self.selected_index);
+    }
+
+    fn set_icon_theme(theme: Arc<IconTheme>, cx: &mut App) {
+        SettingsStore::update_global(cx, |store, cx| {
+            let mut theme_settings = store.get::<ThemeSettings>(None).clone();
+            theme_settings.active_icon_theme = theme;
+            store.override_global(theme_settings);
+            cx.refresh_windows();
+        });
+    }
+}
+
+impl PickerDelegate for IconThemeSelectorDelegate {
+    type ListItem = ui::ListItem;
+
+    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
+        "Select Icon Theme...".into()
+    }
+
+    fn match_count(&self) -> usize {
+        self.matches.len()
+    }
+
+    fn confirm(
+        &mut self,
+        _: bool,
+        _window: &mut Window,
+        cx: &mut Context<Picker<IconThemeSelectorDelegate>>,
+    ) {
+        self.selection_completed = true;
+
+        let theme_settings = ThemeSettings::get_global(cx);
+        let theme_name = theme_settings.active_icon_theme.name.clone();
+
+        telemetry::event!(
+            "Settings Changed",
+            setting = "icon_theme",
+            value = theme_name
+        );
+
+        update_settings_file::<ThemeSettings>(self.fs.clone(), cx, move |settings, _| {
+            settings.icon_theme = Some(theme_name.to_string());
+        });
+
+        self.selector
+            .update(cx, |_, cx| {
+                cx.emit(DismissEvent);
+            })
+            .ok();
+    }
+
+    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<IconThemeSelectorDelegate>>) {
+        if !self.selection_completed {
+            Self::set_icon_theme(self.original_theme.clone(), cx);
+            self.selection_completed = true;
+        }
+
+        self.selector
+            .update(cx, |_, cx| cx.emit(DismissEvent))
+            .log_err();
+    }
+
+    fn selected_index(&self) -> usize {
+        self.selected_index
+    }
+
+    fn set_selected_index(
+        &mut self,
+        ix: usize,
+        _: &mut Window,
+        cx: &mut Context<Picker<IconThemeSelectorDelegate>>,
+    ) {
+        self.selected_index = ix;
+        self.show_selected_theme(cx);
+    }
+
+    fn update_matches(
+        &mut self,
+        query: String,
+        window: &mut Window,
+        cx: &mut Context<Picker<IconThemeSelectorDelegate>>,
+    ) -> gpui::Task<()> {
+        let background = cx.background_executor().clone();
+        let candidates = self
+            .themes
+            .iter()
+            .enumerate()
+            .map(|(id, meta)| StringMatchCandidate::new(id, &meta.name))
+            .collect::<Vec<_>>();
+
+        cx.spawn_in(window, |this, mut cx| async move {
+            let matches = if query.is_empty() {
+                candidates
+                    .into_iter()
+                    .enumerate()
+                    .map(|(index, candidate)| StringMatch {
+                        candidate_id: index,
+                        string: candidate.string,
+                        positions: Vec::new(),
+                        score: 0.0,
+                    })
+                    .collect()
+            } else {
+                match_strings(
+                    &candidates,
+                    &query,
+                    false,
+                    100,
+                    &Default::default(),
+                    background,
+                )
+                .await
+            };
+
+            this.update(&mut cx, |this, cx| {
+                this.delegate.matches = matches;
+                this.delegate.selected_index = this
+                    .delegate
+                    .selected_index
+                    .min(this.delegate.matches.len().saturating_sub(1));
+                this.delegate.show_selected_theme(cx);
+            })
+            .log_err();
+        })
+    }
+
+    fn render_match(
+        &self,
+        ix: usize,
+        selected: bool,
+        _window: &mut Window,
+        _cx: &mut Context<Picker<Self>>,
+    ) -> Option<Self::ListItem> {
+        let theme_match = &self.matches[ix];
+
+        Some(
+            ListItem::new(ix)
+                .inset(true)
+                .spacing(ListItemSpacing::Sparse)
+                .toggle_state(selected)
+                .child(HighlightedLabel::new(
+                    theme_match.string.clone(),
+                    theme_match.positions.clone(),
+                )),
+        )
+    }
+}

crates/theme_selector/src/theme_selector.rs 🔗

@@ -1,3 +1,5 @@
+mod icon_theme_selector;
+
 use fs::Fs;
 use fuzzy::{match_strings, StringMatch, StringMatchCandidate};
 use gpui::{
@@ -11,22 +13,25 @@ use theme::{Appearance, Theme, ThemeMeta, ThemeRegistry, ThemeSettings};
 use ui::{prelude::*, v_flex, ListItem, ListItemSpacing};
 use util::ResultExt;
 use workspace::{ui::HighlightedLabel, ModalView, Workspace};
-use zed_actions::theme_selector::Toggle;
+
+use crate::icon_theme_selector::{IconThemeSelector, IconThemeSelectorDelegate};
 
 actions!(theme_selector, [Reload]);
 
 pub fn init(cx: &mut App) {
     cx.observe_new(
         |workspace: &mut Workspace, _window, _cx: &mut Context<Workspace>| {
-            workspace.register_action(toggle);
+            workspace
+                .register_action(toggle_theme_selector)
+                .register_action(toggle_icon_theme_selector);
         },
     )
     .detach();
 }
 
-pub fn toggle(
+fn toggle_theme_selector(
     workspace: &mut Workspace,
-    toggle: &Toggle,
+    toggle: &zed_actions::theme_selector::Toggle,
     window: &mut Window,
     cx: &mut Context<Workspace>,
 ) {
@@ -42,9 +47,27 @@ pub fn toggle(
     });
 }
 
+fn toggle_icon_theme_selector(
+    workspace: &mut Workspace,
+    toggle: &zed_actions::icon_theme_selector::Toggle,
+    window: &mut Window,
+    cx: &mut Context<Workspace>,
+) {
+    let fs = workspace.app_state().fs.clone();
+    workspace.toggle_modal(window, cx, |window, cx| {
+        let delegate = IconThemeSelectorDelegate::new(
+            cx.entity().downgrade(),
+            fs,
+            toggle.themes_filter.as_ref(),
+            cx,
+        );
+        IconThemeSelector::new(delegate, window, cx)
+    });
+}
+
 impl ModalView for ThemeSelector {}
 
-pub struct ThemeSelector {
+struct ThemeSelector {
     picker: Entity<Picker<ThemeSelectorDelegate>>,
 }
 
@@ -73,7 +96,7 @@ impl ThemeSelector {
     }
 }
 
-pub struct ThemeSelectorDelegate {
+struct ThemeSelectorDelegate {
     fs: Arc<dyn Fs>,
     themes: Vec<ThemeMeta>,
     matches: Vec<StringMatch>,

crates/zed_actions/src/lib.rs 🔗

@@ -77,6 +77,20 @@ pub mod theme_selector {
     impl_actions!(theme_selector, [Toggle]);
 }
 
+pub mod icon_theme_selector {
+    use gpui::impl_actions;
+    use schemars::JsonSchema;
+    use serde::Deserialize;
+
+    #[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema)]
+    pub struct Toggle {
+        /// A list of icon theme names to filter the theme selector down to.
+        pub themes_filter: Option<Vec<String>>,
+    }
+
+    impl_actions!(icon_theme_selector, [Toggle]);
+}
+
 pub mod assistant {
     use gpui::{actions, impl_actions};
     use schemars::JsonSchema;