git.lucas.co / cce-status-interface
status bar
git clone https://git.lucas.co/cce-status-interface.git

commit276239a0c04644b706f9418d3a919d4eabdfacab
parenta81a7ff9cc
authorLucas Galante <[email protected]>
date2026-08-29 22:50
feat: bubbles hug their live content, centered in the stable slot

width() keeps sizing the slot and the surface — the templates and the
title buckets exist so the compositor never sees a resize, and that
stays. The new content_width() (default: width()) measures the live
text, and the drawn bubble takes that width, centered in the slot, so
the padding on each side of the text is module { padding } on both
sides instead of padding on the left and padding-plus-template-surplus
on the right.

The drawn width is eased over ~120ms in tick (bubble_w_now), so a
flapping window title breathes instead of snapping the bubble edge on
every change. The in-surface menu expansion now grows out of
collapsed_box — the bubble actually drawn, which may sit inset in its
slot — interpolating x and width to the menu plate, so the
box-grows-into-the-menu continuity survives; contraction reverses to
the same collapsed box.

Co-Authored-By: Claude Fable 5 <[email protected]>

 CLAUDE.md      |  12 +++
 src/main.rs    | 108 +++++++++++++++++++++++----
 src/modules.rs | 230 ++++++++++++++++++++++++++++++++++++++++++++++-----------
 3 files changed, 290 insertions(+), 60 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 9ce648e..52cdf25 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -68,6 +68,18 @@ The app implements `cce_ui::engine::Application` on the **`display_list()` paint
    `rebuild_layout()` when size/scale changed or `needs_rebuild` is set).
    `overlay_quads()` remains a separate on-top pass (used for drag feedback).
 
+**Module boxes hug their content.** `StatusModule::width()` is the STABLE slot
+width — widest-plausible templates for the stat modules, 24px title buckets for
+the window module — and it alone sizes the surface, which is what keeps the
+compositor's configure-echo jitter out of the loop; `content_width()` (default:
+`width()`) measures the live text, and the drawn bubble takes that width,
+centered in the slot, so the padding on each side of the text is
+`module { padding }` rather than padding-plus-template-surplus. The drawn width
+is eased over ~120ms in `tick` (`bubble_w_now`/`bubble_w_target` — one pair of
+fields, sound because a `StatusApp` hosts exactly one module), and the
+in-surface menu expansion grows out of `collapsed_box` — the bubble actually
+drawn — not out of the slot, so the box-grows-into-the-menu continuity holds.
+
 Orientation is dynamic: `is_vertical()` compares the surface size against the
 configured bar thickness; every module renders along one axis using `bar_h`/`coord`
 accordingly.
