git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit37a9dd68c7ca27368d20e42a5814a9344155ebb1
parent8d27ae77c9
authorLucas Galante <[email protected]>
date2026-07-18 12:58
fix: size menu-button dropdowns to their display text, not the widest option

A menu-button dropdown (fixed custom_display_text, e.g. a "File" menu) has
a label independent of its options, so sizing the trigger to the widest
entry stretched it to fit things like a long recent-file path. It now sizes
to the display text; the open popover still expands to the widest option.

Sizing also measured with the raw ink width while the paint pass advances
on a monospace cell or the M-dummy trick, leaving the trigger a few px
narrow and tripping its own right-edge fade — badly under a monospace UI
font. Both passes now share text_advance() and a single LABEL_INSET, with
the monospace helpers extracted so they can't drift.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

 src/widget/input/dropdown.rs | 150 ++++++++++++++++++++++++++++++++++++-------
 1 file changed, 127 insertions(+), 23 deletions(-)

diff --git a/src/widget/input/dropdown.rs b/src/widget/input/dropdown.rs
index 89ab524..8ddc21c 100644
--- a/src/widget/input/dropdown.rs
+++ b/src/widget/input/dropdown.rs
@@ -34,6 +34,44 @@ pub struct ParentSnapshot {
     pub color: [f32; 4],
 }
 
