status bar
git clone https://git.lucas.co/cce-status-interface.git
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(