status bar
git clone https://git.lucas.co/cce-status-interface.git
feat(readout): bold digits in a dark pocket cut into the glyph
White digits on a white ghost had only the alpha gap for contrast. The
number now shapes at module { icon_weight } (default 700 — TextPrim
grows a weight field, set by this one run) and sits in a pocket: a
feathered Glow pool in the number's contrast color, hugging the digits,
drawn between the glyph and the number (module { icon_pocket }, default
0.6). The bubble scrim's treatment applied locally — darken the ground
under the digits, don't outline the letterforms.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
CLAUDE.md | 13 ++++++++++--
src/config.rs | 17 ++++++++++++++++
src/main.rs | 63 ++++++++++++++++++++++++++++++++++++++++++++++++---------
src/modules.rs | 64 +++++++++++++++++++++++++++++++++++++++++++++-------------
4 files changed, 132 insertions(+), 25 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index e403fdc..5a54a0e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -92,7 +92,14 @@ blanket `impl<T: IconStat> StatusModule for T` in `modules.rs` does the shared
layout): each names a cce-icons glyph, the bare number and a color, and
`IconReadout` draws the glyph ghosted at `module { icon_alpha }` with the
number centered on it — no unit symbol, since the glyph IS the unit ("87" on
-the battery, not "Bat 87%"). The muted sink swaps to `volume-muted`; the
+the battery, not "Bat 87%"). The number is bold (`module { icon_weight }`,
+the one run in the bar that sets `TextPrim`'s weight field) and sits in a
+POCKET: a feathered `Prim::Glow` pool in the number's contrast color, hugging
+the digits and drawn between the glyph and the number (`IconPrim::pocket`,
+`module { icon_pocket }`). Same-hue was the problem — white digits on a white
+ghost had only the alpha gap for contrast — and the pocket is the bubble
+scrim's answer applied locally: darken the ground under the digits rather
+than outline the letterforms. The muted sink swaps to `volume-muted`; the
battery keeps its accent color while charging or under 10% (the only
charging cue now that the "⚡" prefix went with the text form). A module whose
reader has nothing (no battery, no backlight, no pactl) returns `None` and
@@ -230,7 +237,9 @@ and `module { icon_size }` (glyph height for the icon readouts, logical px,
default bar height − 6) and `module { icon_font_size }` (the number on the
glyph, default ¾ of `module { font_size }` — "100" at the full size
overhangs a bar-height glyph) and `module { icon_alpha }` (the glyph's
-ghosting under the number, 0-1, default 0.4) and `module { text_raise }` (lifts module text above vertical center, logical
+ghosting under the number, 0-1, default 0.4) and `module { icon_weight }`
+(OpenType weight of that number, default 700) and `module { icon_pocket }`
+(opacity 0-1 of the dark pocket under the digits, default 0.6, 0 = off) and `module { text_raise }` (lifts module text above vertical center, logical
px, bar-side only — every module funnels through `centered_text_y`) and
`module { text_scrim }` (0-1 resting opacity of a
feathered pool filling each module box, the DE's one text-contrast treatment)
diff --git a/src/config.rs b/src/config.rs
index caaae50..943a63b 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -209,6 +209,23 @@ pub(crate) fn read_icon_font_size_from_config(font_size: f32) -> f32 {
cfg_f32("/module/icon_font_size").unwrap_or(font_size * 0.75).max(1.0)
}
+/// `module { icon_weight }` — OpenType weight of the number on a glyph
+/// (default 700, bold; 400 = the face's regular). Stroke width is what a
+/// digit of that size has to spare, so the number is heavier than the
+/// labels around it.
+pub(crate) fn read_icon_weight_from_config() -> Option<u16> {
+ let w = cfg_f32("/module/icon_weight").unwrap_or(700.0).clamp(1.0, 1000.0) as u16;
+ Some(w)
+}
+
+/// `module { icon_pocket }` — opacity 0-1 (default 0.6) of the feathered
+/// pool under the digits, in the number's contrast color: the notch the
+/// number sits in, cut into the glyph so the digits are never read against
+/// the glyph's own tint. 0 disables it.
+pub(crate) fn read_icon_pocket_from_config() -> f32 {
+ cfg_f32("/module/icon_pocket").unwrap_or(0.6).clamp(0.0, 1.0)
+}
+
/// `module { icon_alpha }` — opacity 0-1 of the glyph under the number
/// (default 0.4). The glyph is tinted the number's color, so it is this
/// ghosting alone that keeps the digits legible on it.
diff --git a/src/main.rs b/src/main.rs
index 0326af9..cea0362 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -84,6 +84,23 @@ pub struct IconPrim {
pub w: f32,
pub h: f32,
pub alpha: f32,
+ /// The dark pocket the number sits in, cut into the glyph — see `Pocket`.
+ pub pocket: Option<Pocket>,
+}
+
+/// A feathered pool under a readout's digits (`cce_ui`'s `Prim::Glow`, the
+/// same ground treatment the bubble scrim is): solid through the core rect,
+/// fading to nothing across `feather` px outside it. Drawn between the glyph
+/// and the number, so the digits sit in a notch of the number's contrast
+/// color rather than on the glyph's own tint. Logical px.
+#[derive(Debug, Clone, Copy)]
+pub struct Pocket {
+ pub x: f32,
+ pub y: f32,
+ pub w: f32,
+ pub h: f32,
+ pub feather: f32,
+ pub color: [f32; 4],
}
/// The subset of [`SystemStats`] a given module actually paints, as a comparable
@@ -247,7 +264,7 @@ fn spec_at_reference_height(
/// module color, because a module may paint a run in something else entirely
/// — the volume module's muted state uses the shared `disabled_color`. A
/// black pool behind black text is not a weaker treatment, it is an eraser.
-fn treatment_rgb(rgb: [u8; 3]) -> [f32; 3] {
+pub(crate) fn treatment_rgb(rgb: [u8; 3]) -> [f32; 3] {
let luma = relative_luminance([rgb[0] as f32 / 255.0, rgb[1] as f32 / 255.0, rgb[2] as f32 / 255.0, 1.0]);
if contrast_ratio(luma, 0.0) >= contrast_ratio(luma, 1.0) {
[0.0, 0.0, 0.0]
@@ -283,7 +300,7 @@ fn scrim_feather(w: f32, h: f32, configured: Option<f32>) -> f32 {
/// longest label, and that is the one whose legibility carries the segment.
fn dominant_run_color(runs: &[TextPrim], bx: f32, by: f32, bw: f32, bh: f32) -> Option<[u8; 3]> {
let mut best: Option<(f32, [u8; 3])> = None;
- for (_, tsize, x, y, color, _, _, _, run_w) in runs {
+ for (_, tsize, x, y, color, _, _, _, run_w, _) in runs {
let Some(rw) = *run_w else { continue };
// Runs belong to the box they sit in; a segment with an expanded menu
// has text in both.
@@ -384,6 +401,14 @@ impl ModuleContextMenu {
}
pub(crate) fn make_text_buffer(fs: &mut FontSystem, text: &str, size: f32, font_family: &str) -> Buffer {
+ make_text_buffer_weighted(fs, text, size, font_family, None)
+}
+
+/// `make_text_buffer` at an OpenType weight (`Some(700)` = bold) — the
+/// measurement the icon readouts' numbers need, since the engine shapes a
+/// weighted run with that face and its advances are the face's, not the
+/// regular's.
+pub(crate) fn make_text_buffer_weighted(fs: &mut FontSystem, text: &str, size: f32, font_family: &str, weight: Option<u16>) -> Buffer {
let scale = cce_ui::scale::scale_factor();
let mut font_size = size;
@@ -397,6 +422,9 @@ pub(crate) fn make_text_buffer(fs: &mut FontSystem, text: &str, size: f32, font_
let metrics = Metrics::new(physical_size, physical_size * 1.4);
let mut buf = Buffer::new(fs, metrics);
let mut attrs = Attrs::new();
+ if let Some(w) = weight {
+ attrs = attrs.weight(cce_ui::cosmic_text::Weight(w));
+ }
// Same shaping rule as cce-ui's buffer path (ASCII in a mono face →
// Basic, no ligatures), so this measurement agrees with what the engine
// draws — an fi ligature applied on one side only would skew widths by a
@@ -947,6 +975,7 @@ impl StatusApp {
None,
// No scrim: the tooltip already sits on its own box.
None,
+ None,
));
}
}
@@ -1071,6 +1100,7 @@ impl StatusApp {
None,
// Menu text sits on the expanded box; no scrim.
None,
+ None,
));
let rows = menu.rows().to_vec();
@@ -1112,6 +1142,7 @@ impl StatusApp {
None,
None,
None,
+ None,
));
}
bounds.push((off, h));
@@ -1162,6 +1193,13 @@ impl StatusApp {
ip.y = old_x;
ip.w = old_h;
ip.h = old_w;
+ if let Some(pk) = &mut ip.pocket {
+ let (old_x, old_y, old_w, old_h) = (pk.x, pk.y, pk.w, pk.h);
+ pk.x = old_y;
+ pk.y = old_x;
+ pk.w = old_h;
+ pk.h = old_w;
+ }
}
// Rotate tray_item_bounds
for tib in &mut self.tray_item_bounds {
@@ -1389,7 +1427,9 @@ pub(crate) fn parse_ccectl_windows(output: &str) -> Vec<CcectlWindow> {
/// width. It is what lets the text scrim hug the run instead of the whole
/// module box; `None` simply gets no scrim, which is right for the menu and
/// tooltip text that sits on an opaque box already.
-pub(crate) type TextPrim = (String, f32, f32, f32, [u8; 3], Option<String>, Option<[f32; 4]>, Option<cce_ui::scene::paint::TextLayout>, Option<f32>);
+/// The field after that is the run's OpenType weight (`Some(700)` = bold),
+/// `None` for the face's regular; only the icon readouts' numbers set it.
+pub(crate) type TextPrim = (String, f32, f32, f32, [u8; 3], Option<String>, Option<[f32; 4]>, Option<cce_ui::scene::paint::TextLayout>, Option<f32>, Option<u16>);
/// Emit a measured `StyledLabel` as a text-prim tuple, returning its width (like the legacy
/// `StyledLabel::draw`). The label was built for its width; `into_prim` carries the source
@@ -1397,7 +1437,7 @@ pub(crate) type TextPrim = (String, f32, f32, f32, [u8; 3], Option<String>, Opti
pub(crate) fn draw_label(prims: &mut Vec<TextPrim>, label: cce_ui::widget::StyledLabel, x: f32, y: f32) -> f32 {
let w = label.w;
let p = label.into_prim(x, y);
- prims.push((p.text, p.size, p.x, p.y, p.color, p.font, None, p.layout, Some(w)));
+ prims.push((p.text, p.size, p.x, p.y, p.color, p.font, None, p.layout, Some(w), None));
w
}
@@ -1917,16 +1957,21 @@ impl cce_ui::engine::Application for StatusApp {
// pre-text so the number lands on the glyph.
for ip in &self.icon_prims {
pc.image(ip.image, Rect { x: ip.x, y: ip.y, width: ip.w, height: ip.h }, ip.alpha);
+ if let Some(pk) = ip.pocket {
+ let core = Rect { x: pk.x, y: pk.y, width: pk.w, height: pk.h };
+ pc.glow(core, pk.h.min(pk.w) / 2.0, pk.feather, pk.color);
+ }
}
- for (text, tsize, x, y, color, font, bounds, layout, _run_w) in &self.text_prims {
+ for (text, tsize, x, y, color, font, bounds, layout, _run_w, weight) in &self.text_prims {
+ let attrs = cce_ui::scene::paint::TextAttrs { italic: false, weight: *weight };
match layout {
- Some(l) => pc.text_boxed(text.clone(), *x, *y, *tsize, *color, font.clone(), *bounds, cce_ui::scene::paint::TextAttrs::default(), *l),
+ Some(l) => pc.text_boxed(text.clone(), *x, *y, *tsize, *color, font.clone(), *bounds, attrs, *l),
// Glyphs are drawn plain. Contrast is the scrim's job now —
// it darkens the ground rather than decorating the
// letterforms, and the two together were always one treatment
// too many.
- None => pc.text_with(text.clone(), *x, *y, *tsize, *color, font.clone(), *bounds),
+ None => pc.text_attrs(text.clone(), *x, *y, *tsize, *color, font.clone(), *bounds, attrs),
}
}
@@ -2662,7 +2707,7 @@ mod tests {
}
fn run(x: f32, y: f32, w: f32, color: [u8; 3]) -> TextPrim {
- ("x".to_string(), 14.0, x, y, color, None, None, None, Some(w))
+ ("x".to_string(), 14.0, x, y, color, None, None, None, Some(w), None)
}
#[test]
@@ -2683,7 +2728,7 @@ mod tests {
fn a_box_with_no_measured_text_gets_no_pool() {
// The tray is icons; there is no text to ground, and a pool there
// would just be a smudge behind the icons.
- let runs: Vec<TextPrim> = vec![("i".to_string(), 14.0, 20.0, 7.0, [255, 255, 255], None, None, None, None)];
+ let runs: Vec<TextPrim> = vec![("i".to_string(), 14.0, 20.0, 7.0, [255, 255, 255], None, None, None, None, None)];
assert_eq!(dominant_run_color(&runs, 10.0, 0.0, 200.0, 27.0), None);
assert_eq!(dominant_run_color(&[], 10.0, 0.0, 200.0, 27.0), None);
}
diff --git a/src/modules.rs b/src/modules.rs
index 0d752c7..e784433 100644
--- a/src/modules.rs
+++ b/src/modules.rs
@@ -326,6 +326,12 @@ impl IconReadout {
crate::read_icon_font_size_from_config(font_size)
}
+ /// A number's run width at the readout weight, logical px.
+ fn number_width(font_system: &mut FontSystem, text: &str, size: f32, font_family: &str) -> f32 {
+ let buf = crate::make_text_buffer_weighted(font_system, text, size, font_family, crate::read_icon_weight_from_config());
+ buf.layout_runs().next().map(|r| r.line_w).unwrap_or(0.0) / cce_ui::scale::scale_factor()
+ }
+
/// Stable slot width: the glyph, the widest number and the live number
/// — whichever is widest — plus padding. Normally the glyph wins, which
/// is what makes the slot stable; a number wider than the glyph would
@@ -334,10 +340,8 @@ impl IconReadout {
match self.glyph() {
Some((_, gw, _)) => {
let ns = Self::number_size(font_size);
- let tmpl = Label::new_with_family(font_system, NUMBER_TEMPLATE, ns, [0.0, 0.0, 0.0, 1.0], font_family).w;
- let live = self.number.as_deref().map_or(0.0, |n| {
- Label::new_with_family(font_system, n, ns, [0.0, 0.0, 0.0, 1.0], font_family).w
- });
+ let tmpl = Self::number_width(font_system, NUMBER_TEMPLATE, ns, font_family);
+ let live = self.number.as_deref().map_or(0.0, |n| Self::number_width(font_system, n, ns, font_family));
gw.max(tmpl).max(live) + 2.0 * padding
}
None => stable_text_width(font_system, &self.fallback, self.fallback_template, font_size, font_family, padding),
@@ -350,9 +354,7 @@ impl IconReadout {
match self.glyph() {
Some((_, gw, _)) => {
let ns = Self::number_size(font_size);
- let live = self.number.as_deref().map_or(0.0, |n| {
- Label::new_with_family(font_system, n, ns, [0.0, 0.0, 0.0, 1.0], font_family).w
- });
+ let live = self.number.as_deref().map_or(0.0, |n| Self::number_width(font_system, n, ns, font_family));
gw.max(live) + 2.0 * padding
}
None => live_text_width(font_system, &self.fallback, font_size, font_family, padding),
@@ -373,18 +375,54 @@ impl IconReadout {
match self.glyph() {
Some((image, gw, gh)) => {
let ns = Self::number_size(font_size);
- let label = self
+ let weight = crate::read_icon_weight_from_config();
+ let number = self
.number
.as_deref()
- .map(|n| Label::new_with_family(font_system, n, ns, self.color, font_family));
+ .map(|n| (n.to_string(), Self::number_width(font_system, n, ns, font_family)));
// Glyph and number share one center: the wider of the two
// spans the content, and both are centered on it. The same
// `module { text_raise }` lift every text run gets via
// `centered_text_y` is applied to the glyph too, so the pair
// stays concentric and level with the neighboring modules.
- let span = gw.max(label.as_ref().map_or(0.0, |l| l.w));
+ let span = gw.max(number.as_ref().map_or(0.0, |(_, w)| *w));
let cx = x + padding + span / 2.0;
let gy = (bar_h - gh) / 2.0 - crate::config::read_text_raise_from_config();
+ let rgb = crate::icons::tint_of(self.color);
+ let mut pocket = None;
+ if let Some((text, nw)) = number {
+ let nx = cx - nw / 2.0;
+ let ny = centered_text_y(bar_h, ns);
+ // The pocket hugs the digits, not the line box: in a
+ // line box of exactly `ns`, digits run from about the
+ // cap line at 0.1em down to the baseline near 0.85em.
+ // Its feather reaches out from that core, so the notch
+ // in the glyph is a little larger than the digits.
+ let pocket_alpha = crate::read_icon_pocket_from_config();
+ if pocket_alpha > 0.0 {
+ let c = crate::treatment_rgb(rgb);
+ pocket = Some(crate::Pocket {
+ x: nx - 1.0,
+ y: ny + ns * 0.1,
+ w: nw + 2.0,
+ h: ns * 0.75,
+ feather: ns * 0.3,
+ color: [c[0], c[1], c[2], pocket_alpha],
+ });
+ }
+ text_prims.push((
+ text,
+ ns,
+ nx,
+ ny,
+ rgb,
+ Some(font_family.to_string()),
+ None,
+ None,
+ Some(nw),
+ weight,
+ ));
+ }
icon_prims.push(crate::IconPrim {
image,
x: cx - gw / 2.0,
@@ -392,11 +430,8 @@ impl IconReadout {
w: gw,
h: gh,
alpha: crate::read_icon_alpha_from_config(),
+ pocket,
});
- if let Some(label) = label {
- let lw = label.w;
- crate::draw_label(text_prims, label, cx - lw / 2.0, centered_text_y(bar_h, ns));
- }
}
None => {
let label = Label::new_with_family(font_system, &self.fallback, font_size, self.color, font_family);
@@ -845,6 +880,7 @@ impl StatusModule for TrayModule {
None,
None,
Some(tw),
+ None,
));
}
}