+/// Monospace detection — the paint pass lays characters out on a fixed cell in a monospace font
+/// and on measured per-character advances otherwise, so the sizing pass must branch the same way.
+fn is_monospace_font(font_family: &str, font_size: f32) -> bool {
+    let w_i10 = crate::widget::display::measure_text_width("iiiiiiiiii", font_family, font_size);
+    let w_m10 = crate::widget::display::measure_text_width("mmmmmmmmmm", font_family, font_size);
+    (w_i10 - w_m10).abs() < 5.0
+}
+
+/// The fixed per-character cell of a monospace font, taken as the slope between a 10- and a
+/// 20-`m` run so any constant side bearing in the measurement cancels out.
+fn monospace_cell_width(font_family: &str, font_size: f32) -> f32 {
+    let w_m10 = crate::widget::display::measure_text_width("mmmmmmmmmm", font_family, font_size);
+    let w_m20 = crate::widget::display::measure_text_width("mmmmmmmmmmmmmmmmmmmm", font_family, font_size);
+    ((w_m20 - w_m10) / 10.0).max(1.0)
+}
+
+/// The advance width `paint_text` will actually lay `text` out to.
+///
+/// This is the single source of truth shared by the sizing pass (`content_width` /
+/// `display_width`) and the paint pass. It is deliberately NOT a plain `measure_text_width`: the
+/// paint pass advances on the monospace cell or on the M-dummy trick, and either can exceed the
+/// raw ink measure. Sizing a dropdown from the ink measure therefore left the trigger a few px too
+/// narrow and tripped its own right-edge fade — badly under a monospace UI font like the default
+/// Berkeley Mono. Keep this in step with `paint_text`.
+fn text_advance(text: &str, font_family: &str, font_size: f32) -> f32 {
+    let n = text.chars().count();
+    if n == 0 {
+        return 0.0;
+    }
+    if is_monospace_font(font_family, font_size) {
+        n as f32 * monospace_cell_width(font_family, font_size)
+    } else {
+        let w_dummy = crate::widget::display::measure_text_width("M", font_family, font_size);
+        let measure_str = format!("{}M", text);
+        (crate::widget::display::measure_text_width(&measure_str, font_family, font_size) - w_dummy).max(0.0)
+    }
+}
+
 /// Side-layout label inset — the legacy `WidgetHost::label_x_offset` default for non-exempt
 /// widgets (Dropdown was never in the exempt list).
 fn side_offset(label: &Option<String>) -> f32 {
@@ -103,13 +141,21 @@ impl Dropdown {
         changed
     }
 
+    /// Horizontal inset added to a measured label to get a dropdown width the label fits inside
+    /// without tripping `paint_text`'s right-edge fade: an 8px left pad plus the 28px right
+    /// reservation (`right_limit = w - 28`, room for the 10px gap and the ▼ arrow) = 36px, plus a
+    /// 2px cushion for the small gap between the ink-`measure_text_width` used here and the M-dummy
+    /// advance the paint pass measures with. Shared by `content_width` (widest option) and
+    /// `display_width` (collapsed display text) so the two can't drift.
+    const LABEL_INSET: f32 = 38.0;
+
     pub fn content_width(&self) -> f32 {
         let font_setting = crate::layout::control_label_font_detached();
         let (font_family, font_size_opt) = crate::layout::parse_font_string(&font_setting);
         let font_size = font_size_opt.unwrap_or(12.0);
         let mut max_w = 0.0f32;
         for opt in &self.options {
-            let opt_w = crate::widget::display::measure_text_width(opt, &font_family, font_size) + 32.0;
+            let opt_w = text_advance(opt, &font_family, font_size) + Self::LABEL_INSET;
             if opt_w > max_w {
                 max_w = opt_w;
             }
@@ -117,6 +163,26 @@ impl Dropdown {
         max_w
     }
 
+    /// The text shown on the collapsed trigger — the fixed `custom_display_text` (menu-button
+    /// mode) if set, otherwise the selected option. Mirrors the selection logic in `paint_text`.
+    fn display_text(&self) -> String {
+        if let Some(ref custom_text) = self.custom_display_text {
+            custom_text.clone()
+        } else {
+            self.options.get(self.selected).cloned().unwrap_or_default()
+        }
+    }
+
+    /// Trigger width sized to the collapsed display text rather than the widest option (via
+    /// `content_width`). Used by menu-button dropdowns whose label is fixed, so "File"/"Edit" don't
+    /// stretch to their longest menu entry. Shares `LABEL_INSET` so the label fits without fading.
+    fn display_width(&self) -> f32 {
+        let font_setting = crate::layout::control_label_font_detached();
+        let (font_family, font_size_opt) = crate::layout::parse_font_string(&font_setting);
+        let font_size = font_size_opt.unwrap_or(12.0);
+        text_advance(&self.display_text(), &font_family, font_size) + Self::LABEL_INSET
+    }
+
     /// The detached-label strip height above the content rect — a replica of
     /// `Widget::label_offset` over the synced label (zero in side layout or unlabeled).
     fn label_top(&self) -> f32 {
@@ -326,16 +392,10 @@ impl Dropdown {
         let chars: Vec<char> = selected_text.chars().collect();
         let n = chars.len();
 
-        let is_monospace = {
-            let w_i10 = crate::widget::display::measure_text_width("iiiiiiiiii", &font_family, font_size);
-            let w_m10 = crate::widget::display::measure_text_width("mmmmmmmmmm", &font_family, font_size);
-            (w_i10 - w_m10).abs() < 5.0
-        };
+        let is_monospace = is_monospace_font(&font_family, font_size);
 
         let cell_width = if is_monospace {
-            let w_m10 = crate::widget::display::measure_text_width("mmmmmmmmmm", &font_family, font_size);
-            let w_m20 = crate::widget::display::measure_text_width("mmmmmmmmmmmmmmmmmmmm", &font_family, font_size);
-            ((w_m20 - w_m10) / 10.0).max(1.0)
+            monospace_cell_width(&font_family, font_size)
         } else {
             0.0
         };
@@ -359,16 +419,7 @@ impl Dropdown {
             }
         }
 
-        let total_advance = if n > 0 {
-            if is_monospace {
-                n as f32 * cell_width
-            } else {
-                let measure_str = format!("{}M", selected_text);
-                (crate::widget::display::measure_text_width(&measure_str, &font_family, font_size) - w_dummy).max(0.0)
-            }
-        } else {
-            0.0
-        };
+        let total_advance = text_advance(&selected_text, &font_family, font_size);
 
         // Draw and fade every character individually
         let mut prev_char_end = 0.0;
@@ -543,11 +594,19 @@ impl Layout for Dropdown {
         4.0
     }
 
-    /// Content size for the scene layout engine (Phase 2b): wide enough for the widest option
-    /// (via `content_width`, which already includes the arrow/padding inset), at the configured
-    /// dropdown height — so the control doesn't resize as the selection changes.
+    /// Content size for the scene layout engine (Phase 2b). A normal dropdown is wide enough for
+    /// the widest option (via `content_width`, which already includes the arrow/padding inset), so
+    /// the control doesn't resize as the selection changes. A menu-button dropdown (fixed
+    /// `custom_display_text`, e.g. a "File" menu) instead sizes to its display text — its label is
+    /// fixed regardless of options, so fitting the widest entry would just stretch the trigger. The
+    /// open popover still expands to the widest option via `popover_width`'s `.max(content_width())`.
     fn intrinsic_size(&self) -> Option<Size> {
-        Some(Size::new(self.content_width(), crate::layout::dropdown_height()))
+        let width = if self.custom_display_text.is_some() {
+            self.display_width()
+        } else {
+            self.content_width()
+        };
+        Some(Size::new(width, crate::layout::dropdown_height()))
     }
 
     fn intrinsic_measure_width(&self) -> bool {
@@ -1003,6 +1062,51 @@ mod tests {
         );
     }
 
+    #[test]
+    fn menu_button_trigger_fits_display_text_not_widest_option() {
+        // A menu-button dropdown (fixed custom display text) sizes its trigger to that text, so a
+        // long menu entry (e.g. a recent-file path) no longer stretches the "File" button.
+        let menu = Dropdown::new(
+            vec![
+                "New".to_string(),
+                "/home/user/some/very/long/recent/project/path".to_string(),
+            ],
+            0,
+        )
+        .with_custom_display_text("File");
+
+        let trigger = Layout::intrinsic_size(menu.inner()).expect("dropdown reports intrinsic size");
+        assert!(
+            trigger.width < menu.inner().content_width(),
+            "menu-button trigger ({}) fits its display text, not the widest option ({})",
+            trigger.width,
+            menu.inner().content_width(),
+        );
+
+        // The open popover still expands to the widest option.
+        let content = Rect { x: 0.0, y: 0.0, width: trigger.width, height: trigger.height };
+        assert!(
+            menu.inner().popover_geom(content).2 >= menu.inner().content_width(),
+            "popover still fits the widest option",
+        );
+
+        // The trigger leaves the paint pass's full budget (8px left pad + 28px right/arrow
+        // reservation) for the label, so the display text renders without tripping the right-edge
+        // fade. This is the paint condition `start_x + total_advance > right_limit` restated:
+        // `content.x + 8 + advance > content.x + width - 28`, i.e. it must hold that
+        // `width >= advance + 36`. Measured via `text_advance` — the same function the paint pass
+        // lays out with — so the guarantee holds in a monospace UI font too.
+        let (font_family, font_size) = crate::layout::control_label_font_detached_parsed();
+        let advance = text_advance("File", &font_family, font_size);
+        assert!(
+            trigger.width >= advance + 36.0,
+            "trigger width ({}) leaves room for the laid-out label (advance {} + 36px budget), \
+             so it doesn't fade",
+            trigger.width,
+            advance,
+        );
+    }
+
     /// Migration additions: popover routing through the adapter (`WidgetHost::popover_rect` /
     /// `render_popover`), outside-press close, and Escape via routed key events.
     #[test]