onboarding ui: Add theme preview tiles and button functionality to basic page (#35413)

Anthony Eid and Remco Smits created

This PR polishes and adds functionality to the onboarding UI with a
focus on the basic page. It added theme preview tiles, got the Vim,
telemetry, crash reporting, and sign-in button working.

The theme preview component was moved to the UI crate and it now can
have a click handler on it.

Finally, this commit also changed `client::User.github_login` and
`client::UserStore.by_github_login` to use `SharedStrings` instead of
`Strings`. This change was made because user.github_login was cloned in
several areas including the UI, and was cast to a shared string in some
cases too.

Release Notes:

- N/A

---------

Co-authored-by: Remco Smits <djsmits12@gmail.com>

Change summary

Cargo.lock                                       |   3 
crates/channel/src/channel_store.rs              |   2 
crates/client/src/user.rs                        |   8 
crates/collab/src/tests.rs                       |   4 
crates/collab/src/tests/integration_tests.rs     |   6 
crates/collab/src/tests/test_server.rs           |   6 
crates/collab_ui/src/collab_panel.rs             |   6 
crates/git_ui/src/git_panel.rs                   |   4 
crates/onboarding/Cargo.toml                     |   2 
crates/onboarding/src/basics_page.rs             | 187 ++++++++++++++---
crates/onboarding/src/onboarding.rs              |  85 +++++++
crates/ui/src/components.rs                      |   2 
crates/ui/src/components/button/toggle_button.rs |  20 +
crates/ui/src/components/theme_preview.rs        |  32 ++
crates/ui/src/components/toggle.rs               |   6 
crates/welcome/Cargo.toml                        |   1 
crates/welcome/src/welcome.rs                    |   1 
crates/welcome/src/welcome_ui.rs                 |   1 
18 files changed, 294 insertions(+), 82 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -10923,6 +10923,7 @@ name = "onboarding"
 version = "0.1.0"
 dependencies = [
  "anyhow",
+ "client",
  "command_palette_hooks",
  "db",
  "editor",
@@ -10937,6 +10938,7 @@ dependencies = [
  "theme",
  "ui",
  "util",
+ "vim_mode_setting",
  "workspace",
  "workspace-hack",
  "zed_actions",
@@ -18594,7 +18596,6 @@ dependencies = [
  "serde",
  "settings",
  "telemetry",
- "theme",
  "ui",
  "util",
  "vim_mode_setting",

crates/channel/src/channel_store.rs 🔗

@@ -126,7 +126,7 @@ impl ChannelMembership {
                 proto::channel_member::Kind::Member => 0,
                 proto::channel_member::Kind::Invitee => 1,
             },
-            username_order: self.user.github_login.as_str(),
+            username_order: &self.user.github_login,
         }
     }
 }

crates/client/src/user.rs 🔗

@@ -55,7 +55,7 @@ pub struct ParticipantIndex(pub u32);
 #[derive(Default, Debug)]
 pub struct User {
     pub id: UserId,
-    pub github_login: String,
+    pub github_login: SharedString,
     pub avatar_uri: SharedUri,
     pub name: Option<String>,
 }
@@ -107,7 +107,7 @@ pub enum ContactRequestStatus {
 
 pub struct UserStore {
     users: HashMap<u64, Arc<User>>,
-    by_github_login: HashMap<String, u64>,
+    by_github_login: HashMap<SharedString, u64>,
     participant_indices: HashMap<u64, ParticipantIndex>,
     update_contacts_tx: mpsc::UnboundedSender<UpdateContacts>,
     current_plan: Option<proto::Plan>,
@@ -904,7 +904,7 @@ impl UserStore {
         let mut missing_user_ids = Vec::new();
         for id in user_ids {
             if let Some(github_login) = self.get_cached_user(id).map(|u| u.github_login.clone()) {
-                ret.insert(id, github_login.into());
+                ret.insert(id, github_login);
             } else {
                 missing_user_ids.push(id)
             }
@@ -925,7 +925,7 @@ impl User {
     fn new(message: proto::User) -> Arc<Self> {
         Arc::new(User {
             id: message.id,
-            github_login: message.github_login,
+            github_login: message.github_login.into(),
             avatar_uri: message.avatar_url.into(),
             name: message.name,
         })

crates/collab/src/tests.rs 🔗

@@ -38,12 +38,12 @@ fn room_participants(room: &Entity<Room>, cx: &mut TestAppContext) -> RoomPartic
         let mut remote = room
             .remote_participants()
             .values()
-            .map(|participant| participant.user.github_login.clone())
+            .map(|participant| participant.user.github_login.clone().to_string())
             .collect::<Vec<_>>();
         let mut pending = room
             .pending_participants()
             .iter()
-            .map(|user| user.github_login.clone())
+            .map(|user| user.github_login.clone().to_string())
             .collect::<Vec<_>>();
         remote.sort();
         pending.sort();

crates/collab/src/tests/integration_tests.rs 🔗

@@ -1881,7 +1881,7 @@ async fn test_active_call_events(
         vec![room::Event::RemoteProjectShared {
             owner: Arc::new(User {
                 id: client_a.user_id().unwrap(),
-                github_login: "user_a".to_string(),
+                github_login: "user_a".into(),
                 avatar_uri: "avatar_a".into(),
                 name: None,
             }),
@@ -1900,7 +1900,7 @@ async fn test_active_call_events(
         vec![room::Event::RemoteProjectShared {
             owner: Arc::new(User {
                 id: client_b.user_id().unwrap(),
-                github_login: "user_b".to_string(),
+                github_login: "user_b".into(),
                 avatar_uri: "avatar_b".into(),
                 name: None,
             }),
@@ -6079,7 +6079,7 @@ async fn test_contacts(
                 .iter()
                 .map(|contact| {
                     (
-                        contact.user.github_login.clone(),
+                        contact.user.github_login.clone().to_string(),
                         if contact.online { "online" } else { "offline" },
                         if contact.busy { "busy" } else { "free" },
                     )

crates/collab/src/tests/test_server.rs 🔗

@@ -696,17 +696,17 @@ impl TestClient {
                 current: store
                     .contacts()
                     .iter()
-                    .map(|contact| contact.user.github_login.clone())
+                    .map(|contact| contact.user.github_login.clone().to_string())
                     .collect(),
                 outgoing_requests: store
                     .outgoing_contact_requests()
                     .iter()
-                    .map(|user| user.github_login.clone())
+                    .map(|user| user.github_login.clone().to_string())
                     .collect(),
                 incoming_requests: store
                     .incoming_contact_requests()
                     .iter()
-                    .map(|user| user.github_login.clone())
+                    .map(|user| user.github_login.clone().to_string())
                     .collect(),
             })
     }

crates/collab_ui/src/collab_panel.rs 🔗

@@ -940,7 +940,7 @@ impl CollabPanel {
             room.read(cx).local_participant().role == proto::ChannelRole::Admin
         });
 
-        ListItem::new(SharedString::from(user.github_login.clone()))
+        ListItem::new(user.github_login.clone())
             .start_slot(Avatar::new(user.avatar_uri.clone()))
             .child(Label::new(user.github_login.clone()))
             .toggle_state(is_selected)
@@ -2583,7 +2583,7 @@ impl CollabPanel {
     ) -> impl IntoElement {
         let online = contact.online;
         let busy = contact.busy || calling;
-        let github_login = SharedString::from(contact.user.github_login.clone());
+        let github_login = contact.user.github_login.clone();
         let item = ListItem::new(github_login.clone())
             .indent_level(1)
             .indent_step_size(px(20.))
@@ -2662,7 +2662,7 @@ impl CollabPanel {
         is_selected: bool,
         cx: &mut Context<Self>,
     ) -> impl IntoElement {
-        let github_login = SharedString::from(user.github_login.clone());
+        let github_login = user.github_login.clone();
         let user_id = user.id;
         let is_response_pending = self.user_store.read(cx).is_contact_request_pending(user);
         let color = if is_response_pending {

crates/git_ui/src/git_panel.rs 🔗

@@ -2416,7 +2416,7 @@ impl GitPanel {
                     .committer_name
                     .clone()
                     .or_else(|| participant.user.name.clone())
-                    .unwrap_or_else(|| participant.user.github_login.clone());
+                    .unwrap_or_else(|| participant.user.github_login.clone().to_string());
                 new_co_authors.push((name.clone(), email.clone()))
             }
         }
@@ -2436,7 +2436,7 @@ impl GitPanel {
             .name
             .clone()
             .or_else(|| user.name.clone())
-            .unwrap_or_else(|| user.github_login.clone());
+            .unwrap_or_else(|| user.github_login.clone().to_string());
         Some((name, email))
     }
 

crates/onboarding/Cargo.toml 🔗

@@ -16,6 +16,7 @@ default = []
 
 [dependencies]
 anyhow.workspace = true
+client.workspace = true
 command_palette_hooks.workspace = true
 db.workspace = true
 editor.workspace = true
@@ -30,6 +31,7 @@ settings.workspace = true
 theme.workspace = true
 ui.workspace = true
 util.workspace = true
+vim_mode_setting.workspace = true
 workspace-hack.workspace = true
 workspace.workspace = true
 zed_actions.workspace = true

crates/onboarding/src/basics_page.rs 🔗

@@ -1,16 +1,28 @@
+use std::sync::Arc;
+
+use client::TelemetrySettings;
 use fs::Fs;
-use gpui::{App, IntoElement, Window};
-use settings::{Settings, update_settings_file};
-use theme::{ThemeMode, ThemeSettings};
-use ui::{SwitchField, ToggleButtonGroup, ToggleButtonSimple, ToggleButtonWithIcon, prelude::*};
+use gpui::{App, IntoElement};
+use settings::{BaseKeymap, Settings, update_settings_file};
+use theme::{Appearance, SystemAppearance, ThemeMode, ThemeSettings};
+use ui::{
+    SwitchField, ThemePreviewTile, ToggleButtonGroup, ToggleButtonSimple, ToggleButtonWithIcon,
+    prelude::*,
+};
+use vim_mode_setting::VimModeSetting;
+
+use crate::Onboarding;
 
-fn read_theme_selection(cx: &App) -> ThemeMode {
+fn read_theme_selection(cx: &App) -> (ThemeMode, SharedString) {
     let settings = ThemeSettings::get_global(cx);
-    settings
-        .theme_selection
-        .as_ref()
-        .and_then(|selection| selection.mode())
-        .unwrap_or_default()
+    (
+        settings
+            .theme_selection
+            .as_ref()
+            .and_then(|selection| selection.mode())
+            .unwrap_or_default(),
+        settings.active_theme.name.clone(),
+    )
 }
 
 fn write_theme_selection(theme_mode: ThemeMode, cx: &App) {
@@ -21,9 +33,15 @@ fn write_theme_selection(theme_mode: ThemeMode, cx: &App) {
     });
 }
 
-fn render_theme_section(cx: &mut App) -> impl IntoElement {
-    let theme_mode = read_theme_selection(cx);
+fn write_keymap_base(keymap_base: BaseKeymap, cx: &App) {
+    let fs = <dyn Fs>::global(cx);
+
+    update_settings_file::<BaseKeymap>(fs, cx, move |setting, _| {
+        *setting = Some(keymap_base);
+    });
+}
 
+fn render_theme_section(theme_mode: ThemeMode) -> impl IntoElement {
     h_flex().justify_between().child(Label::new("Theme")).child(
         ToggleButtonGroup::single_row(
             "theme-selector-onboarding",
@@ -49,55 +67,160 @@ fn render_theme_section(cx: &mut App) -> impl IntoElement {
     )
 }
 
-fn render_telemetry_section() -> impl IntoElement {
+fn render_telemetry_section(fs: Arc<dyn Fs>, cx: &App) -> impl IntoElement {
     v_flex()
-        .gap_3()
+
+        .gap_4()
         .child(Label::new("Telemetry").size(LabelSize::Large))
         .child(SwitchField::new(
-            "vim_mode",
+            "onboarding-telemetry-metrics",
             "Help Improve Zed",
             "Sending anonymous usage data helps us build the right features and create the best experience.",
-            ui::ToggleState::Selected,
-            |_, _, _| {},
+            if TelemetrySettings::get_global(cx).metrics {
+                ui::ToggleState::Selected
+            } else {
+                ui::ToggleState::Unselected
+            },
+            {
+            let fs = fs.clone();
+            move |selection, _, cx| {
+                let enabled = match selection {
+                    ToggleState::Selected => true,
+                    ToggleState::Unselected => false,
+                    ToggleState::Indeterminate => { return; },
+                };
+
+                update_settings_file::<TelemetrySettings>(
+                    fs.clone(),
+                    cx,
+                    move |setting, _| setting.metrics = Some(enabled),
+                );
+            }},
         ))
         .child(SwitchField::new(
-            "vim_mode",
+            "onboarding-telemetry-crash-reports",
             "Help Fix Zed",
             "Send crash reports so we can fix critical issues fast.",
-            ui::ToggleState::Selected,
-            |_, _, _| {},
+            if TelemetrySettings::get_global(cx).diagnostics {
+                ui::ToggleState::Selected
+            } else {
+                ui::ToggleState::Unselected
+            },
+            {
+                let fs = fs.clone();
+                move |selection, _, cx| {
+                    let enabled = match selection {
+                        ToggleState::Selected => true,
+                        ToggleState::Unselected => false,
+                        ToggleState::Indeterminate => { return; },
+                    };
+
+                    update_settings_file::<TelemetrySettings>(
+                        fs.clone(),
+                        cx,
+                        move |setting, _| setting.diagnostics = Some(enabled),
+                    );
+                }
+            }
         ))
 }
 
-pub(crate) fn render_basics_page(_: &mut Window, cx: &mut App) -> impl IntoElement {
+pub(crate) fn render_basics_page(onboarding: &Onboarding, cx: &mut App) -> impl IntoElement {
+    let (theme_mode, active_theme_name) = read_theme_selection(cx);
+    let themes = match theme_mode {
+        ThemeMode::Dark => &onboarding.dark_themes,
+        ThemeMode::Light => &onboarding.light_themes,
+        ThemeMode::System => match SystemAppearance::global(cx).0 {
+            Appearance::Light => &onboarding.light_themes,
+            Appearance::Dark => &onboarding.dark_themes,
+        },
+    };
+
+    let base_keymap = match BaseKeymap::get_global(cx) {
+        BaseKeymap::VSCode => Some(0),
+        BaseKeymap::JetBrains => Some(1),
+        BaseKeymap::SublimeText => Some(2),
+        BaseKeymap::Atom => Some(3),
+        BaseKeymap::Emacs => Some(4),
+        BaseKeymap::Cursor => Some(5),
+        BaseKeymap::TextMate | BaseKeymap::None => None,
+    };
+
     v_flex()
         .gap_6()
-        .child(render_theme_section(cx))
+        .child(render_theme_section(theme_mode))
+        .child(h_flex().children(
+            themes.iter().map(|theme| {
+                ThemePreviewTile::new(theme.clone(), active_theme_name == theme.name, 0.48)
+                .on_click({
+                    let theme_name = theme.name.clone();
+                    let fs = onboarding.fs.clone();
+                    move |_, _, cx| {
+                        let theme_name = theme_name.clone();
+                        update_settings_file::<ThemeSettings>(fs.clone(), cx, move |settings, cx| {
+                            settings.set_theme(theme_name.to_string(), SystemAppearance::global(cx).0);
+                        });
+                    }
+                })
+            })
+        ))
         .child(
             v_flex().gap_2().child(Label::new("Base Keymap")).child(
                 ToggleButtonGroup::two_rows(
                     "multiple_row_test",
                     [
-                        ToggleButtonWithIcon::new("VS Code", IconName::AiZed, |_, _, _| {}),
-                        ToggleButtonWithIcon::new("Jetbrains", IconName::AiZed, |_, _, _| {}),
-                        ToggleButtonWithIcon::new("Sublime Text", IconName::AiZed, |_, _, _| {}),
+                        ToggleButtonWithIcon::new("VS Code", IconName::AiZed, |_, _, cx| {
+                            write_keymap_base(BaseKeymap::VSCode, cx);
+                        }),
+                        ToggleButtonWithIcon::new("Jetbrains", IconName::AiZed, |_, _, cx| {
+                            write_keymap_base(BaseKeymap::JetBrains, cx);
+                        }),
+                        ToggleButtonWithIcon::new("Sublime Text", IconName::AiZed, |_, _, cx| {
+                            write_keymap_base(BaseKeymap::SublimeText, cx);
+                        }),
                     ],
                     [
-                        ToggleButtonWithIcon::new("Atom", IconName::AiZed, |_, _, _| {}),
-                        ToggleButtonWithIcon::new("Emacs", IconName::AiZed, |_, _, _| {}),
-                        ToggleButtonWithIcon::new("Cursor (Beta)", IconName::AiZed, |_, _, _| {}),
+                        ToggleButtonWithIcon::new("Atom", IconName::AiZed, |_, _, cx| {
+                            write_keymap_base(BaseKeymap::Atom, cx);
+                        }),
+                        ToggleButtonWithIcon::new("Emacs", IconName::AiZed, |_, _, cx| {
+                            write_keymap_base(BaseKeymap::Emacs, cx);
+                        }),
+                        ToggleButtonWithIcon::new("Cursor (Beta)", IconName::AiZed, |_, _, cx| {
+                            write_keymap_base(BaseKeymap::Cursor, cx);
+                        }),
                     ],
                 )
+                .when_some(base_keymap, |this, base_keymap| this.selected_index(base_keymap))
                 .button_width(rems_from_px(230.))
                 .style(ui::ToggleButtonGroupStyle::Outlined)
             ),
         )
         .child(v_flex().justify_center().child(div().h_0().child("hack").invisible()).child(SwitchField::new(
-            "vim_mode",
+            "onboarding-vim-mode",
             "Vim Mode",
             "Coming from Neovim? Zed's first-class implementation of Vim Mode has got your back.",
-            ui::ToggleState::Selected,
-            |_, _, _| {},
+            if VimModeSetting::get_global(cx).0 {
+                ui::ToggleState::Selected
+            } else {
+                ui::ToggleState::Unselected
+            },
+            {
+                let fs = onboarding.fs.clone();
+                move |selection, _, cx| {
+                    let enabled = match selection {
+                        ToggleState::Selected => true,
+                        ToggleState::Unselected => false,
+                        ToggleState::Indeterminate => { return; },
+                    };
+
+                    update_settings_file::<VimModeSetting>(
+                        fs.clone(),
+                        cx,
+                        move |setting, _| *setting = Some(enabled),
+                    );
+                }
+            },
         )))
-        .child(render_telemetry_section())
+        .child(render_telemetry_section(onboarding.fs.clone(), cx))
 }

crates/onboarding/src/onboarding.rs 🔗

@@ -1,4 +1,5 @@
 use crate::welcome::{ShowWelcome, WelcomePage};
+use client::{Client, UserStore};
 use command_palette_hooks::CommandPaletteFilter;
 use db::kvp::KEY_VALUE_STORE;
 use feature_flags::{FeatureFlag, FeatureFlagViewExt as _};
@@ -12,11 +13,13 @@ use schemars::JsonSchema;
 use serde::Deserialize;
 use settings::{SettingsStore, VsCodeSettingsSource};
 use std::sync::Arc;
-use ui::{FluentBuilder, KeyBinding, Vector, VectorName, prelude::*, rems_from_px};
+use theme::{Theme, ThemeRegistry};
+use ui::{Avatar, FluentBuilder, KeyBinding, Vector, VectorName, prelude::*, rems_from_px};
 use workspace::{
     AppState, Workspace, WorkspaceId,
     dock::DockPosition,
     item::{Item, ItemEvent},
+    notifications::NotifyResultExt as _,
     open_new, with_active_or_new_workspace,
 };
 
@@ -72,7 +75,11 @@ pub fn init(cx: &mut App) {
                     if let Some(existing) = existing {
                         workspace.activate_item(&existing, true, true, window, cx);
                     } else {
-                        let settings_page = Onboarding::new(workspace.weak_handle(), cx);
+                        let settings_page = Onboarding::new(
+                            workspace.weak_handle(),
+                            workspace.user_store().clone(),
+                            cx,
+                        );
                         workspace.add_item_to_active_pane(
                             Box::new(settings_page),
                             None,
@@ -188,7 +195,8 @@ pub fn show_onboarding_view(app_state: Arc<AppState>, cx: &mut App) -> Task<anyh
         |workspace, window, cx| {
             {
                 workspace.toggle_dock(DockPosition::Left, window, cx);
-                let onboarding_page = Onboarding::new(workspace.weak_handle(), cx);
+                let onboarding_page =
+                    Onboarding::new(workspace.weak_handle(), workspace.user_store().clone(), cx);
                 workspace.add_item_to_center(Box::new(onboarding_page.clone()), window, cx);
 
                 window.focus(&onboarding_page.focus_handle(cx));
@@ -211,17 +219,51 @@ enum SelectedPage {
 
 struct Onboarding {
     workspace: WeakEntity<Workspace>,
+    light_themes: [Arc<Theme>; 3],
+    dark_themes: [Arc<Theme>; 3],
     focus_handle: FocusHandle,
     selected_page: SelectedPage,
+    fs: Arc<dyn Fs>,
+    user_store: Entity<UserStore>,
     _settings_subscription: Subscription,
 }
 
 impl Onboarding {
-    fn new(workspace: WeakEntity<Workspace>, cx: &mut App) -> Entity<Self> {
+    fn new(
+        workspace: WeakEntity<Workspace>,
+        user_store: Entity<UserStore>,
+        cx: &mut App,
+    ) -> Entity<Self> {
+        let theme_registry = ThemeRegistry::global(cx);
+
+        let one_dark = theme_registry
+            .get("One Dark")
+            .expect("Default themes are always present");
+        let ayu_dark = theme_registry
+            .get("Ayu Dark")
+            .expect("Default themes are always present");
+        let gruvbox_dark = theme_registry
+            .get("Gruvbox Dark")
+            .expect("Default themes are always present");
+
+        let one_light = theme_registry
+            .get("One Light")
+            .expect("Default themes are always present");
+        let ayu_light = theme_registry
+            .get("Ayu Light")
+            .expect("Default themes are always present");
+        let gruvbox_light = theme_registry
+            .get("Gruvbox Light")
+            .expect("Default themes are always present");
+
         cx.new(|cx| Self {
             workspace,
+            user_store,
             focus_handle: cx.focus_handle(),
+            light_themes: [one_light, ayu_light, gruvbox_light],
+            dark_themes: [one_dark, ayu_dark, gruvbox_dark],
             selected_page: SelectedPage::Basics,
+            fs: <dyn Fs>::global(cx),
             _settings_subscription: cx.observe_global::<SettingsStore>(move |_, cx| cx.notify()),
         })
     }
@@ -339,16 +381,37 @@ impl Onboarding {
                     ),
             )
             .child(
-                Button::new("sign_in", "Sign In")
-                    .style(ButtonStyle::Outlined)
-                    .full_width(),
+                if let Some(user) = self.user_store.read(cx).current_user() {
+                    h_flex()
+                        .gap_2()
+                        .child(Avatar::new(user.avatar_uri.clone()))
+                        .child(Label::new(user.github_login.clone()))
+                        .into_any_element()
+                } else {
+                    Button::new("sign_in", "Sign In")
+                        .style(ButtonStyle::Outlined)
+                        .full_width()
+                        .on_click(|_, window, cx| {
+                            let client = Client::global(cx);
+                            window
+                                .spawn(cx, async move |cx| {
+                                    client
+                                        .authenticate_and_connect(true, &cx)
+                                        .await
+                                        .into_response()
+                                        .notify_async_err(cx);
+                                })
+                                .detach();
+                        })
+                        .into_any_element()
+                },
             )
     }
 
     fn render_page(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
         match self.selected_page {
             SelectedPage::Basics => {
-                crate::basics_page::render_basics_page(window, cx).into_any_element()
+                crate::basics_page::render_basics_page(&self, cx).into_any_element()
             }
             SelectedPage::Editing => {
                 crate::editing_page::render_editing_page(window, cx).into_any_element()
@@ -420,7 +483,11 @@ impl Item for Onboarding {
         _: &mut Window,
         cx: &mut Context<Self>,
     ) -> Option<Entity<Self>> {
-        Some(Onboarding::new(self.workspace.clone(), cx))
+        Some(Onboarding::new(
+            self.workspace.clone(),
+            self.user_store.clone(),
+            cx,
+        ))
     }
 
     fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {

crates/ui/src/components.rs 🔗

@@ -34,6 +34,7 @@ mod stack;
 mod sticky_items;
 mod tab;
 mod tab_bar;
+mod theme_preview;
 mod toggle;
 mod tooltip;
 
@@ -76,6 +77,7 @@ pub use stack::*;
 pub use sticky_items::*;
 pub use tab::*;
 pub use tab_bar::*;
+pub use theme_preview::*;
 pub use toggle::*;
 pub use tooltip::*;
 

crates/ui/src/components/button/toggle_button.rs 🔗

@@ -431,15 +431,17 @@ impl<T: ButtonBuilder, const COLS: usize, const ROWS: usize> RenderOnce
 {
     fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
         let entries = self.rows.into_iter().enumerate().map(|(row_index, row)| {
-            row.into_iter().enumerate().map(move |(index, button)| {
+            row.into_iter().enumerate().map(move |(col_index, button)| {
                 let ButtonConfiguration {
                     label,
                     icon,
                     on_click,
                 } = button.into_configuration();
 
-                ButtonLike::new((self.group_name, row_index * COLS + index))
-                    .when(index == self.selected_index, |this| {
+                let entry_index = row_index * COLS + col_index;
+
+                ButtonLike::new((self.group_name, entry_index))
+                    .when(entry_index == self.selected_index, |this| {
                         this.toggle_state(true)
                             .selected_style(ButtonStyle::Tinted(TintColor::Accent))
                     })
@@ -451,10 +453,12 @@ impl<T: ButtonBuilder, const COLS: usize, const ROWS: usize> RenderOnce
                         h_flex()
                             .min_w(self.button_width)
                             .gap_1p5()
+                            .px_3()
+                            .py_1()
                             .justify_center()
                             .when_some(icon, |this, icon| {
                                 this.child(Icon::new(icon).size(IconSize::XSmall).map(|this| {
-                                    if index == self.selected_index {
+                                    if entry_index == self.selected_index {
                                         this.color(Color::Accent)
                                     } else {
                                         this.color(Color::Muted)
@@ -462,9 +466,11 @@ impl<T: ButtonBuilder, const COLS: usize, const ROWS: usize> RenderOnce
                                 }))
                             })
                             .child(
-                                Label::new(label).when(index == self.selected_index, |this| {
-                                    this.color(Color::Accent)
-                                }),
+                                Label::new(label)
+                                    .size(LabelSize::Small)
+                                    .when(entry_index == self.selected_index, |this| {
+                                        this.color(Color::Accent)
+                                    }),
                             ),
                     )
                     .on_click(on_click)

crates/welcome/src/welcome_ui/theme_preview.rs → crates/ui/src/components/theme_preview.rs 🔗

@@ -1,10 +1,7 @@
-#![allow(unused, dead_code)]
-use gpui::{Hsla, Length};
-use std::sync::Arc;
+use crate::{component_prelude::Documented, prelude::*, utils::inner_corner_radius};
+use gpui::{App, ClickEvent, Hsla, IntoElement, Length, RenderOnce, Window};
+use std::{rc::Rc, sync::Arc};
 use theme::{Theme, ThemeRegistry};
-use ui::{
-    IntoElement, RenderOnce, component_prelude::Documented, prelude::*, utils::inner_corner_radius,
-};
 
 /// Shows a preview of a theme as an abstract illustration
 /// of a thumbnail-sized editor.
@@ -12,6 +9,7 @@ use ui::{
 pub struct ThemePreviewTile {
     theme: Arc<Theme>,
     selected: bool,
+    on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
     seed: f32,
 }
 
@@ -19,8 +17,9 @@ impl ThemePreviewTile {
     pub fn new(theme: Arc<Theme>, selected: bool, seed: f32) -> Self {
         Self {
             theme,
-            selected,
             seed,
+            selected,
+            on_click: None,
         }
     }
 
@@ -28,10 +27,18 @@ impl ThemePreviewTile {
         self.selected = selected;
         self
     }
+
+    pub fn on_click(
+        mut self,
+        listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+    ) -> Self {
+        self.on_click = Some(Rc::new(listener));
+        self
+    }
 }
 
 impl RenderOnce for ThemePreviewTile {
-    fn render(self, _window: &mut ui::Window, _cx: &mut ui::App) -> impl IntoElement {
+    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
         let color = self.theme.colors();
 
         let root_radius = px(8.0);
@@ -181,6 +188,13 @@ impl RenderOnce for ThemePreviewTile {
         let content = div().size_full().flex().child(sidebar).child(pane);
 
         div()
+            // Note: If two theme preview tiles are rendering the same theme they'll share an ID
+            // this will mean on hover and on click events will be shared between them
+            .id(SharedString::from(self.theme.id.clone()))
+            .when_some(self.on_click.clone(), |this, on_click| {
+                this.on_click(move |event, window, cx| on_click(event, window, cx))
+                    .hover(|style| style.cursor_pointer().border_color(color.element_hover))
+            })
             .size_full()
             .rounded(root_radius)
             .p(root_padding)
@@ -261,7 +275,7 @@ impl Component for ThemePreviewTile {
                                 themes_to_preview
                                     .iter()
                                     .enumerate()
-                                    .map(|(i, theme)| {
+                                    .map(|(_, theme)| {
                                         div().w(px(200.)).h(px(140.)).child(ThemePreviewTile::new(
                                             theme.clone(),
                                             false,

crates/ui/src/components/toggle.rs 🔗

@@ -1,6 +1,6 @@
 use gpui::{
-    AnyElement, AnyView, ClickEvent, CursorStyle, ElementId, Hsla, IntoElement, Styled, Window,
-    div, hsla, prelude::*,
+    AnyElement, AnyView, ClickEvent, ElementId, Hsla, IntoElement, Styled, Window, div, hsla,
+    prelude::*,
 };
 use std::sync::Arc;
 
@@ -610,7 +610,7 @@ impl RenderOnce for SwitchField {
         h_flex()
             .id(SharedString::from(format!("{}-container", self.id)))
             .when(!self.disabled, |this| {
-                this.hover(|this| this.cursor(CursorStyle::PointingHand))
+                this.hover(|this| this.cursor_pointer())
             })
             .w_full()
             .gap_4()

crates/welcome/Cargo.toml 🔗

@@ -29,7 +29,6 @@ project.workspace = true
 serde.workspace = true
 settings.workspace = true
 telemetry.workspace = true
-theme.workspace = true
 ui.workspace = true
 util.workspace = true
 vim_mode_setting.workspace = true

crates/welcome/src/welcome.rs 🔗

@@ -21,7 +21,6 @@ pub use multibuffer_hint::*;
 
 mod base_keymap_picker;
 mod multibuffer_hint;
-mod welcome_ui;
 
 actions!(
     welcome,