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

commit4a9764a70ea86dd798b78ead089e46daa30644ce
parent95ba909a9d
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-30 10:23
fix: measure a button's label in the font the frame draws it in

Button::label_width shaped the label with no family (the UI sans) on the
belief that paint's text_with(.., None, ..) was what reached the frame. It
is not: Adapted::paint_self drops that prim and re-emits the label in
widget_font (the configured button font, Berkeley Mono by default), and the
legacy render_widget path draws in widget_font too. Mono runs ~20% wider
than the sans, so every label was under-measured: intrinsic_size cut the
plate short and the glyphs, clamped to the 8px inset on the left, ran over
the plate's right rim — cce-mail's "Load Images" and "View: HTML" buttons,
and any other button sized from intrinsic_size.

Measure with Paint::widget_font — the same font string the walk attaches,
through the same shaped-buffer cache — so the measure and the draw cannot
disagree. A new test paints an intrinsic-sized button through the walk and
checks the label ends 8px short of the plate in the font the prim carries
(it failed by 10px against the old measure); the centring test now shapes
in that font as well.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

 src/widget/input/button.rs | 83 +++++++++++++++++++++++++++++++++++++---------
 1 file changed, 68 insertions(+), 15 deletions(-)

diff --git a/src/widget/input/button.rs b/src/widget/input/button.rs
index f5465b5..363b942 100644
--- a/src/widget/input/button.rs
+++ b/src/widget/input/button.rs
@@ -234,17 +234,24 @@ impl Button {
         (family, size.unwrap_or(12.0))
     }
 
-    /// The label's width AS `paint` DRAWS IT: shaped by cosmic-text with no
-    /// family (the DE's UI sans), which is what the `text_with(.., None, ..)`
-    /// below emits — the same shaped-buffer cache the draw reads.
+    /// The label's width AS THE FRAME DRAWS IT: shaped by cosmic-text in
+    /// [`Paint::widget_font`] — the configured button family (`list_font`
+    /// for a ListRow), which is the font the adapter attaches to this
+    /// widget's text when it re-emits `paint`'s label prim
+    /// (`Adapted::paint_self` → `own_labels_with_prim_font(text_font())`).
+    /// Same font string, same shaped-buffer cache the draw reads.
     ///
-    /// This used to be `measure_text_width` in the configured `button_font`
-    /// family: an SVG-inked extent in a face the label is never drawn in.
-    /// Under the default Berkeley Mono that measured every label wider than
-    /// its sans draw ("Attach..." 73px against 53px drawn), and since both
-    /// the centring and `intrinsic_size` read this, every label sat left of
-    /// centre with the surplus piled up on its right. The inked measure is
-    /// now only the fallback for a font system that shapes nothing.
+    /// Two wrong measures preceded this one. First `measure_text_width` in
+    /// the button family: an SVG-inked extent, not the shaped run. Then
+    /// shaping with no family (the UI sans), on the belief that the label
+    /// was drawn in it — the `text_with(.., None, ..)` in `paint` below
+    /// says so, but that `None` never reaches the frame: the paint walk
+    /// swaps in `widget_font`. Under the default Berkeley Mono every label
+    /// then drew ~20% wider than measured, so `intrinsic_size` cut the plate
+    /// short and the glyphs ran over its right rim ("Load Images",
+    /// "View: HTML" in cce-mail). Legacy hosts (`render_widget`) draw in
+    /// `widget_font` too, so this measure holds on both paths. The inked
+    /// measure is now only the fallback for a font system that shapes nothing.
     ///
     /// Blocking lock, as the context menu takes it: `try_lock` fell back to
     /// the wrong measure whenever another thread was shaping. The one site
@@ -255,11 +262,12 @@ impl Button {
             return 12.0;
         }
         let (family, size) = self.font();
