onboarding: Use a picker for the font dropdowns (#35638)

Danilo Leal created

Release Notes:

- N/A

Change summary

Cargo.lock                                  |   2 
crates/onboarding/Cargo.toml                |   2 
crates/onboarding/src/editing_page.rs       | 312 ++++++++++++++++++----
crates/picker/src/popover_menu.rs           |   1 
crates/ui/src/components/dropdown_menu.rs   |  24 
crates/ui/src/components/numeric_stepper.rs |   7 
6 files changed, 272 insertions(+), 76 deletions(-)

Detailed changes

Cargo.lock πŸ”—

@@ -11039,12 +11039,14 @@ dependencies = [
  "editor",
  "feature_flags",
  "fs",
+ "fuzzy",
  "gpui",
  "itertools 0.14.0",
  "language",
  "language_model",
  "menu",
  "notifications",
+ "picker",
  "project",
  "schemars",
  "serde",

crates/onboarding/Cargo.toml πŸ”—

@@ -25,12 +25,14 @@ db.workspace = true
 editor.workspace = true
 feature_flags.workspace = true
 fs.workspace = true
+fuzzy.workspace = true
 gpui.workspace = true
 itertools.workspace = true
 language.workspace = true
 language_model.workspace = true
 menu.workspace = true
 notifications.workspace = true
+picker.workspace = true
 project.workspace = true
 schemars.workspace = true
 serde.workspace = true

crates/onboarding/src/editing_page.rs πŸ”—

@@ -2,14 +2,19 @@ use std::sync::Arc;
 
 use editor::{EditorSettings, ShowMinimap};
 use fs::Fs;
-use gpui::{Action, App, FontFeatures, IntoElement, Pixels, Window};
+use fuzzy::{StringMatch, StringMatchCandidate};
+use gpui::{
+    Action, AnyElement, App, Context, FontFeatures, IntoElement, Pixels, SharedString, Task, Window,
+};
 use language::language_settings::{AllLanguageSettings, FormatOnSave};
+use picker::{Picker, PickerDelegate};
 use project::project_settings::ProjectSettings;
 use settings::{Settings as _, update_settings_file};
 use theme::{FontFamilyCache, FontFamilyName, ThemeSettings};
 use ui::{
-    ButtonLike, ContextMenu, DropdownMenu, NumericStepper, SwitchField, ToggleButtonGroup,
-    ToggleButtonGroupStyle, ToggleButtonSimple, ToggleState, Tooltip, prelude::*,
+    ButtonLike, ListItem, ListItemSpacing, NumericStepper, PopoverMenu, SwitchField,
+    ToggleButtonGroup, ToggleButtonGroupStyle, ToggleButtonSimple, ToggleState, Tooltip,
+    prelude::*,
 };
 
 use crate::{ImportCursorSettings, ImportVsCodeSettings, SettingsImportState};
@@ -246,9 +251,25 @@ fn render_import_settings_section(cx: &App) -> impl IntoElement {
 fn render_font_customization_section(window: &mut Window, cx: &mut App) -> impl IntoElement {
     let theme_settings = ThemeSettings::get_global(cx);
     let ui_font_size = theme_settings.ui_font_size(cx);
-    let font_family = theme_settings.buffer_font.family.clone();
+    let ui_font_family = theme_settings.ui_font.family.clone();
+    let buffer_font_family = theme_settings.buffer_font.family.clone();
     let buffer_font_size = theme_settings.buffer_font_size(cx);
 
+    let ui_font_picker =
+        cx.new(|cx| font_picker(ui_font_family.clone(), write_ui_font_family, window, cx));
+
+    let buffer_font_picker = cx.new(|cx| {
+        font_picker(
+            buffer_font_family.clone(),
+            write_buffer_font_family,
+            window,
+            cx,
+        )
+    });
+
+    let ui_font_handle = ui::PopoverMenuHandle::default();
+    let buffer_font_handle = ui::PopoverMenuHandle::default();
+
     h_flex()
         .w_full()
         .gap_4()
@@ -263,34 +284,35 @@ fn render_font_customization_section(window: &mut Window, cx: &mut App) -> impl
                         .justify_between()
                         .gap_2()
                         .child(
-                            DropdownMenu::new(
-                                "ui-font-family",
-                                theme_settings.ui_font.family.clone(),
-                                ContextMenu::build(window, cx, |mut menu, _, cx| {
-                                    let font_family_cache = FontFamilyCache::global(cx);
-
-                                    for font_name in font_family_cache.list_font_families(cx) {
-                                        menu = menu.custom_entry(
-                                            {
-                                                let font_name = font_name.clone();
-                                                move |_window, _cx| {
-                                                    Label::new(font_name.clone()).into_any_element()
-                                                }
-                                            },
-                                            {
-                                                let font_name = font_name.clone();
-                                                move |_window, cx| {
-                                                    write_ui_font_family(font_name.clone(), cx);
-                                                }
-                                            },
-                                        )
-                                    }
-
-                                    menu
-                                }),
-                            )
-                            .style(ui::DropdownStyle::Outlined)
-                            .full_width(true),
+                            PopoverMenu::new("ui-font-picker")
+                                .menu({
+                                    let ui_font_picker = ui_font_picker.clone();
+                                    move |_window, _cx| Some(ui_font_picker.clone())
+                                })
+                                .trigger(
+                                    ButtonLike::new("ui-font-family-button")
+                                        .style(ButtonStyle::Outlined)
+                                        .size(ButtonSize::Medium)
+                                        .full_width()
+                                        .child(
+                                            h_flex()
+                                                .w_full()
+                                                .justify_between()
+                                                .child(Label::new(ui_font_family))
+                                                .child(
+                                                    Icon::new(IconName::ChevronUpDown)
+                                                        .color(Color::Muted)
+                                                        .size(IconSize::XSmall),
+                                                ),
+                                        ),
+                                )
+                                .full_width(true)
+                                .anchor(gpui::Corner::TopLeft)
+                                .offset(gpui::Point {
+                                    x: px(0.0),
+                                    y: px(4.0),
+                                })
+                                .with_handle(ui_font_handle),
                         )
                         .child(
                             NumericStepper::new(
@@ -318,34 +340,35 @@ fn render_font_customization_section(window: &mut Window, cx: &mut App) -> impl
                         .justify_between()
                         .gap_2()
                         .child(
-                            DropdownMenu::new(
-                                "buffer-font-family",
-                                font_family,
-                                ContextMenu::build(window, cx, |mut menu, _, cx| {
-                                    let font_family_cache = FontFamilyCache::global(cx);
-
-                                    for font_name in font_family_cache.list_font_families(cx) {
-                                        menu = menu.custom_entry(
-                                            {
-                                                let font_name = font_name.clone();
-                                                move |_window, _cx| {
-                                                    Label::new(font_name.clone()).into_any_element()
-                                                }
-                                            },
-                                            {
-                                                let font_name = font_name.clone();
-                                                move |_window, cx| {
-                                                    write_buffer_font_family(font_name.clone(), cx);
-                                                }
-                                            },
-                                        )
-                                    }
-
-                                    menu
-                                }),
-                            )
-                            .style(ui::DropdownStyle::Outlined)
-                            .full_width(true),
+                            PopoverMenu::new("buffer-font-picker")
+                                .menu({
+                                    let buffer_font_picker = buffer_font_picker.clone();
+                                    move |_window, _cx| Some(buffer_font_picker.clone())
+                                })
+                                .trigger(
+                                    ButtonLike::new("buffer-font-family-button")
+                                        .style(ButtonStyle::Outlined)
+                                        .size(ButtonSize::Medium)
+                                        .full_width()
+                                        .child(
+                                            h_flex()
+                                                .w_full()
+                                                .justify_between()
+                                                .child(Label::new(buffer_font_family))
+                                                .child(
+                                                    Icon::new(IconName::ChevronUpDown)
+                                                        .color(Color::Muted)
+                                                        .size(IconSize::XSmall),
+                                                ),
+                                        ),
+                                )
+                                .full_width(true)
+                                .anchor(gpui::Corner::TopLeft)
+                                .offset(gpui::Point {
+                                    x: px(0.0),
+                                    y: px(4.0),
+                                })
+                                .with_handle(buffer_font_handle),
                         )
                         .child(
                             NumericStepper::new(
@@ -364,6 +387,175 @@ fn render_font_customization_section(window: &mut Window, cx: &mut App) -> impl
         )
 }
 
+type FontPicker = Picker<FontPickerDelegate>;
+
+pub struct FontPickerDelegate {
+    fonts: Vec<SharedString>,
+    filtered_fonts: Vec<StringMatch>,
+    selected_index: usize,
+    current_font: SharedString,
+    on_font_changed: Arc<dyn Fn(SharedString, &mut App) + 'static>,
+}
+
+impl FontPickerDelegate {
+    fn new(
+        current_font: SharedString,
+        on_font_changed: impl Fn(SharedString, &mut App) + 'static,
+        cx: &mut Context<FontPicker>,
+    ) -> Self {
+        let font_family_cache = FontFamilyCache::global(cx);
+
+        let fonts: Vec<SharedString> = font_family_cache
+            .list_font_families(cx)
+            .into_iter()
+            .collect();
+
+        let selected_index = fonts
+            .iter()
+            .position(|font| *font == current_font)
+            .unwrap_or(0);
+
+        Self {
+            fonts: fonts.clone(),
+            filtered_fonts: fonts
+                .iter()
+                .enumerate()
+                .map(|(index, font)| StringMatch {
+                    candidate_id: index,
+                    string: font.to_string(),
+                    positions: Vec::new(),
+                    score: 0.0,
+                })
+                .collect(),
+            selected_index,
+            current_font,
+            on_font_changed: Arc::new(on_font_changed),
+        }
+    }
+}
+
+impl PickerDelegate for FontPickerDelegate {
+    type ListItem = AnyElement;
+
+    fn match_count(&self) -> usize {
+        self.filtered_fonts.len()
+    }
+
+    fn selected_index(&self) -> usize {
+        self.selected_index
+    }
+
+    fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<FontPicker>) {
+        self.selected_index = ix.min(self.filtered_fonts.len().saturating_sub(1));
+        cx.notify();
+    }
+
+    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
+        "Search fonts…".into()
+    }
+
+    fn update_matches(
+        &mut self,
+        query: String,
+        _window: &mut Window,
+        cx: &mut Context<FontPicker>,
+    ) -> Task<()> {
+        let fonts = self.fonts.clone();
+        let current_font = self.current_font.clone();
+
+        let matches: Vec<StringMatch> = if query.is_empty() {
+            fonts
+                .iter()
+                .enumerate()
+                .map(|(index, font)| StringMatch {
+                    candidate_id: index,
+                    string: font.to_string(),
+                    positions: Vec::new(),
+                    score: 0.0,
+                })
+                .collect()
+        } else {
+            let _candidates: Vec<StringMatchCandidate> = fonts
+                .iter()
+                .enumerate()
+                .map(|(id, font)| StringMatchCandidate::new(id, font.as_ref()))
+                .collect();
+
+            fonts
+                .iter()
+                .enumerate()
+                .filter(|(_, font)| font.to_lowercase().contains(&query.to_lowercase()))
+                .map(|(index, font)| StringMatch {
+                    candidate_id: index,
+                    string: font.to_string(),
+                    positions: Vec::new(),
+                    score: 0.0,
+                })
+                .collect()
+        };
+
+        let selected_index = if query.is_empty() {
+            fonts
+                .iter()
+                .position(|font| *font == current_font)
+                .unwrap_or(0)
+        } else {
+            matches
+                .iter()
+                .position(|m| fonts[m.candidate_id] == current_font)
+                .unwrap_or(0)
+        };
+
+        self.filtered_fonts = matches;
+        self.selected_index = selected_index;
+        cx.notify();
+
+        Task::ready(())
+    }
+
+    fn confirm(&mut self, _secondary: bool, _window: &mut Window, cx: &mut Context<FontPicker>) {
+        if let Some(font_match) = self.filtered_fonts.get(self.selected_index) {
+            let font = font_match.string.clone();
+            (self.on_font_changed)(font.into(), cx);
+        }
+    }
+
+    fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<FontPicker>) {}
+
+    fn render_match(
+        &self,
+        ix: usize,
+        selected: bool,
+        _window: &mut Window,
+        _cx: &mut Context<FontPicker>,
+    ) -> Option<Self::ListItem> {
+        let font_match = self.filtered_fonts.get(ix)?;
+
+        Some(
+            ListItem::new(ix)
+                .inset(true)
+                .spacing(ListItemSpacing::Sparse)
+                .toggle_state(selected)
+                .child(Label::new(font_match.string.clone()))
+                .into_any_element(),
+        )
+    }
+}
+
+fn font_picker(
+    current_font: SharedString,
+    on_font_changed: impl Fn(SharedString, &mut App) + 'static,
+    window: &mut Window,
+    cx: &mut Context<FontPicker>,
+) -> FontPicker {
+    let delegate = FontPickerDelegate::new(current_font, on_font_changed, cx);
+
+    Picker::list(delegate, window, cx)
+        .show_scrollbar(true)
+        .width(rems_from_px(210.))
+        .max_height(Some(rems(20.).into()))
+}
+
 fn render_popular_settings_section(window: &mut Window, cx: &mut App) -> impl IntoElement {
     const LIGATURE_TOOLTIP: &'static str = "Ligatures are when a font creates a special character out of combining two characters into one. For example, with ligatures turned on, =/= would become β‰ .";
 

crates/picker/src/popover_menu.rs πŸ”—

@@ -80,6 +80,7 @@ where
 {
     fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
         let picker = self.picker.clone();
+
         PopoverMenu::new("popover-menu")
             .menu(move |_window, _cx| Some(picker.clone()))
             .trigger_with_tooltip(self.trigger, self.tooltip)

crates/ui/src/components/dropdown_menu.rs πŸ”—

@@ -276,25 +276,25 @@ impl RenderOnce for DropdownMenuTrigger {
             .gap_2()
             .justify_between()
             .rounded_sm()
-            .bg(style.bg)
-            .hover(|s| s.bg(cx.theme().colors().element_hover))
+            .map(|this| {
+                if self.full_width {
+                    this.w_full()
+                } else {
+                    this.flex_none().w_auto()
+                }
+            })
             .when(is_outlined, |this| {
                 this.border_1()
                     .border_color(cx.theme().colors().border)
                     .overflow_hidden()
             })
-            .map(|el| {
-                if self.full_width {
-                    el.w_full()
-                } else {
-                    el.flex_none().w_auto()
-                }
-            })
-            .map(|el| {
+            .map(|this| {
                 if disabled {
-                    el.cursor_not_allowed()
+                    this.cursor_not_allowed()
+                        .bg(cx.theme().colors().element_disabled)
                 } else {
-                    el.cursor_pointer()
+                    this.bg(style.bg)
+                        .hover(|s| s.bg(cx.theme().colors().element_hover))
                 }
             })
             .child(match self.label {

crates/ui/src/components/numeric_stepper.rs πŸ”—

@@ -96,7 +96,7 @@ impl RenderOnce for NumericStepper {
                             this.overflow_hidden()
                                 .bg(cx.theme().colors().surface_background)
                                 .border_1()
-                                .border_color(cx.theme().colors().border)
+                                .border_color(cx.theme().colors().border_variant)
                         } else {
                             this.px_1().bg(cx.theme().colors().editor_background)
                         }
@@ -111,7 +111,7 @@ impl RenderOnce for NumericStepper {
                                     .justify_center()
                                     .hover(|s| s.bg(cx.theme().colors().element_hover))
                                     .border_r_1()
-                                    .border_color(cx.theme().colors().border)
+                                    .border_color(cx.theme().colors().border_variant)
                                     .child(Icon::new(IconName::Dash).size(IconSize::Small))
                                     .on_click(self.on_decrement),
                             )
@@ -124,7 +124,6 @@ impl RenderOnce for NumericStepper {
                             )
                         }
                     })
-                    .when(is_outlined, |this| this)
                     .child(Label::new(self.value).mx_3())
                     .map(|increment| {
                         if is_outlined {
@@ -136,7 +135,7 @@ impl RenderOnce for NumericStepper {
                                     .justify_center()
                                     .hover(|s| s.bg(cx.theme().colors().element_hover))
                                     .border_l_1()
-                                    .border_color(cx.theme().colors().border)
+                                    .border_color(cx.theme().colors().border_variant)
                                     .child(Icon::new(IconName::Plus).size(IconSize::Small))
                                     .on_click(self.on_increment),
                             )