settings ui: Add numeric steppers to settings UI (#39491)

Anthony Eid created

This PR adds the numeric stepper component to the settings ui and
implements some settings that rely on this component as well.

I also switched {buffer/ui}_font_weight to the `gpui::FontWeight` type
and added a manual implementation of the Schemars trait. This allows Zed
to send min, max, and default information to the JSON LSP when a user is
manually editing the settings file.

The numeric stepper elements added to the settings ui are below:
- ui font size
- ui font weight
- Buffer font size
- Buffer font weight 
- Scroll sensitivity
- Fast scroll sensitivity
- Vertical scroll margin
- Horizontal scroll margin
- Inline blame padding 
- Inline blame delay
- Inline blame min column
- Unnecessary code fade
- Tab Size
- Hover popover delay

Release Notes:

- N/A

Change summary

Cargo.lock                                    |   2 
assets/settings/default.json                  |   4 
crates/gpui/src/text_system.rs                |  34 +
crates/settings/Cargo.toml                    |   1 
crates/settings/src/merge_from.rs             |   3 
crates/settings/src/settings_content/theme.rs |  78 ++++
crates/settings/src/vscode_import.rs          |   6 
crates/settings_ui/Cargo.toml                 |   1 
crates/settings_ui/src/settings_ui.rs         | 370 ++++++++++++++------
crates/theme/src/settings.rs                  |  10 
crates/ui_input/src/numeric_stepper.rs        | 129 ++++++
11 files changed, 501 insertions(+), 137 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -14292,6 +14292,7 @@ name = "settings"
 version = "0.1.0"
 dependencies = [
  "anyhow",
+ "derive_more",
  "ec4rs",
  "fs",
  "futures 0.3.31",
@@ -14379,6 +14380,7 @@ dependencies = [
  "strum 0.27.1",
  "theme",
  "ui",
+ "ui_input",
  "workspace",
  "workspace-hack",
  "zed-util",

assets/settings/default.json 🔗

@@ -1231,6 +1231,10 @@
     // 2. Hide the gutter
     //      "git_gutter": "hide"
     "git_gutter": "tracked_files",
+    /// Sets the debounce threshold (in milliseconds) after which changes are reflected in the git gutter.
+    ///
+    /// Default: null
+    "gutter_debounce": null,
     // Control whether the git blame information is shown inline,
     // in the currently focused line.
     "inline_blame": {

crates/gpui/src/text_system.rs 🔗

@@ -19,7 +19,7 @@ use crate::{
 use anyhow::{Context as _, anyhow};
 use collections::FxHashMap;
 use core::fmt;
-use derive_more::Deref;
+use derive_more::{Add, Deref, FromStr, Sub};
 use itertools::Itertools;
 use parking_lot::{Mutex, RwLock, RwLockUpgradableReadGuard};
 use smallvec::{SmallVec, smallvec};
@@ -605,9 +605,22 @@ impl DerefMut for LineWrapperHandle {
 
 /// The degree of blackness or stroke thickness of a font. This value ranges from 100.0 to 900.0,
 /// with 400.0 as normal.
-#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Serialize, Deserialize, JsonSchema)]
+#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Serialize, Deserialize, Add, Sub, FromStr)]
+#[serde(transparent)]
 pub struct FontWeight(pub f32);
 
+impl Display for FontWeight {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{}", self.0)
+    }
+}
+
+impl From<f32> for FontWeight {
+    fn from(weight: f32) -> Self {
+        FontWeight(weight)
+    }
+}
+
 impl Default for FontWeight {
     #[inline]
     fn default() -> FontWeight {
@@ -657,6 +670,23 @@ impl FontWeight {
     ];
 }
 
+impl schemars::JsonSchema for FontWeight {
+    fn schema_name() -> std::borrow::Cow<'static, str> {
+        "FontWeight".into()
+    }
+
+    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
+        use schemars::json_schema;
+        json_schema!({
+            "type": "number",
+            "minimum": Self::THIN,
+            "maximum": Self::BLACK,
+            "default": Self::default(),
+            "description": "Font weight value between 100 (thin) and 900 (black)"
+        })
+    }
+}
+
 /// Allows italic or oblique faces to be selected.
 #[derive(Clone, Copy, Eq, PartialEq, Debug, Hash, Default, Serialize, Deserialize, JsonSchema)]
 pub enum FontStyle {

crates/settings/Cargo.toml 🔗

@@ -18,6 +18,7 @@ test-support = ["gpui/test-support", "fs/test-support"]
 [dependencies]
 anyhow.workspace = true
 collections.workspace = true
+derive_more.workspace = true
 ec4rs.workspace = true
 fs.workspace = true
 futures.workspace = true

crates/settings/src/merge_from.rs 🔗

@@ -57,7 +57,8 @@ merge_from_overwrites!(
     gpui::SharedString,
     std::path::PathBuf,
     gpui::Modifiers,
-    gpui::FontFeatures
+    gpui::FontFeatures,
+    gpui::FontWeight
 );
 
 impl<T: Clone + MergeFrom> MergeFrom for Option<T> {

crates/settings/src/settings_content/theme.rs 🔗

@@ -5,7 +5,7 @@ use serde::{Deserialize, Deserializer, Serialize};
 use serde_json::Value;
 use serde_repr::{Deserialize_repr, Serialize_repr};
 use settings_macros::MergeFrom;
-use std::sync::Arc;
+use std::{fmt::Display, sync::Arc};
 
 use serde_with::skip_serializing_none;
 
@@ -31,7 +31,8 @@ pub struct ThemeSettingsContent {
     pub ui_font_features: Option<FontFeatures>,
     /// The weight of the UI font in CSS units from 100 to 900.
     #[serde(default)]
-    pub ui_font_weight: Option<f32>,
+    #[schemars(default = "default_buffer_font_weight")]
+    pub ui_font_weight: Option<FontWeight>,
     /// The name of a font to use for rendering in text buffers.
     #[serde(default)]
     pub buffer_font_family: Option<FontFamilyName>,
@@ -44,7 +45,8 @@ pub struct ThemeSettingsContent {
     pub buffer_font_size: Option<f32>,
     /// The weight of the editor font in CSS units from 100 to 900.
     #[serde(default)]
-    pub buffer_font_weight: Option<f32>,
+    #[schemars(default = "default_buffer_font_weight")]
+    pub buffer_font_weight: Option<FontWeight>,
     /// The buffer's line height.
     #[serde(default)]
     pub buffer_line_height: Option<BufferLineHeight>,
@@ -73,7 +75,8 @@ pub struct ThemeSettingsContent {
 
     /// How much to fade out unused code.
     #[serde(default)]
-    pub unnecessary_code_fade: Option<f32>,
+    #[schemars(range(min = 0.0, max = 0.9))]
+    pub unnecessary_code_fade: Option<CodeFade>,
 
     /// EXPERIMENTAL: Overrides for the current theme.
     ///
@@ -88,6 +91,27 @@ pub struct ThemeSettingsContent {
     pub theme_overrides: HashMap<String, ThemeStyleContent>,
 }
 
+#[derive(
+    Clone,
+    Copy,
+    Debug,
+    Serialize,
+    Deserialize,
+    JsonSchema,
+    MergeFrom,
+    PartialEq,
+    PartialOrd,
+    derive_more::FromStr,
+)]
+#[serde(transparent)]
+pub struct CodeFade(pub f32);
+
+impl Display for CodeFade {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{:.2}", self.0)
+    }
+}
+
 fn default_font_features() -> Option<FontFeatures> {
     Some(FontFeatures::default())
 }
@@ -96,6 +120,10 @@ fn default_font_fallbacks() -> Option<FontFallbacks> {
     Some(FontFallbacks::default())
 }
 
+fn default_buffer_font_weight() -> Option<FontWeight> {
+    Some(FontWeight::default())
+}
+
 /// Represents the selection of a theme, which can be either static or dynamic.
 #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq)]
 #[serde(untagged)]
@@ -1113,4 +1141,46 @@ mod tests {
                 .contains("buffer_line_height.custom must be at least 1.0")
         );
     }
+
+    #[test]
+    fn test_buffer_font_weight_schema_has_default() {
+        use schemars::schema_for;
+
+        let schema = schema_for!(ThemeSettingsContent);
+        let schema_value = serde_json::to_value(&schema).unwrap();
+
+        let properties = &schema_value["properties"];
+        let buffer_font_weight = &properties["buffer_font_weight"];
+
+        assert!(
+            buffer_font_weight.get("default").is_some(),
+            "buffer_font_weight should have a default value in the schema"
+        );
+
+        let default_value = &buffer_font_weight["default"];
+        assert_eq!(
+            default_value.as_f64(),
+            Some(FontWeight::NORMAL.0 as f64),
+            "buffer_font_weight default should be 400.0 (FontWeight::NORMAL)"
+        );
+
+        let defs = &schema_value["$defs"];
+        let font_weight_def = &defs["FontWeight"];
+
+        assert_eq!(
+            font_weight_def["minimum"].as_f64(),
+            Some(FontWeight::THIN.0 as f64),
+            "FontWeight should have minimum of 100.0"
+        );
+        assert_eq!(
+            font_weight_def["maximum"].as_f64(),
+            Some(FontWeight::BLACK.0 as f64),
+            "FontWeight should have maximum of 900.0"
+        );
+        assert_eq!(
+            font_weight_def["default"].as_f64(),
+            Some(FontWeight::NORMAL.0 as f64),
+            "FontWeight should have default of 400.0"
+        );
+    }
 }

crates/settings/src/vscode_import.rs 🔗

@@ -125,6 +125,12 @@ impl VsCodeSettings {
         }
     }
 
+    pub fn from_f32_setting<T: From<f32>>(&self, key: &str, setting: &mut Option<T>) {
+        if let Some(s) = self.content.get(key).and_then(Value::as_f64) {
+            *setting = Some(T::from(s as f32))
+        }
+    }
+
     pub fn enum_setting<T>(
         &self,
         key: &str,

crates/settings_ui/Cargo.toml 🔗

@@ -31,6 +31,7 @@ settings.workspace = true
 strum.workspace = true
 theme.workspace = true
 ui.workspace = true
+ui_input.workspace = true
 util.workspace = true
 workspace-hack.workspace = true
 workspace.workspace = true

crates/settings_ui/src/settings_ui.rs 🔗

@@ -5,19 +5,20 @@ use editor::{Editor, EditorEvent};
 use feature_flags::{FeatureFlag, FeatureFlagAppExt as _};
 use fuzzy::StringMatchCandidate;
 use gpui::{
-    App, AppContext as _, Context, Div, Entity, Global, IntoElement, ReadGlobal as _, Render,
-    ScrollHandle, Task, TitlebarOptions, UniformListScrollHandle, Window, WindowHandle,
+    App, AppContext as _, Context, Div, Entity, FontWeight, Global, IntoElement, ReadGlobal as _,
+    Render, ScrollHandle, Task, TitlebarOptions, UniformListScrollHandle, Window, WindowHandle,
     WindowOptions, actions, div, point, px, size, uniform_list,
 };
 use project::WorktreeId;
 use settings::{
-    BottomDockLayout, CloseWindowWhenNoItems, CursorShape, OnLastWindowClosed,
+    BottomDockLayout, CloseWindowWhenNoItems, CodeFade, CursorShape, OnLastWindowClosed,
     RestoreOnStartupBehavior, SaturatingBool, SettingsContent, SettingsStore,
 };
 use std::{
     any::{Any, TypeId, type_name},
     cell::RefCell,
     collections::HashMap,
+    num::NonZeroU32,
     ops::Range,
     rc::Rc,
     sync::{Arc, atomic::AtomicBool},
@@ -26,6 +27,7 @@ use ui::{
     ContextMenu, Divider, DropdownMenu, DropdownStyle, Switch, SwitchColor, TreeViewItem,
     prelude::*,
 };
+use ui_input::{NumericStepper, NumericStepperType};
 use util::{ResultExt as _, paths::PathStyle, rel_path::RelPath};
 
 use crate::components::SettingsEditor;
@@ -367,25 +369,24 @@ fn user_settings_data() -> Vec<SettingsPage> {
                     }),
                     metadata: None,
                 }),
-                // todo(settings_ui): We need to implement a numeric stepper for these
-                // SettingsPageItem::SettingItem(SettingItem {
-                //     title: "Buffer Font Size",
-                //     description: "Font size for editor text",
-                //     field: Box::new(SettingField {
-                //         pick: |settings_content| &settings_content.theme.buffer_font_size,
-                //         pick_mut: |settings_content| &mut settings_content.theme.buffer_font_size,
-                //     }),
-                //     metadata: None,
-                // }),
-                // SettingsPageItem::SettingItem(SettingItem {
-                //     title: "Buffer Font Weight",
-                //     description: "Font weight for editor text (100-900)",
-                //     field: Box::new(SettingField {
-                //         pick: |settings_content| &settings_content.theme.buffer_font_weight,
-                //         pick_mut: |settings_content| &mut settings_content.theme.buffer_font_weight,
-                //     }),
-                //     metadata: None,
-                // }),
+                SettingsPageItem::SettingItem(SettingItem {
+                    title: "Buffer Font Size",
+                    description: "Font size for editor text",
+                    field: Box::new(SettingField {
+                        pick: |settings_content| &settings_content.theme.buffer_font_size,
+                        pick_mut: |settings_content| &mut settings_content.theme.buffer_font_size,
+                    }),
+                    metadata: None,
+                }),
+                SettingsPageItem::SettingItem(SettingItem {
+                    title: "Buffer Font Weight",
+                    description: "Font weight for editor text (100-900)",
+                    field: Box::new(SettingField {
+                        pick: |settings_content| &settings_content.theme.buffer_font_weight,
+                        pick_mut: |settings_content| &mut settings_content.theme.buffer_font_weight,
+                    }),
+                    metadata: None,
+                }),
                 SettingsPageItem::SettingItem(SettingItem {
                     title: "Buffer Line Height",
                     description: "Line height for editor text",
@@ -404,25 +405,24 @@ fn user_settings_data() -> Vec<SettingsPage> {
                     }),
                     metadata: None,
                 }),
-                // todo(settings_ui): We need to implement a numeric stepper for these
-                // SettingsPageItem::SettingItem(SettingItem {
-                //     title: "UI Font Size",
-                //     description: "Font size for UI elements",
-                //     field: Box::new(SettingField {
-                //         pick: |settings_content| &settings_content.theme.ui_font_size,
-                //         pick_mut: |settings_content| &mut settings_content.theme.ui_font_size,
-                //     }),
-                //     metadata: None,
-                // }),
-                // SettingsPageItem::SettingItem(SettingItem {
-                //     title: "UI Font Weight",
-                //     description: "Font weight for UI elements (100-900)",
-                //     field: Box::new(SettingField {
-                //         pick: |settings_content| &settings_content.theme.ui_font_weight,
-                //         pick_mut: |settings_content| &mut settings_content.theme.ui_font_weight,
-                //     }),
-                //     metadata: None,
-                // }),
+                SettingsPageItem::SettingItem(SettingItem {
+                    title: "UI Font Size",
+                    description: "Font size for UI elements",
+                    field: Box::new(SettingField {
+                        pick: |settings_content| &settings_content.theme.ui_font_size,
+                        pick_mut: |settings_content| &mut settings_content.theme.ui_font_size,
+                    }),
+                    metadata: None,
+                }),
+                SettingsPageItem::SettingItem(SettingItem {
+                    title: "UI Font Weight",
+                    description: "Font weight for UI elements (100-900)",
+                    field: Box::new(SettingField {
+                        pick: |settings_content| &settings_content.theme.ui_font_weight,
+                        pick_mut: |settings_content| &mut settings_content.theme.ui_font_weight,
+                    }),
+                    metadata: None,
+                }),
                 SettingsPageItem::SectionHeader("Keymap"),
                 SettingsPageItem::SettingItem(SettingItem {
                     title: "Base Keymap",
@@ -493,16 +493,17 @@ fn user_settings_data() -> Vec<SettingsPage> {
                     metadata: None,
                 }),
                 SettingsPageItem::SectionHeader("Highlighting"),
-                // todo(settings_ui): numeric stepper and validator is needed for this
-                // SettingsPageItem::SettingItem(SettingItem {
-                //     title: "Unnecessary Code Fade",
-                //     description: "How much to fade out unused code (0.0 - 0.9)",
-                //     field: Box::new(SettingField {
-                //         pick: |settings_content| &settings_content.theme.unnecessary_code_fade,
-                //         pick_mut: |settings_content| &mut settings_content.theme.unnecessary_code_fade,
-                //     }),
-                //     metadata: None,
-                // }),
+                SettingsPageItem::SettingItem(SettingItem {
+                    title: "Unnecessary Code Fade",
+                    description: "How much to fade out unused code (0.0 - 0.9)",
+                    field: Box::new(SettingField {
+                        pick: |settings_content| &settings_content.theme.unnecessary_code_fade,
+                        pick_mut: |settings_content| {
+                            &mut settings_content.theme.unnecessary_code_fade
+                        },
+                    }),
+                    metadata: None,
+                }),
                 SettingsPageItem::SettingItem(SettingItem {
                     title: "Current Line Highlight",
                     description: "How to highlight the current line",
@@ -623,25 +624,41 @@ fn user_settings_data() -> Vec<SettingsPage> {
                     }),
                     metadata: None,
                 }),
-                // todo(settings_ui): Needs numeric stepper
+                // todo(settings_ui): Needs numeric stepper + option within an option
                 // SettingsPageItem::SettingItem(SettingItem {
                 //     title: "Centered Layout Left Padding",
-                //     description: "Left padding for cenetered layout",
+                //     description: "Left padding for centered layout",
                 //     field: Box::new(SettingField {
-                //         pick: |settings_content| &settings_content.workspace.bottom_dock_layout,
+                //         pick: |settings_content| {
+                //             &settings_content.workspace.centered_layout.left_padding
+                //         },
                 //         pick_mut: |settings_content| {
-                //             &mut settings_content.workspace.bottom_dock_layout
+                //             &mut settings_content.workspace.centered_layout.left_padding
                 //         },
                 //     }),
                 //     metadata: None,
                 // }),
                 // SettingsPageItem::SettingItem(SettingItem {
                 //     title: "Centered Layout Right Padding",
-                //     description: "Right padding for cenetered layout",
+                //     description: "Right padding for centered layout",
                 //     field: Box::new(SettingField {
-                //         pick: |settings_content| &settings_content.workspace.bottom_dock_layout,
+                //         pick: |settings_content| {
+                //             if let Some(centered_layout) =
+                //                 &settings_content.workspace.centered_layout
+                //             {
+                //                 &centered_layout.right_padding
+                //             } else {
+                //                 &None
+                //             }
+                //         },
                 //         pick_mut: |settings_content| {
-                //             &mut settings_content.workspace.bottom_dock_layout
+                //             if let Some(mut centered_layout) =
+                //                 settings_content.workspace.centered_layout
+                //             {
+                //                 &mut centered_layout.right_padding
+                //             } else {
+                //                 &mut None
+                //             }
                 //         },
                 //     }),
                 //     metadata: None,
@@ -664,15 +681,19 @@ fn user_settings_data() -> Vec<SettingsPage> {
             items: vec![
                 SettingsPageItem::SectionHeader("Indentation"),
                 // todo(settings_ui): Needs numeric stepper
-                // SettingsPageItem::SettingItem(SettingItem {
-                //     title: "Tab Size",
-                //     description: "How many columns a tab should occupy",
-                //     field: Box::new(SettingField {
-                //         pick: |settings_content| &settings_content.project.all_languages.defaults.tab_size,
-                //         pick_mut: |settings_content| &mut settings_content.project.all_languages.defaults.tab_size,
-                //     }),
-                //     metadata: None,
-                // }),
+                SettingsPageItem::SettingItem(SettingItem {
+                    title: "Tab Size",
+                    description: "How many columns a tab should occupy",
+                    field: Box::new(SettingField {
+                        pick: |settings_content| {
+                            &settings_content.project.all_languages.defaults.tab_size
+                        },
+                        pick_mut: |settings_content| {
+                            &mut settings_content.project.all_languages.defaults.tab_size
+                        },
+                    }),
+                    metadata: None,
+                }),
                 SettingsPageItem::SettingItem(SettingItem {
                     title: "Hard Tabs",
                     description: "Whether to indent lines using tab characters, as opposed to multiple spaces",
@@ -837,36 +858,50 @@ fn user_settings_data() -> Vec<SettingsPage> {
                     }),
                     metadata: None,
                 }),
-                // todo(settings_ui): Needs numeric stepper
-                // SettingsPageItem::SettingItem(SettingItem {
-                //     title: "Vertical Scroll Margin",
-                //     description: "The number of lines to keep above/below the cursor when auto-scrolling",
-                //     field: Box::new(SettingField {
-                //         pick: |settings_content| &settings_content.editor.vertical_scroll_margin,
-                //         pick_mut: |settings_content| &mut settings_content.editor.vertical_scroll_margin,
-                //     }),
-                //     metadata: None,
-                // }),
-                // todo(settings_ui): Needs numeric stepper
-                // SettingsPageItem::SettingItem(SettingItem {
-                //     title: "Horizontal Scroll Margin",
-                //     description: "The number of characters to keep on either side when scrolling with the mouse",
-                //     field: Box::new(SettingField {
-                //         pick: |settings_content| &settings_content.editor.horizontal_scroll_margin,
-                //         pick_mut: |settings_content| &mut settings_content.editor.horizontal_scroll_margin,
-                //     }),
-                //     metadata: None,
-                // }),
-                // todo(settings_ui): Needs numeric stepper
-                // SettingsPageItem::SettingItem(SettingItem {
-                //     title: "Scroll Sensitivity",
-                //     description: "Scroll sensitivity multiplier",
-                //     field: Box::new(SettingField {
-                //         pick: |settings_content| &settings_content.editor.scroll_sensitivity,
-                //         pick_mut: |settings_content| &mut settings_content.editor.scroll_sensitivity,
-                //     }),
-                //     metadata: None,
-                // }),
+                SettingsPageItem::SettingItem(SettingItem {
+                    title: "Vertical Scroll Margin",
+                    description: "The number of lines to keep above/below the cursor when auto-scrolling",
+                    field: Box::new(SettingField {
+                        pick: |settings_content| &settings_content.editor.vertical_scroll_margin,
+                        pick_mut: |settings_content| {
+                            &mut settings_content.editor.vertical_scroll_margin
+                        },
+                    }),
+                    metadata: None,
+                }),
+                SettingsPageItem::SettingItem(SettingItem {
+                    title: "Horizontal Scroll Margin",
+                    description: "The number of characters to keep on either side when scrolling with the mouse",
+                    field: Box::new(SettingField {
+                        pick: |settings_content| &settings_content.editor.horizontal_scroll_margin,
+                        pick_mut: |settings_content| {
+                            &mut settings_content.editor.horizontal_scroll_margin
+                        },
+                    }),
+                    metadata: None,
+                }),
+                SettingsPageItem::SettingItem(SettingItem {
+                    title: "Scroll Sensitivity",
+                    description: "Scroll sensitivity multiplier for both horizontal and vertical scrolling",
+                    field: Box::new(SettingField {
+                        pick: |settings_content| &settings_content.editor.scroll_sensitivity,
+                        pick_mut: |settings_content| {
+                            &mut settings_content.editor.scroll_sensitivity
+                        },
+                    }),
+                    metadata: None,
+                }),
+                SettingsPageItem::SettingItem(SettingItem {
+                    title: "Fast Scroll Sensitivity",
+                    description: "Fast Scroll sensitivity multiplier for both horizontal and vertical scrolling",
+                    field: Box::new(SettingField {
+                        pick: |settings_content| &settings_content.editor.fast_scroll_sensitivity,
+                        pick_mut: |settings_content| {
+                            &mut settings_content.editor.fast_scroll_sensitivity
+                        },
+                    }),
+                    metadata: None,
+                }),
                 SettingsPageItem::SettingItem(SettingItem {
                     title: "Autoscroll On Clicks",
                     description: "Whether to scroll when clicking near the edge of the visible text area",
@@ -1117,16 +1152,18 @@ fn user_settings_data() -> Vec<SettingsPage> {
                     }),
                     metadata: None,
                 }),
-                // todo(settings_ui): Needs numeric stepper
-                // SettingsPageItem::SettingItem(SettingItem {
-                //     title: "Hover Popover Delay",
-                //     description: "Time to wait in milliseconds before showing the informational hover box",
-                //     field: Box::new(SettingField {
-                //         pick: |settings_content| &settings_content.editor.hover_popover_delay,
-                //         pick_mut: |settings_content| &mut settings_content.editor.hover_popover_delay,
-                //     }),
-                //     metadata: None,
-                // }),
+                // todo(settings ui): add units to this numeric stepper
+                SettingsPageItem::SettingItem(SettingItem {
+                    title: "Hover Popover Delay",
+                    description: "Time to wait in milliseconds before showing the informational hover box",
+                    field: Box::new(SettingField {
+                        pick: |settings_content| &settings_content.editor.hover_popover_delay,
+                        pick_mut: |settings_content| {
+                            &mut settings_content.editor.hover_popover_delay
+                        },
+                    }),
+                    metadata: None,
+                }),
                 SettingsPageItem::SectionHeader("Code Actions"),
                 SettingsPageItem::SettingItem(SettingItem {
                     title: "Inline Code Actions",
@@ -1713,7 +1750,7 @@ fn user_settings_data() -> Vec<SettingsPage> {
                     }),
                     metadata: None,
                 }),