+        let font = Paint::widget_font(self);
         crate::geometry_font_system()
             .lock()
             .ok()
             .and_then(|mut fs| {
-                crate::backend::window_runner::shaped_cluster_offsets(&mut fs, label, size, None)
+                crate::backend::window_runner::shaped_cluster_offsets(&mut fs, label, size, font.as_deref())
                     .last()
                     .map(|&(_, total)| total)
             })
@@ -714,15 +722,18 @@ mod tests {
     }
 
     /// Centred means centred on the glyphs as DRAWN: the gap either side of
-    /// the shaped label is equal. Measuring in the configured button family
-    /// while drawing in the UI sans left "Attach..." ~10px left of centre.
+    /// the shaped label is equal. Measuring in a face the label is not drawn
+    /// in (first the inked button family, then the UI sans) left labels off
+    /// centre and, worse, under-measured for `intrinsic_size`.
     #[test]
     fn a_label_is_centred_on_its_drawn_width() {
-        let (_, size) = Button::model(ButtonKind::Primary).font();
+        let b = Button::model(ButtonKind::Primary);
+        let (_, size) = b.font();
+        let font = Paint::widget_font(&b);
         for label in ["Attach...", "Load Images", "Cancel"] {
             let drawn = {
                 let mut fs = crate::geometry_font_system().lock().unwrap();
-                crate::backend::window_runner::shaped_cluster_offsets(&mut fs, label, size, None)
+                crate::backend::window_runner::shaped_cluster_offsets(&mut fs, label, size, font.as_deref())
                     .last()
                     .map(|&(_, t)| t)
                     .unwrap()
@@ -733,6 +744,48 @@ mod tests {
         }
     }
 
+    /// The measure and the frame agree on the font: the paint walk emits the
+    /// label prim in `widget_font` (the adapter swaps it in for `paint`'s
+    /// `None`), so `label_width` must shape in exactly that font, and a
+    /// button sized by `intrinsic_size` then holds its whole label with the
+    /// 8px inset each side that `paint` clamps to.
+    #[test]
+    fn intrinsic_size_holds_the_label_as_the_walk_draws_it() {
+        use crate::scene::paint::Prim;
+        let mut ctx = UiContext::new();
+        let mut b = Button::new(0.0, 0.0, 0.0, 0.0).with_label("Load Images");
+        let (id, ptr) = (b.id(), b.as_ptr_mut());
+        ctx.register_widget(id, ptr);
+        let size = b.intrinsic_size().unwrap();
+        WidgetHost::set_rect(&mut b, 10.0, 20.0, size.width, size.height);
+
+        let list = crate::scene::painter::paint_tree(&ctx, &b);
+        let text = list
+            .items
+            .iter()
+            .find_map(|it| match &it.prim {
+                Prim::Text { text, x, font_size, font, .. } => Some((text.clone(), *x, *font_size, font.clone())),
+                _ => None,
+            })
+            .expect("the walk emits the label");
+        assert_eq!(text.0, "Load Images");
+        assert_eq!(text.3, Paint::widget_font(&*b), "the walk draws the label in widget_font");
+
+        // Shape it as the renderer will (that font string, that size) and
+        // check it ends 8px short of the plate's right edge, as it starts
+        // 8px in from the left.
+        let drawn = {
+            let mut fs = crate::geometry_font_system().lock().unwrap();
+            crate::backend::window_runner::shaped_cluster_offsets(&mut fs, &text.0, text.2, text.3.as_deref())
+                .last()
+                .map(|&(_, t)| t)
+                .unwrap()
+        };
+        let right_gap = (10.0 + size.width) - (text.1 + drawn);
+        assert!((text.1 - 18.0).abs() < 0.5, "label starts at the 8px inset, got x={}", text.1);
+        assert!((right_gap - 8.0).abs() < 1.0, "label ends {right_gap:.1}px short of the plate, want 8");
+    }
+
     fn press(x: f32, y: f32) -> Event {
         Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, x, y, local_x: x, local_y: y }
     }