Use ch-width (`0`) instead of em-width (`m`) for gutter width calculation (#32548)

Max Mynter created

Closes #21860

Release Notes:

- Added `ch_width` and `ch_advance` function alongside their `em_*`
counterparts
- Use `ch_*` version to calculate gutter layouts
- Update a stale comment from changes in #31959

The ch units refer to the width of the number `0` whereas em is the
width of `m` and the actual font size (e.g. 16px means 16 px width of
`m`).

This change has no effect for monospaced fonts but can be drastic for
proportional ones as seen below for "Zed Plex Sans" with a
`"min_line_number_width" = 4`.

<img width="726" alt="Screenshot 2025-06-11 at 15 47 35"
src="https://github.com/user-attachments/assets/aa73f4d4-32bc-42cf-a9f6-7e25fee68c9a"
/>

Change summary

crates/editor/src/editor.rs    | 25 +++++++++++++------------
crates/gpui/src/text_system.rs | 14 ++++++++++++++
2 files changed, 27 insertions(+), 12 deletions(-)

Detailed changes

crates/editor/src/editor.rs 🔗

@@ -21565,8 +21565,8 @@ impl EditorSnapshot {
             return None;
         }
 
-        let em_width = cx.text_system().em_width(font_id, font_size).log_err()?;
-        let em_advance = cx.text_system().em_advance(font_id, font_size).log_err()?;
+        let ch_width = cx.text_system().ch_width(font_id, font_size).log_err()?;
+        let ch_advance = cx.text_system().ch_advance(font_id, font_size).log_err()?;
 
         let show_git_gutter = self.show_git_diff_gutter.unwrap_or_else(|| {
             matches!(
@@ -21579,9 +21579,10 @@ impl EditorSnapshot {
             .show_line_numbers
             .unwrap_or(gutter_settings.line_numbers);
         let line_gutter_width = if show_line_numbers {
-            // Avoid flicker-like gutter resizes when the line number gains another digit and only resize the gutter on files with N*10^5 lines.
+            // Avoid flicker-like gutter resizes when the line number gains another digit by
+            // only resizing the gutter on files with > 10**min_line_number_digits lines.
             let min_width_for_number_on_gutter =
-                em_advance * gutter_settings.min_line_number_digits as f32;
+                ch_advance * gutter_settings.min_line_number_digits as f32;
             max_line_number_width.max(min_width_for_number_on_gutter)
         } else {
             0.0.into()
@@ -21604,20 +21605,20 @@ impl EditorSnapshot {
                         + MAX_RELATIVE_TIMESTAMP.len()
                         + SPACING_WIDTH;
 
-                    em_advance * max_char_count
+                    ch_advance * max_char_count
                 });
 
         let is_singleton = self.buffer_snapshot.is_singleton();
 
         let mut left_padding = git_blame_entries_width.unwrap_or(Pixels::ZERO);
         left_padding += if !is_singleton {
-            em_width * 4.0
+            ch_width * 4.0
         } else if show_runnables || show_breakpoints {
-            em_width * 3.0
+            ch_width * 3.0
         } else if show_git_gutter && show_line_numbers {
-            em_width * 2.0
+            ch_width * 2.0
         } else if show_git_gutter || show_line_numbers {
-            em_width
+            ch_width
         } else {
             px(0.)
         };
@@ -21625,11 +21626,11 @@ impl EditorSnapshot {
         let shows_folds = is_singleton && gutter_settings.folds;
 
         let right_padding = if shows_folds && show_line_numbers {
-            em_width * 4.0
+            ch_width * 4.0
         } else if shows_folds || (!is_singleton && show_line_numbers) {
-            em_width * 3.0
+            ch_width * 3.0
         } else if show_line_numbers {
-            em_width
+            ch_width
         } else {
             px(0.)
         };

crates/gpui/src/text_system.rs 🔗

@@ -209,6 +209,20 @@ impl TextSystem {
         Ok(self.advance(font_id, font_size, 'm')?.width)
     }
 
+    /// Returns the width of an `ch`.
+    ///
+    /// Uses the width of the `0` character in the given font and size.
+    pub fn ch_width(&self, font_id: FontId, font_size: Pixels) -> Result<Pixels> {
+        Ok(self.typographic_bounds(font_id, font_size, '0')?.size.width)
+    }
+
+    /// Returns the advance width of an `ch`.
+    ///
+    /// Uses the advance width of the `0` character in the given font and size.
+    pub fn ch_advance(&self, font_id: FontId, font_size: Pixels) -> Result<Pixels> {
+        Ok(self.advance(font_id, font_size, '0')?.width)
+    }
+
     /// Get the number of font size units per 'em square',
     /// Per MDN: "an abstract square whose height is the intended distance between
     /// lines of type in the same type size"