-                // todo(settings_ui): Needs numeric stepper
+                // todo(settings_ui): Figure out the right default for this value in default.json
                 // SettingsPageItem::SettingItem(SettingItem {
                 //     title: "Gutter Debounce",
                 //     description: "Debounce threshold in milliseconds after which changes are reflected in the git gutter",
@@ -1757,6 +1794,84 @@ fn user_settings_data() -> Vec<SettingsPage> {
                     }),
                     metadata: None,
                 }),
+                SettingsPageItem::SettingItem(SettingItem {
+                    title: "Inline Blame Delay",
+                    description: "The delay after which the inline blame information is shown",
+                    field: Box::new(SettingField {
+                        pick: |settings_content| {
+                            if let Some(git) = &settings_content.git {
+                                if let Some(inline_blame) = &git.inline_blame {
+                                    &inline_blame.delay_ms
+                                } else {
+                                    &None
+                                }
+                            } else {
+                                &None
+                            }
+                        },
+                        pick_mut: |settings_content| {
+                            &mut settings_content
+                                .git
+                                .get_or_insert_default()
+                                .inline_blame
+                                .get_or_insert_default()
+                                .delay_ms
+                        },
+                    }),
+                    metadata: None,
+                }),
+                SettingsPageItem::SettingItem(SettingItem {
+                    title: "Inline Blame Padding",
+                    description: "Padding between the end of the source line and the start of the inline blame in columns",
+                    field: Box::new(SettingField {
+                        pick: |settings_content| {
+                            if let Some(git) = &settings_content.git {
+                                if let Some(inline_blame) = &git.inline_blame {
+                                    &inline_blame.padding
+                                } else {
+                                    &None
+                                }
+                            } else {
+                                &None
+                            }
+                        },
+                        pick_mut: |settings_content| {
+                            &mut settings_content
+                                .git
+                                .get_or_insert_default()
+                                .inline_blame
+                                .get_or_insert_default()
+                                .padding
+                        },
+                    }),
+                    metadata: None,
+                }),
+                SettingsPageItem::SettingItem(SettingItem {
+                    title: "Inline Blame Min Column",
+                    description: "The minimum column number to show the inline blame information at",
+                    field: Box::new(SettingField {
+                        pick: |settings_content| {
+                            if let Some(git) = &settings_content.git {
+                                if let Some(inline_blame) = &git.inline_blame {
+                                    &inline_blame.min_column
+                                } else {
+                                    &None
+                                }
+                            } else {
+                                &None
+                            }
+                        },
+                        pick_mut: |settings_content| {
+                            &mut settings_content
+                                .git
+                                .get_or_insert_default()
+                                .inline_blame
+                                .get_or_insert_default()
+                                .min_column
+                        },
+                    }),
+                    metadata: None,
+                }),
                 SettingsPageItem::SettingItem(SettingItem {
                     title: "Show Commit Summary",
                     description: "Whether to show commit summary as part of the inline blame",
@@ -2722,6 +2837,24 @@ fn init_renderers(cx: &mut App) {
         })
         .add_renderer::<settings::ShowCloseButton>(|settings_field, file, _, window, cx| {
             render_dropdown(*settings_field, file, window, cx)
+        })
+        .add_renderer::<f32>(|settings_field, file, _, window, cx| {
+            render_numeric_stepper(*settings_field, file, window, cx)
+        })
+        .add_renderer::<u32>(|settings_field, file, _, window, cx| {
+            render_numeric_stepper(*settings_field, file, window, cx)
+        })
+        .add_renderer::<u64>(|settings_field, file, _, window, cx| {
+            render_numeric_stepper(*settings_field, file, window, cx)
+        })
+        .add_renderer::<NonZeroU32>(|settings_field, file, _, window, cx| {
+            render_numeric_stepper(*settings_field, file, window, cx)
+        })
+        .add_renderer::<CodeFade>(|settings_field, file, _, window, cx| {
+            render_numeric_stepper(*settings_field, file, window, cx)
+        })
+        .add_renderer::<FontWeight>(|settings_field, file, _, window, cx| {
+            render_numeric_stepper(*settings_field, file, window, cx)
         });
 
     // todo(settings_ui): Figure out how we want to handle discriminant unions
@@ -3571,6 +3704,27 @@ fn render_toggle_button<B: Into<bool> + From<bool> + Copy>(
         .into_any_element()
 }
 
+fn render_numeric_stepper<T: NumericStepperType + Send + Sync>(
+    field: SettingField<T>,
+    file: SettingsUiFile,
+    window: &mut Window,
+    cx: &mut App,
+) -> AnyElement {
+    let (_, &value) = SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
+
+    NumericStepper::new("numeric_stepper", value, window, cx)
+        .on_change({
+            move |value, _window, cx| {
+                let value = *value;
+                update_settings_file(file.clone(), cx, move |settings, _cx| {
+                    *(field.pick_mut)(settings) = Some(value);
+                })
+                .log_err(); // todo(settings_ui) don't log err
+            }
+        })
+        .into_any_element()
+}
+
 fn render_dropdown<T>(
     field: SettingField<T>,
     file: SettingsUiFile,

crates/theme/src/settings.rs 🔗

@@ -817,7 +817,7 @@ impl settings::Settings for ThemeSettings {
                 family: content.ui_font_family.as_ref().unwrap().0.clone().into(),
                 features: content.ui_font_features.clone().unwrap(),
                 fallbacks: font_fallbacks_from_settings(content.ui_font_fallbacks.clone()),
-                weight: clamp_font_weight(content.ui_font_weight.unwrap()),
+                weight: clamp_font_weight(content.ui_font_weight.unwrap().0),
                 style: Default::default(),
             },
             buffer_font: Font {
@@ -830,7 +830,7 @@ impl settings::Settings for ThemeSettings {
                     .into(),
                 features: content.buffer_font_features.clone().unwrap(),
                 fallbacks: font_fallbacks_from_settings(content.buffer_font_fallbacks.clone()),
-                weight: clamp_font_weight(content.buffer_font_weight.unwrap()),
+                weight: clamp_font_weight(content.buffer_font_weight.unwrap().0),
                 style: FontStyle::default(),
             },
             buffer_font_size: clamp_font_size(content.buffer_font_size.unwrap().into()),
@@ -850,15 +850,15 @@ impl settings::Settings for ThemeSettings {
                 .unwrap(),
             icon_theme_selection: Some(icon_theme_selection),
             ui_density: content.ui_density.unwrap_or_default().into(),
-            unnecessary_code_fade: content.unnecessary_code_fade.unwrap().clamp(0.0, 0.9),
+            unnecessary_code_fade: content.unnecessary_code_fade.unwrap().0.clamp(0.0, 0.9),
         };
         this.apply_theme_overrides();
         this
     }
 
     fn import_from_vscode(vscode: &settings::VsCodeSettings, current: &mut SettingsContent) {
-        vscode.f32_setting("editor.fontWeight", &mut current.theme.buffer_font_weight);
-        vscode.f32_setting("editor.fontSize", &mut current.theme.buffer_font_size);
+        vscode.from_f32_setting("editor.fontWeight", &mut current.theme.buffer_font_weight);
+        vscode.from_f32_setting("editor.fontSize", &mut current.theme.buffer_font_size);
         if let Some(font) = vscode.read_string("editor.font") {
             current.theme.buffer_font_family = Some(FontFamilyName(font.into()));
         }

crates/ui_input/src/numeric_stepper.rs 🔗

@@ -1,13 +1,14 @@
 use std::{
     fmt::Display,
-    ops::{Add, Sub},
+    num::{NonZeroU32, NonZeroU64},
     rc::Rc,
     str::FromStr,
 };
 
 use editor::{Editor, EditorStyle};
-use gpui::{ClickEvent, Entity, FocusHandle, Focusable, Modifiers};
+use gpui::{ClickEvent, Entity, FocusHandle, Focusable, FontWeight, Modifiers};
 
+use settings::CodeFade;
 use ui::{IconButtonShape, prelude::*};
 
 #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
@@ -25,15 +26,7 @@ pub enum NumericStepperMode {
 }
 
 pub trait NumericStepperType:
-    Display
-    + Add<Output = Self>
-    + Sub<Output = Self>
-    + Copy
-    + Clone
-    + Sized
-    + PartialOrd
-    + FromStr
-    + 'static
+    Display + Copy + Clone + Sized + PartialOrd + FromStr + 'static
 {
     fn default_format(value: &Self) -> String {
         format!("{}", value)
@@ -43,6 +36,56 @@ pub trait NumericStepperType:
     fn small_step() -> Self;
     fn min_value() -> Self;
     fn max_value() -> Self;
+    fn saturating_add(self, rhs: Self) -> Self;
+    fn saturating_sub(self, rhs: Self) -> Self;
+}
+
+impl NumericStepperType for gpui::FontWeight {
+    fn default_step() -> Self {
+        FontWeight(10.0)
+    }
+    fn large_step() -> Self {
+        FontWeight(50.0)
+    }
+    fn small_step() -> Self {
+        FontWeight(5.0)
+    }
+    fn min_value() -> Self {
+        gpui::FontWeight::THIN
+    }
+    fn max_value() -> Self {
+        gpui::FontWeight::BLACK
+    }
+    fn saturating_add(self, rhs: Self) -> Self {
+        FontWeight((self.0 + rhs.0).min(Self::max_value().0))
+    }
+    fn saturating_sub(self, rhs: Self) -> Self {
+        FontWeight((self.0 - rhs.0).max(Self::min_value().0))
+    }
+}
+
+impl NumericStepperType for settings::CodeFade {
+    fn default_step() -> Self {
+        CodeFade(0.10)
+    }
+    fn large_step() -> Self {
+        CodeFade(0.20)
+    }
+    fn small_step() -> Self {
+        CodeFade(0.05)
+    }
+    fn min_value() -> Self {
+        CodeFade(0.0)
+    }
+    fn max_value() -> Self {
+        CodeFade(0.9)
+    }
+    fn saturating_add(self, rhs: Self) -> Self {
+        CodeFade((self.0 + rhs.0).min(Self::max_value().0))
+    }
+    fn saturating_sub(self, rhs: Self) -> Self {
+        CodeFade((self.0 - rhs.0).max(Self::min_value().0))
+    }
 }
 
 macro_rules! impl_numeric_stepper_int {
@@ -67,6 +110,50 @@ macro_rules! impl_numeric_stepper_int {
             fn max_value() -> Self {
                 <$type>::MAX
             }
+
+            fn saturating_add(self, rhs: Self) -> Self {
+                self.saturating_add(rhs)
+            }
+
+            fn saturating_sub(self, rhs: Self) -> Self {
+                self.saturating_sub(rhs)
+            }
+        }
+    };
+}
+
+macro_rules! impl_numeric_stepper_nonzero_int {
+    ($nonzero:ty, $inner:ty) => {
+        impl NumericStepperType for $nonzero {
+            fn default_step() -> Self {
+                <$nonzero>::new(1).unwrap()
+            }
+
+            fn large_step() -> Self {
+                <$nonzero>::new(10).unwrap()
+            }
+
+            fn small_step() -> Self {
+                <$nonzero>::new(1).unwrap()
+            }
+
+            fn min_value() -> Self {
+                <$nonzero>::MIN
+            }
+
+            fn max_value() -> Self {
+                <$nonzero>::MAX
+            }
+
+            fn saturating_add(self, rhs: Self) -> Self {
+                let result = self.get().saturating_add(rhs.get());
+                <$nonzero>::new(result.max(1)).unwrap()
+            }
+
+            fn saturating_sub(self, rhs: Self) -> Self {
+                let result = self.get().saturating_sub(rhs.get()).max(1);
+                <$nonzero>::new(result).unwrap()
+            }
         }
     };
 }
@@ -75,10 +162,7 @@ macro_rules! impl_numeric_stepper_float {
     ($type:ident) => {
         impl NumericStepperType for $type {
             fn default_format(value: &Self) -> String {
-                format!("{:^4}", value)
-                    .trim_end_matches('0')
-                    .trim_end_matches('.')
-                    .to_string()
+                format!("{:.2}", value)
             }
 
             fn default_step() -> Self {
@@ -100,6 +184,14 @@ macro_rules! impl_numeric_stepper_float {
             fn max_value() -> Self {
                 <$type>::MAX
             }
+
+            fn saturating_add(self, rhs: Self) -> Self {
+                (self + rhs).clamp(Self::min_value(), Self::max_value())
+            }
+
+            fn saturating_sub(self, rhs: Self) -> Self {
+                (self - rhs).clamp(Self::min_value(), Self::max_value())
+            }
         }
     };
 }
@@ -113,6 +205,9 @@ impl_numeric_stepper_int!(u32);
 impl_numeric_stepper_int!(i64);
 impl_numeric_stepper_int!(u64);
 
+impl_numeric_stepper_nonzero_int!(NonZeroU32, u32);
+impl_numeric_stepper_nonzero_int!(NonZeroU64, u64);
+
 #[derive(RegisterComponent)]
 pub struct NumericStepper<T = usize> {
     id: ElementId,
@@ -281,7 +376,7 @@ impl<T: NumericStepperType> RenderOnce for NumericStepper<T> {
                             let min = self.min_value;
                             move |click: &ClickEvent, window: &mut Window, cx: &mut App| {
                                 let step = get_step(click.modifiers());
-                                let new_value = value - step;
+                                let new_value = value.saturating_sub(step);
                                 let new_value = if new_value < min { min } else { new_value };
                                 on_change(&new_value, window, cx);
                                 window.focus_prev();
@@ -410,7 +505,7 @@ impl<T: NumericStepperType> RenderOnce for NumericStepper<T> {
                             let max = self.max_value;
                             move |click: &ClickEvent, window: &mut Window, cx: &mut App| {
                                 let step = get_step(click.modifiers());
-                                let new_value = value + step;
+                                let new_value = value.saturating_add(step);
                                 let new_value = if new_value > max { max } else { new_value };
                                 on_change(&new_value, window, cx);
                             }