diff --git a/src/main.rs b/src/main.rs
index c94c293..3ad3352 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -459,6 +459,21 @@ struct StatusApp {
     /// True while the menu is animating shut; `context_menu` is dropped
     /// only when the contraction lands back at the strip.
     menu_closing: bool,
+    /// The drawn bubble's width, eased in `tick` toward `bubble_w_target`
+    /// (the module's live `content_width`). The slot and the surface hold the
+    /// stable `width()` — templates and title quantization keep the
+    /// compositor from ever seeing a resize — while the bubble inside hugs
+    /// the content, centered on the difference, so side padding stays the
+    /// configured padding. Easing is what keeps a flapping window title from
+    /// snapping the bubble edge on every change. 0 = not yet measured (the
+    /// first rebuild snaps straight to the target). Single-module by
+    /// construction: a `StatusApp` always hosts exactly one module, so one
+    /// pair of fields covers "the" bubble.
+    bubble_w_now: f32,
+    bubble_w_target: f32,
+    /// The collapsed bubble actually drawn this rebuild (x, w), slot coords —
+    /// what the in-surface menu expansion grows out of and contracts back to.
+    collapsed_box: Option<(f32, f32)>,
     input_regions: Vec<(i32, i32, i32, i32)>,
     module_bounds: Vec<ModuleBounds>,
     left_modules: Vec<Box<dyn StatusModule>>,
@@ -561,6 +576,7 @@ impl StatusApp {
         self.input_regions.clear();
         self.module_bounds.clear();
         self.tray_item_bounds.clear();
+        self.collapsed_box = None;
 
         let left_modules = std::mem::take(&mut self.left_modules);
         let right_modules = std::mem::take(&mut self.right_modules);
@@ -604,6 +620,23 @@ impl StatusApp {
                 }
                 is_first_left = false;
 
+                // The bubble hugs the LIVE content, centered in the stable
+                // slot: `width()` keeps the surface from resizing (the
+                // configure-echo jitter), while the drawn box shrinks so the
+                // padding on each side of the text is the configured padding
+                // rather than padding-plus-template-surplus. The drawn width
+                // is `bubble_w_now`, eased toward the live measure in `tick`.
+                let cw = module
+                    .content_width(&self.stats, &self.title, &mut self.font_system, &font_family, font_size, &self.tray_items, padding)
+                    .min(w);
+                self.bubble_w_target = cw;
+                if self.bubble_w_now <= 0.0 {
+                    self.bubble_w_now = cw;
+                }
+                let bw = self.bubble_w_now.min(w);
+                let bx = left_x + (w - bw) / 2.0;
+                self.collapsed_box = Some((bx, bw));
+
                 // With the in-surface menu open the module box is replaced by
                 // the unified expanded box drawn in the menu branch below —
                 // the module box GROWS into the menu, it doesn't sit atop it.
@@ -614,12 +647,12 @@ impl StatusApp {
                             // belly silhouette's AA feather, plus the
                             // contact shadow's reserved gap.
                             let inset = 1.0 + spec.shadow_gap(bar_h);
-                            self.droplet_boxes.push((left_x, 0.0, w, bar_h - inset, color, spec));
+                            self.droplet_boxes.push((bx, 0.0, bw, bar_h - inset, color, spec));
                         } else {
                             self.rounded_boxes.push(RoundedBox {
-                                x: left_x,
+                                x: bx,
                                 y: 0.0,
-                                w,
+                                w: bw,
                                 h: bar_h,
                                 radius: status_box_radius,
                                 color,
@@ -631,8 +664,8 @@ impl StatusApp {
                 }
 
                 module.render(
-                    left_x,
-                    w,
+                    bx,
+                    bw,
                     &self.stats,
                     &self.title,
                     &mut self.font_system,
@@ -691,6 +724,19 @@ impl StatusApp {
                 });
                 self.input_regions.push((right_x.round() as i32, 0, w.round() as i32, bar_h.round() as i32));
 
+                // See the left loop: the bubble hugs the eased live content
+                // width, centered in the stable slot.
+                let cw = module
+                    .content_width(&self.stats, &self.title, &mut self.font_system, &font_family, font_size, &self.tray_items, padding)
+                    .min(w);
+                self.bubble_w_target = cw;
+                if self.bubble_w_now <= 0.0 {
+                    self.bubble_w_now = cw;
+                }
+                let bw = self.bubble_w_now.min(w);
+                let bx = right_x + (w - bw) / 2.0;
+                self.collapsed_box = Some((bx, bw));
+
                 // See the left loop: an open in-surface menu swaps the module
                 // box for the unified expanded box.
                 if !module.has_custom_background(&self.title) && self.context_menu.is_none() {
@@ -698,12 +744,12 @@ impl StatusApp {
                         if let Some(spec) = self.droplet {
                             // Same bottom inset as the left loop.
                             let inset = 1.0 + spec.shadow_gap(bar_h);
-                            self.droplet_boxes.push((right_x, 0.0, w, bar_h - inset, color, spec));
+                            self.droplet_boxes.push((bx, 0.0, bw, bar_h - inset, color, spec));
                         } else {
                             self.rounded_boxes.push(RoundedBox {
-                                x: right_x,
+                                x: bx,
                                 y: 0.0,
-                                w,
+                                w: bw,
                                 h: bar_h,
                                 radius: status_box_radius,
                                 color,
@@ -715,8 +761,8 @@ impl StatusApp {
                 }
 
                 module.render(
-                    right_x,
-                    w,
+                    bx,
+                    bw,
                     &self.stats,
                     &self.title,
                     &mut self.font_system,
@@ -889,10 +935,18 @@ impl StatusApp {
                         let a = self.menu_anim.clamp(0.0, 1.0);
                         1.0 - (1.0 - a) * (1.0 - a) * (1.0 - a)
                     };
-                    let anim_w = module_box_w + (menu_w - module_box_w) * t;
+                    // The expansion grows out of the bubble actually drawn on
+                    // the strip — which may sit inset in its slot, hugging
+                    // the live content — not out of the slot itself, so the
+                    // "module box grows into the menu" continuity holds with
+                    // content-hugging bubbles. Contraction reverses back to
+                    // the same collapsed box.
+                    let (start_x, start_w) = self.collapsed_box.unwrap_or((plate_x, module_box_w));
+                    let anim_w = start_w + (menu_w - start_w) * t;
+                    let anim_x = start_x + (plate_x - start_x) * t;
                     let reveal_h = menu_h * t;
-                    menu.rect = (plate_x, bar_h, anim_w, reveal_h);
-                    self.width = self.width.max((anim_w + 2.0 * plate_x).round() as u32);
+                    menu.rect = (anim_x, bar_h, anim_w, reveal_h);
+                    self.width = self.width.max((anim_x + anim_w + plate_x).round() as u32);
                     self.height = (bar_h + reveal_h).round() as u32;
 
                     // ONE continuous box in the module's own fill, spanning
@@ -912,10 +966,10 @@ impl StatusApp {
                         let collapsed_h = bar_h - inset;
                         let box_h = bar_h + reveal_h - inset;
                         let menu_spec = spec_at_reference_height(spec, collapsed_h, box_h);
-                        self.droplet_boxes.push((plate_x, 0.0, anim_w, box_h, menu_color, menu_spec));
+                        self.droplet_boxes.push((anim_x, 0.0, anim_w, box_h, menu_color, menu_spec));
                     } else {
                         self.rounded_boxes.insert(0, RoundedBox {
-                            x: plate_x,
+                            x: anim_x,
                             y: 0.0,
                             w: anim_w,
                             h: bar_h + reveal_h,
@@ -973,7 +1027,7 @@ impl StatusApp {
                         } else {
                             if hovered == Some(i) && row.enabled {
                                 self.rects.push(RectWidget {
-                                    x: plate_x + 2.0,
+                                    x: anim_x + 2.0,
                                     y: iy,
                                     w: anim_w - 4.0,
                                     h: h,
@@ -1369,6 +1423,9 @@ impl cce_ui::engine::Application for StatusApp {
             context_menu: None,
             menu_anim: 0.0,
             menu_closing: false,
+            bubble_w_now: 0.0,
+            bubble_w_target: 0.0,
+            collapsed_box: None,
             input_regions: Vec::new(),
             module_bounds: Vec::new(),
             left_modules,
@@ -1559,6 +1616,25 @@ impl cce_ui::engine::Application for StatusApp {
             }
         }
 
+        // Ease the drawn bubble toward its live-content width — the same
+        // treatment the scrim gets: stepping straight there would snap the
+        // bubble edges on every stat update or title change, and a flapping
+        // browser title would make the box twitch instead of breathe.
+        {
+            const BUBBLE_EASE_S: f32 = 0.12;
+            let target = self.bubble_w_target;
+            if (target - self.bubble_w_now).abs() > 0.5 {
+                let step = (dt / BUBBLE_EASE_S).clamp(0.0, 1.0);
+                self.bubble_w_now += (target - self.bubble_w_now) * step;
+                *needs_rebuild = true;
+                self.needs_rebuild = true;
+            } else if self.bubble_w_now != target && target > 0.0 {
+                self.bubble_w_now = target;
+                *needs_rebuild = true;
+                self.needs_rebuild = true;
+            }
+        }
+
         // In-surface menu expansion/contraction: the surface grows into the
         // menu and shrinks back over MENU_ANIM_S, one resize+repaint per
         // tick. The menu object is dropped only when the contraction lands.
diff --git a/src/modules.rs b/src/modules.rs
index ccd7695..0a65560 100644
--- a/src/modules.rs
+++ b/src/modules.rs
@@ -32,6 +32,27 @@ pub trait StatusModule {
         padding: f32,
     ) -> f32;
 
+    /// Width of the module's LIVE content plus padding — what the drawn
+    /// bubble hugs. `width()` stays the stable LAYOUT width (widest-plausible
+    /// templates, title quantization) that sizes the slot and the surface, so
+    /// the compositor never sees a resize; this one may be narrower, and the
+    /// bubble is centered in the slot on the difference so the padding on
+    /// each side of the content is the configured padding rather than
+    /// padding-plus-template-surplus. Defaults to `width()` for modules whose
+    /// slot already is their content (clock, tray, light_source).
+    fn content_width(
+        &self,
+        stats: &Option<SystemStats>,
+        title: &str,
+        font_system: &mut FontSystem,
+        font_family: &str,
+        font_size: f32,
+        tray_items: &HashMap<String, TrayItem>,
+        padding: f32,
+    ) -> f32 {
+        self.width(stats, title, font_system, font_family, font_size, tray_items, padding)
+    }
+
     fn render(
         &self,
         x: f32,
@@ -75,9 +96,33 @@ fn stable_text_width(
     live.max(tmpl) + 2.0 * padding
 }
 
+/// The live half of `stable_text_width`: the text as it is right now, plus
+/// padding — the content measure `content_width` implementations return.
+fn live_text_width(
+    font_system: &mut FontSystem,
+    text: &str,
+    font_size: f32,
+    font_family: &str,
+    padding: f32,
+) -> f32 {
+    Label::new_with_family(font_system, text, font_size, [0.0, 0.0, 0.0, 1.0], font_family).w
+        + 2.0 * padding
+}
+
 /// Chip text shown by the window module while nothing holds keyboard focus.
 const NO_FOCUS_TEXT: &str = "no focus";
 
+/// The window module's title as displayed: ellipsized past 40 chars. One
+/// place, because `width`, `content_width` and `render` must all measure the
+/// same string.
+fn display_title(title: &str) -> String {
+    if title.chars().count() > 40 {
+        title.chars().take(37).collect::<String>() + "..."
+    } else {
+        title.to_string()
+    }
+}
+
 pub struct WindowModule;
 
 impl StatusModule for WindowModule {
@@ -99,12 +144,7 @@ impl StatusModule for WindowModule {
     ) -> f32 {
         let has_title = !title.is_empty() && title != "(none)";
         if has_title {
-            let mut display_title = title.to_string();
-            if display_title.chars().count() > 40 {
-                display_title = display_title.chars().take(37).collect::<String>() + "...";
-            }
-            let label = Label::new_with_family(font_system, &display_title, font_size, [0.0, 0.0, 0.0, 1.0], font_family);
-            let total_w = label.w;
+            let total_w = live_text_width(font_system, &display_title(title), font_size, font_family, padding);
 
             // Title-mode width quantized UP to a coarse step: titles change
             // constantly (dirty markers, browser tabs, terminal cwd), and
@@ -112,22 +152,42 @@ impl StatusModule for WindowModule {
             // shoving the neighboring module sideways each time (the
             // light_source flicker) and, at 372↔456px alternation rates,
             // feeding the compositor's configure echo loop. Within a bucket a
-            // title change costs nothing.
+            // title change costs nothing. (The drawn bubble hugs the exact
+            // title via `content_width`; the bucket sizes only the surface.)
             const TITLE_WIDTH_STEP: f32 = 24.0;
-            ((total_w + 2.0 * padding) / TITLE_WIDTH_STEP).ceil() * TITLE_WIDTH_STEP
+            (total_w / TITLE_WIDTH_STEP).ceil() * TITLE_WIDTH_STEP
         } else {
             // "(none)" is the compositor explicitly reporting Focus::None
             // (keystrokes go nowhere); an empty title is just the feed not
             // having connected yet, which must not flash the indicator.
             if title == "(none)" {
-                let label = Label::new_with_family(font_system, NO_FOCUS_TEXT, font_size, [0.0, 0.0, 0.0, 1.0], font_family);
-                label.w + 2.0 * padding
+                live_text_width(font_system, NO_FOCUS_TEXT, font_size, font_family, padding)
             } else {
                 0.0
             }
         }
     }
 
+    fn content_width(
+        &self,
+        _stats: &Option<SystemStats>,
+        title: &str,
+        font_system: &mut FontSystem,
+        font_family: &str,
+        font_size: f32,
+        _tray_items: &HashMap<String, TrayItem>,
+        padding: f32,
+    ) -> f32 {
+        let has_title = !title.is_empty() && title != "(none)";
+        if has_title {
+            live_text_width(font_system, &display_title(title), font_size, font_family, padding)
+        } else if title == "(none)" {
+            live_text_width(font_system, NO_FOCUS_TEXT, font_size, font_family, padding)
+        } else {
+            0.0
+        }
+    }
+
     fn render(
         &self,
         x: f32,
@@ -152,11 +212,7 @@ impl StatusModule for WindowModule {
     ) {
         let has_title = !title.is_empty() && title != "(none)";
         if has_title {
-            let mut display_title = title.to_string();
-            if display_title.chars().count() > 40 {
-                display_title = display_title.chars().take(37).collect::<String>() + "...";
-            }
-            let label = Label::new_with_family(font_system, &display_title, font_size, normal_color, font_family);
+            let label = Label::new_with_family(font_system, &display_title(title), font_size, normal_color, font_family);
             crate::draw_label(text_prims, label, x + padding, centered_text_y(bar_h, font_size));
         } else if title == "(none)" {
             // Dim chip signalling that no window has keyboard focus — the
@@ -238,6 +294,15 @@ impl StatusModule for ClockModule {
 
 pub struct BatteryModule;
 
+impl BatteryModule {
+    fn live_text<'a>(stats: &'a Option<SystemStats>) -> &'a str {
+        match stats {
+            Some(s) if !s.battery.is_empty() => &s.battery,
+            _ => "Bat 100%",
+        }
+    }
+}
+
 impl StatusModule for BatteryModule {
     fn name(&self) -> &'static str { "battery" }
 
@@ -251,12 +316,20 @@ impl StatusModule for BatteryModule {
         _tray_items: &HashMap<String, TrayItem>,
         padding: f32,
     ) -> f32 {
-        let text = if let Some(ref s) = stats {
-            if !s.battery.is_empty() { &s.battery } else { "Bat 100%" }
-        } else {
-            "Bat 100%"
-        };
-        stable_text_width(font_system, text, "Bat 100%", font_size, font_family, padding)
+        stable_text_width(font_system, Self::live_text(stats), "Bat 100%", font_size, font_family, padding)
+    }
+
+    fn content_width(
+        &self,
+        stats: &Option<SystemStats>,
+        _title: &str,
+        font_system: &mut FontSystem,
+        font_family: &str,
+        font_size: f32,
+        _tray_items: &HashMap<String, TrayItem>,
+        padding: f32,
+    ) -> f32 {
+        live_text_width(font_system, Self::live_text(stats), font_size, font_family, padding)
     }
 
     fn render(
@@ -297,6 +370,15 @@ impl StatusModule for BatteryModule {
 
 pub struct VolumeModule;
 
+impl VolumeModule {
+    fn live_text<'a>(stats: &'a Option<SystemStats>) -> &'a str {
+        match stats {
+            Some(s) if !s.volume.is_empty() => &s.volume,
+            _ => "Vol 100%",
+        }
+    }
+}
+
 impl StatusModule for VolumeModule {
     fn name(&self) -> &'static str { "volume" }
 
@@ -310,12 +392,20 @@ impl StatusModule for VolumeModule {
         _tray_items: &HashMap<String, TrayItem>,
         padding: f32,
     ) -> f32 {
-        let text = if let Some(ref s) = stats {
-            if !s.volume.is_empty() { &s.volume } else { "Vol 100%" }
-        } else {
-            "Vol 100%"
-        };
-        stable_text_width(font_system, text, "Vol 100%", font_size, font_family, padding)
+        stable_text_width(font_system, Self::live_text(stats), "Vol 100%", font_size, font_family, padding)
+    }
+
+    fn content_width(
+        &self,
+        stats: &Option<SystemStats>,
+        _title: &str,
+        font_system: &mut FontSystem,
+        font_family: &str,
+        font_size: f32,
+        _tray_items: &HashMap<String, TrayItem>,
+        padding: f32,
+    ) -> f32 {
+        live_text_width(font_system, Self::live_text(stats), font_size, font_family, padding)
     }
 
     fn render(
@@ -357,6 +447,15 @@ impl StatusModule for VolumeModule {
 
 pub struct BrightnessModule;
 
+impl BrightnessModule {
+    fn live_text<'a>(stats: &'a Option<SystemStats>) -> &'a str {
+        match stats {
+            Some(s) if !s.brightness.is_empty() => &s.brightness,
+            _ => "Bri 100%",
+        }
+    }
+}
+
 impl StatusModule for BrightnessModule {
     fn name(&self) -> &'static str { "brightness" }
 
@@ -370,12 +469,20 @@ impl StatusModule for BrightnessModule {
         _tray_items: &HashMap<String, TrayItem>,
         padding: f32,
     ) -> f32 {
-        let text = if let Some(ref s) = stats {
-            if !s.brightness.is_empty() { &s.brightness } else { "Bri 100%" }
-        } else {
-            "Bri 100%"
-        };
-        stable_text_width(font_system, text, "Bri 100%", font_size, font_family, padding)
+        stable_text_width(font_system, Self::live_text(stats), "Bri 100%", font_size, font_family, padding)
+    }
+
+    fn content_width(
+        &self,
+        stats: &Option<SystemStats>,
+        _title: &str,
+        font_system: &mut FontSystem,
+        font_family: &str,
+        font_size: f32,
+        _tray_items: &HashMap<String, TrayItem>,
+        padding: f32,
+    ) -> f32 {
+        live_text_width(font_system, Self::live_text(stats), font_size, font_family, padding)
     }
 
     fn render(
@@ -411,6 +518,15 @@ impl StatusModule for BrightnessModule {
 
 pub struct MemoryModule;
 
+impl MemoryModule {
+    fn live_text<'a>(stats: &'a Option<SystemStats>) -> &'a str {
+        match stats {
+            Some(s) if !s.memory.is_empty() => &s.memory,
+            _ => "Mem 0/0G",
+        }
+    }
+}
+
 impl StatusModule for MemoryModule {
     fn name(&self) -> &'static str { "memory" }
 
@@ -424,11 +540,7 @@ impl StatusModule for MemoryModule {
         _tray_items: &HashMap<String, TrayItem>,
         padding: f32,
     ) -> f32 {
-        let text = if let Some(ref s) = stats {
-            if !s.memory.is_empty() { &s.memory } else { "Mem 0/0G" }
-        } else {
-            "Mem 0/0G"
-        };
+        let text = Self::live_text(stats);
         // "Mem 17/62G" → "Mem 62/62G": used pinned to the total, the
         // widest this machine's readout gets.
         let template = text
@@ -440,6 +552,19 @@ impl StatusModule for MemoryModule {
         stable_text_width(font_system, text, &template, font_size, font_family, padding)
     }
 
+    fn content_width(
+        &self,
+        stats: &Option<SystemStats>,
+        _title: &str,
+        font_system: &mut FontSystem,
+        font_family: &str,
+        font_size: f32,
+        _tray_items: &HashMap<String, TrayItem>,
+        padding: f32,
+    ) -> f32 {
+        live_text_width(font_system, Self::live_text(stats), font_size, font_family, padding)
+    }
+
     fn render(
         &self,
         x: f32,
@@ -471,6 +596,15 @@ impl StatusModule for MemoryModule {
 
 pub struct CpuModule;
 
+impl CpuModule {
+    fn live_text<'a>(stats: &'a Option<SystemStats>) -> &'a str {
+        match stats {
+            Some(s) if !s.cpu.is_empty() => &s.cpu,
+            _ => "Cpu 0%",
+        }
+    }
+}
+
 impl StatusModule for CpuModule {
     fn name(&self) -> &'static str { "cpu" }
 
@@ -484,12 +618,20 @@ impl StatusModule for CpuModule {
         _tray_items: &HashMap<String, TrayItem>,
         padding: f32,
     ) -> f32 {
-        let text = if let Some(ref s) = stats {
-            if !s.cpu.is_empty() { &s.cpu } else { "Cpu 0%" }
-        } else {
-            "Cpu 0%"
-        };
-        stable_text_width(font_system, text, "Cpu 100%", font_size, font_family, padding)
+        stable_text_width(font_system, Self::live_text(stats), "Cpu 100%", font_size, font_family, padding)
+    }
+
+    fn content_width(
+        &self,
+        stats: &Option<SystemStats>,
+        _title: &str,
+        font_system: &mut FontSystem,
+        font_family: &str,
+        font_size: f32,
+        _tray_items: &HashMap<String, TrayItem>,
+        padding: f32,
+    ) -> f32 {
+        live_text_width(font_system, Self::live_text(stats), font_size, font_family, padding)
     }
 
     fn render(