git.lucas.co / cce-cloud
cloud storage client
git clone https://git.lucas.co/cce-cloud.git

commitce91b3a06c844b66cd81525dd7d94cb081702d8a
parent80cf88ecda
authorLucas Galante <[email protected]>
date2026-07-17 22:31
fix: size json-popup buttons for their label inset so left labels don't overflow

Button draws its left-justified label 8px in from its own edge in the button
font, but the popup width estimate used measure_text (menubar-font ink extent)
+ 32, leaving usable_w == the raw text width. The label then spilled past the
button's right edge. Measure buttons the way the button measures itself
(measure_text_width in the button font/size) and budget the 8px-per-side inset.
Factored the three duplicated width heuristics into json_widget_desired_width so
they can't drift.

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

 src/main.rs | 70 ++++++++++++++++++++++++++++++++++++++++++-------------------
 1 file changed, 49 insertions(+), 21 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index ec6d907..76df737 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -730,6 +730,28 @@ struct State {
     select_and_close_requested: bool,
 }
 
+/// Logical-px width one JSON-layout widget wants for the auto-sizing popup.
+///
+/// Buttons are the subtlety: `cce_ui::widget::Button` draws its label with an 8px inset on
+/// each side (see `Button::paint`) in the *button* font — not the menubar font `measure_text`
+/// assumes. So measure the label the way the button itself does (`measure_text_width` in the
+/// button font/size) and budget the button's 16px of inset on top of the container's 16px-per-
+/// side margins; otherwise a left-justified label starts 8px in and spills past the button's
+/// right edge (`JsonLayoutWidget::layout_children` sets `usable_w = width - 32`).
+fn json_widget_desired_width(widget_type: &str, text: &str) -> f32 {
+    match widget_type {
+        "button" => {
+            let (family, size) = cce_ui::layout::parse_font_string(&cce_ui::layout::button_font());
+            cce_ui::widget::display::measure_text_width(text, &family, size.unwrap_or(12.0)) + 48.0
+        }
+        "label" => cce_ui::widget::display::measure_text(text, 13.0) + 32.0,
+        "checkbox" => cce_ui::widget::display::measure_text(text, 13.0) + 44.0,
+        "spinbox" | "color" => cce_ui::widget::display::measure_text(text, 13.0) + 120.0,
+        "slider" => cce_ui::widget::display::measure_text(text, 13.0) + 160.0,
+        _ => 150.0,
+    }
+}
+
 impl State {
     fn new(
         conn: &Connection,
@@ -760,13 +782,7 @@ impl State {
                     let mut max_widget_w = 120.0f32; // fallback minimum
                     if let Some(ref widgets) = config.widgets {
                         for w_conf in widgets {
-                            let w_w = match w_conf.widget_type.as_str() {
-                                "button" | "label" => cce_ui::widget::display::measure_text(&w_conf.text, 13.0) + 32.0,
-                                "checkbox" => cce_ui::widget::display::measure_text(&w_conf.text, 13.0) + 44.0,
-                                "spinbox" | "color" => cce_ui::widget::display::measure_text(&w_conf.text, 13.0) + 120.0,
-                                "slider" => cce_ui::widget::display::measure_text(&w_conf.text, 13.0) + 160.0,
-                                _ => 150.0,
-                            };
+                            let w_w = json_widget_desired_width(&w_conf.widget_type, &w_conf.text);
                             if w_w > max_widget_w {
                                 max_widget_w = w_w;
                             }
@@ -774,13 +790,7 @@ impl State {
                     } else if let Some(ref pages) = config.pages {
                         for page in pages {
                             for w_conf in &page.widgets {
-                                let w_w = match w_conf.widget_type.as_str() {
-                                    "button" | "label" => cce_ui::widget::display::measure_text(&w_conf.text, 13.0) + 32.0,
-                                    "checkbox" => cce_ui::widget::display::measure_text(&w_conf.text, 13.0) + 44.0,
-                                    "spinbox" | "color" => cce_ui::widget::display::measure_text(&w_conf.text, 13.0) + 120.0,
-                                    "slider" => cce_ui::widget::display::measure_text(&w_conf.text, 13.0) + 160.0,
-                                    _ => 150.0,
-                                };
+                                let w_w = json_widget_desired_width(&w_conf.widget_type, &w_conf.text);
                                 if w_w > max_widget_w {
                                     max_widget_w = w_w;
                                 }
@@ -1080,13 +1090,7 @@ impl State {
                     if w.page_idx != active_page {
                         continue;
                     }
-                    let w_w = match w.widget_type.as_str() {
-                        "button" | "label" => cce_ui::widget::display::measure_text(&w.text, 13.0) + 32.0,
-                        "checkbox" => cce_ui::widget::display::measure_text(&w.text, 13.0) + 44.0,
-                        "spinbox" | "color" => cce_ui::widget::display::measure_text(&w.text, 13.0) + 120.0,
-                        "slider" => cce_ui::widget::display::measure_text(&w.text, 13.0) + 160.0,
-                        _ => 150.0,
-                    };
+                    let w_w = json_widget_desired_width(&w.widget_type, &w.text);
                     if w_w > max_widget_w {
                         max_widget_w = w_w;
                     }
@@ -2948,6 +2952,30 @@ fn main() {
 mod tests {
     use super::*;
 
+    #[test]
+    fn button_width_covers_label_inset() {
+        // Regression: a left-justified Button draws its label 8px in from its own left edge
+        // (Button::paint) in the button font. layout_children gives the button
+        // `usable_w = popup_width - 32`. If the popup width doesn't budget the button's
+        // 8px-per-side inset, the label spills past the button's right edge — the desktop
+        // context-menu bug. Every desktop-menu label must fit within usable_w with the inset.
+        let (family, size) = cce_ui::layout::parse_font_string(&cce_ui::layout::button_font());
+        let size = size.unwrap_or(12.0);
+        for label in [
+            "Terminal", "Files", "Data Editor", "Applications",
+            "System Settings", "Expose Windows", "Reload Config", "Logout",
+        ] {
+            let popup_w = json_widget_desired_width("button", label);
+            let usable_w = popup_w - 32.0; // container margins, per layout_children
+            let label_w = cce_ui::widget::display::measure_text_width(label, &family, size);
+            // 8px left inset + label + 8px right breathing room must fit the button.
+            assert!(
+                usable_w >= label_w + 16.0,
+                "button '{label}': usable_w {usable_w} < label {label_w} + 16 inset",
+            );
+        }
+    }
+
     #[test]
     fn test_json_layout_parsing() {
         let json_str = r#"{