settings_ui: Make font size settings use editable number fields (#45875)

Danilo Leal created

Now that the edit mode in number fields is finally working well, we can
make the UX of editing font sizes much nicer because you can now type
inside the number field :)


https://github.com/user-attachments/assets/8df7c6ee-e82b-4e10-a175-e0ca5f1bab1f

Release Notes:

- settings UI: Improved the UX of editing font size fields as you can
now type the desired value as opposed to just using the
decrement/increment buttons.

Change summary

crates/settings/src/settings_content/terminal.rs |  5 -
crates/settings/src/settings_content/theme.rs    | 54 +++++++++++++++---
crates/settings/src/vscode_import.rs             |  6 +
crates/settings_ui/src/settings_ui.rs            | 42 +++++++++++++
crates/terminal/src/terminal_settings.rs         |  2 
crates/ui_input/src/number_field.rs              |  5 +
6 files changed, 96 insertions(+), 18 deletions(-)

Detailed changes

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

@@ -6,7 +6,7 @@ use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use settings_macros::{MergeFrom, with_fallible_options};
 
-use crate::FontFamilyName;
+use crate::{FontFamilyName, FontSize};
 
 #[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom)]
 pub struct ProjectTerminalSettingsContent {
@@ -75,8 +75,7 @@ pub struct TerminalSettingsContent {
     ///
     /// If this option is not included,
     /// the terminal will default to matching the buffer's font size.
-    #[serde(serialize_with = "crate::serialize_optional_f32_with_two_decimal_places")]
-    pub font_size: Option<f32>,
+    pub font_size: Option<FontSize>,
     /// Sets the terminal's font family.
     ///
     /// If this option is not included,

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

@@ -1,5 +1,5 @@
 use collections::{HashMap, IndexMap};
-use gpui::{FontFallbacks, FontFeatures, FontStyle, FontWeight, SharedString};
+use gpui::{FontFallbacks, FontFeatures, FontStyle, FontWeight, Pixels, SharedString};
 use schemars::{JsonSchema, JsonSchema_repr};
 use serde::{Deserialize, Deserializer, Serialize};
 use serde_json::Value;
@@ -15,8 +15,7 @@ use crate::serialize_f32_with_two_decimal_places;
 #[derive(Clone, PartialEq, Debug, Default, Serialize, Deserialize, JsonSchema, MergeFrom)]
 pub struct ThemeSettingsContent {
     /// The default font size for text in the UI.
-    #[serde(serialize_with = "crate::serialize_optional_f32_with_two_decimal_places")]
-    pub ui_font_size: Option<f32>,
+    pub ui_font_size: Option<FontSize>,
     /// The name of a font to use for rendering in the UI.
     pub ui_font_family: Option<FontFamilyName>,
     /// The font fallbacks to use for rendering in the UI.
@@ -35,8 +34,7 @@ pub struct ThemeSettingsContent {
     #[schemars(extend("uniqueItems" = true))]
     pub buffer_font_fallbacks: Option<Vec<FontFamilyName>>,
     /// The default font size for rendering in text buffers.
-    #[serde(serialize_with = "crate::serialize_optional_f32_with_two_decimal_places")]
-    pub buffer_font_size: Option<f32>,
+    pub buffer_font_size: Option<FontSize>,
     /// The weight of the editor font in CSS units from 100 to 900.
     #[schemars(default = "default_buffer_font_weight")]
     pub buffer_font_weight: Option<FontWeight>,
@@ -46,11 +44,9 @@ pub struct ThemeSettingsContent {
     #[schemars(default = "default_font_features")]
     pub buffer_font_features: Option<FontFeatures>,
     /// The font size for agent responses in the agent panel. Falls back to the UI font size if unset.
-    #[serde(serialize_with = "crate::serialize_optional_f32_with_two_decimal_places")]
-    pub agent_ui_font_size: Option<f32>,
+    pub agent_ui_font_size: Option<FontSize>,
     /// The font size for user messages in the agent panel.
-    #[serde(serialize_with = "crate::serialize_optional_f32_with_two_decimal_places")]
-    pub agent_buffer_font_size: Option<f32>,
+    pub agent_buffer_font_size: Option<FontSize>,
     /// The name of the Zed theme to use.
     pub theme: Option<ThemeSelection>,
     /// The name of the icon theme to use.
@@ -79,6 +75,46 @@ pub struct ThemeSettingsContent {
     pub theme_overrides: HashMap<String, ThemeStyleContent>,
 }
 
+/// A font size value in pixels, wrapping around `f32` for custom settings UI rendering.
+#[derive(
+    Clone,
+    Copy,
+    Debug,
+    Serialize,
+    Deserialize,
+    JsonSchema,
+    MergeFrom,
+    PartialEq,
+    PartialOrd,
+    derive_more::FromStr,
+)]
+#[serde(transparent)]
+pub struct FontSize(#[serde(serialize_with = "serialize_f32_with_two_decimal_places")] pub f32);
+
+impl Display for FontSize {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{:.2}", self.0)
+    }
+}
+
+impl From<f32> for FontSize {
+    fn from(value: f32) -> Self {
+        Self(value)
+    }
+}
+
+impl From<FontSize> for Pixels {
+    fn from(value: FontSize) -> Self {
+        value.0.into()
+    }
+}
+
+impl From<Pixels> for FontSize {
+    fn from(value: Pixels) -> Self {
+        Self(value.into())
+    }
+}
+
 #[derive(
     Clone,
     Copy,

crates/settings/src/vscode_import.rs 🔗

@@ -736,7 +736,9 @@ impl VsCodeSettings {
             font_fallbacks,
             font_family,
             font_features: None,
-            font_size: self.read_f32("terminal.integrated.fontSize"),
+            font_size: self
+                .read_f32("terminal.integrated.fontSize")
+                .map(FontSize::from),
             font_weight: None,
             keep_selection_on_copy: None,
             line_height: self
@@ -795,7 +797,7 @@ impl VsCodeSettings {
             ui_font_weight: None,
             buffer_font_family,
             buffer_font_fallbacks,
-            buffer_font_size: self.read_f32("editor.fontSize"),
+            buffer_font_size: self.read_f32("editor.fontSize").map(FontSize::from),
             buffer_font_weight: self.read_f32("editor.fontWeight").map(|w| w.into()),
             buffer_line_height: None,
             buffer_font_features: None,

crates/settings_ui/src/settings_ui.rs 🔗

@@ -31,7 +31,7 @@ use ui::{
     Banner, ContextMenu, Divider, DropdownMenu, DropdownStyle, IconButtonShape, KeyBinding,
     KeybindingHint, PopoverMenu, Switch, Tooltip, TreeViewItem, WithScrollbar, prelude::*,
 };
-use ui_input::{NumberField, NumberFieldType};
+use ui_input::{NumberField, NumberFieldMode, NumberFieldType};
 use util::{ResultExt as _, paths::PathStyle, rel_path::RelPath};
 use workspace::{AppState, OpenOptions, OpenVisible, Workspace, client_side_decorations};
 use zed_actions::{OpenProjectSettings, OpenSettings, OpenSettingsAt};
@@ -513,6 +513,7 @@ fn init_renderers(cx: &mut App) {
         .add_basic_renderer::<settings::EditPredictionsMode>(render_dropdown)
         .add_basic_renderer::<settings::RelativeLineNumbers>(render_dropdown)
         .add_basic_renderer::<settings::WindowDecorations>(render_dropdown)
+        .add_basic_renderer::<settings::FontSize>(render_editable_number_field)
         // please semicolon stay on next line
         ;
 }
@@ -3667,7 +3668,44 @@ fn render_number_field<T: NumberFieldType + Send + Sync>(
 ) -> AnyElement {
     let (_, value) = SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
     let value = value.copied().unwrap_or_else(T::min_value);
-    NumberField::new("numeric_stepper", value, window, cx)
+
+    let id = field
+        .json_path
+        .map(|p| format!("numeric_stepper_{}", p))
+        .unwrap_or_else(|| "numeric_stepper".to_string());
+
+    NumberField::new(id, value, window, cx)
+        .tab_index(0_isize)
+        .on_change({
+            move |value, _window, cx| {
+                let value = *value;
+                update_settings_file(file.clone(), field.json_path, cx, move |settings, _cx| {
+                    (field.write)(settings, Some(value));
+                })
+                .log_err(); // todo(settings_ui) don't log err
+            }
+        })
+        .into_any_element()
+}
+
+fn render_editable_number_field<T: NumberFieldType + Send + Sync>(
+    field: SettingField<T>,
+    file: SettingsUiFile,
+    _metadata: Option<&SettingsFieldMetadata>,
+    window: &mut Window,
+    cx: &mut App,
+) -> AnyElement {
+    let (_, value) = SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
+    let value = value.copied().unwrap_or_else(T::min_value);
+
+    let id = field
+        .json_path
+        .map(|p| format!("numeric_stepper_{}", p))
+        .unwrap_or_else(|| "numeric_stepper".to_string());
+
+    NumberField::new(id, value, window, cx)
+        .mode(NumberFieldMode::Edit, cx)
+        .tab_index(0_isize)
         .on_change({
             move |value, _window, cx| {
                 let value = *value;

crates/terminal/src/terminal_settings.rs 🔗

@@ -84,7 +84,7 @@ impl settings::Settings for TerminalSettings {
         TerminalSettings {
             shell: settings_shell_to_task_shell(project_content.shell.unwrap()),
             working_directory: project_content.working_directory.unwrap(),
-            font_size: user_content.font_size.map(px),
+            font_size: user_content.font_size.map(Into::into),
             font_family: user_content.font_family,
             font_fallbacks: user_content.font_fallbacks.map(|fallbacks| {
                 FontFallbacks::from_fonts(

crates/ui_input/src/number_field.rs 🔗

@@ -11,7 +11,9 @@ use gpui::{
     TextStyleRefinement, WeakEntity,
 };
 
-use settings::{CenteredPaddingSettings, CodeFade, DelayMs, InactiveOpacity, MinimumContrast};
+use settings::{
+    CenteredPaddingSettings, CodeFade, DelayMs, FontSize, InactiveOpacity, MinimumContrast,
+};
 use ui::prelude::*;
 
 #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
@@ -105,6 +107,7 @@ macro_rules! impl_newtype_numeric_stepper_int {
 #[rustfmt::skip]
 impl_newtype_numeric_stepper_float!(FontWeight, 50., 100., 10., FontWeight::THIN, FontWeight::BLACK);
 impl_newtype_numeric_stepper_float!(CodeFade, 0.1, 0.2, 0.05, 0.0, 0.9);
+impl_newtype_numeric_stepper_float!(FontSize, 1.0, 4.0, 0.5, 6.0, 72.0);
 impl_newtype_numeric_stepper_float!(InactiveOpacity, 0.1, 0.2, 0.05, 0.0, 1.0);
 impl_newtype_numeric_stepper_float!(MinimumContrast, 1., 10., 0.5, 0.0, 106.0);
 impl_newtype_numeric_stepper_int!(DelayMs, 100, 500, 10, 0, 2000);