status bar
git clone https://git.lucas.co/cce-status-interface.git
fix: every symbol the bar draws is a cce-icons glyph
- icons.rs is a thin icons::glyph over cce_ui::upload_icon_tinted, whose
cache follows the renderer epoch; its own rasterize-tint-upload cache,
which outlived the renderer and had to be emptied by hand in
renderer_init (bar and OSD), is gone.
- The in-surface menus draw the toolkit's marks and page turns as
glyphs: a DBusMenu toggle becomes context_menu::MARK_CHECK / MARK_ON /
MARK_OFF by its toggle-type (check, circle, circle-outline), a submenu
row ends in chevron-right, a back row reads "Back" after chevron-left,
and the window module's Mode page is a radio group. They were "[x]" /
"[ ]", "Label >" and "< Back" text.
- A tray item with neither a pixmap nor a resolvable icon is drawn as a
glyph guessed from its icon name (volume, wifi, battery, bluetooth,
mail, chat, gamepad, cube, else gear) where it was an emoji.
- The charging battery's text fallback is "Chg 87%", not a bolt.
Tests: menu_marks_and_page_turns_are_glyphs,
a_tray_item_without_an_icon_is_a_glyph.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 47 +++++++++++-------
src/cloud.rs | 71 ++++++++++++++++++++-------
src/icons.rs | 112 ++++++++++++-------------------------------
src/main.rs | 148 +++++++++++++++++++++++++++++++++++++++++++++++----------
src/modules.rs | 127 ++++++++++++++++++++++++-------------------------
src/osd.rs | 18 ++-----
6 files changed, 305 insertions(+), 218 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index cf476c1..0aa210c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -20,7 +20,7 @@ cce-icons glyph textures), `src/listeners.rs` (status/switcher socket tasks),
```sh
cargo build --release # standalone build (or `-p cce-status-interface` from the workspace root)
-cargo test # 46 tests: main.rs (parsers, droplet geometry), config.rs, tray.rs, osd.rs, the tray bridge's x11.rs and title.rs
+cargo test # 48 tests: main.rs (parsers, droplet geometry, menu glyphs), config.rs, tray.rs, osd.rs, the tray bridge's x11.rs and title.rs
make install # release build, then `ccebuild install --no-build cce-status-interface`
```
@@ -244,19 +244,20 @@ number (cpu with no /proc/stat, a sink with no level) draws the glyph
alone.
The glyphs come from the **cce-icons** crate via `cce_ui::icons_dir()`
-(`$CCE_ICONS_DIR`, else `~/projects/cce/cce-icons/svg`) — but NOT through
-`cce_ui::upload_icon`: a `Prim::Image` has alpha and no color, and the
-artwork is white, so `icons.rs::tinted_icon` rasterizes the SVG itself
-(`cce_ui::rasterize_svg`), multiplies it by the readout's raw-sRGB color and
-uploads it, cached per `(name, px, color)` — for the life of the RENDERER,
-not the process: the cache holds renderer image ids, and a reconnect
-(cce-ui repairs a lost transport by opening a new session around the same
-`Application`) rebuilds the renderer and its image table, leaving every
-cached id naming nothing. A draw for an unknown id is skipped rather than
-reported, so a reconnected bar came back with its numbers and no glyphs at
-all; `renderer_init` now calls `icons::drop_textures()` on every renderer
-after the first, and the rebuild it forces re-uploads them. A
-glyph that fails to load falls back to the old text readout ("Cpu 45%"), so
+(`$CCE_ICONS_DIR`, else `~/projects/cce/cce-icons/svg`), through
+`icons::glyph` — `cce_ui::upload_icon_tinted` at a logical size: a
+`Prim::Image` has alpha and no color, and the artwork is white, so the
+readout's raw-sRGB color is baked into the texture, cached per `(name, px,
+color)` and keyed on the renderer epoch. A reconnect (cce-ui repairs a lost
+transport by opening a new session around the same `Application`) rebuilds
+the renderer and its image table; the cache then misses and re-uploads, and
+`renderer_init` forces a layout rebuild so the retained `icon_prims` stop
+naming the old renderer's ids — a draw for an unknown id is skipped rather
+than reported, which is how a reconnected bar once kept its numbers and lost
+every glyph. (Until 2026-10-05 `icons.rs` rasterized and uploaded the SVGs
+itself, through a cache `renderer_init` had to empty by hand.) A
+glyph that fails to load falls back to the old text readout ("Cpu 45%",
+"Chg 87%" for a charging battery), so
a bar started without the icon set is still attributable; **a shadow session
needs `CCE_ICONS_DIR` exported into the spawn**, its HOME being elsewhere,
exactly as it needs `CCE_FONTS_DIR`. Slot stability holds as before: the
@@ -316,7 +317,12 @@ listen to tray D-Bus, etc.:
~45ms for the backlight, ~55ms for the sink, against a second before.
- **Tray** (`spawn_status_tray`): a full StatusNotifierItem/Watcher host over `zbus`,
including DBusMenu fetching. Icons arrive as pixmaps or theme names (rendered via
- `resvg`/`png`).
+ `resvg`/`png`) — the app's own art, drawn as it comes. An item that brings
+ neither a pixmap nor a name the theme resolves is drawn as a cce-icons glyph
+ guessed from its icon name (`modules::tray_fallback_glyph`: volume, wifi,
+ battery, bluetooth, mail, chat, gamepad, cube for Dropbox, else the gear),
+ in the accent colour. Those were emoji until 2026-10-05; without the icon
+ set the slot is left empty, since 16 px holds no word.
- **Switcher** (`spawn_switcher_listener`): binds
`/tmp/cce-status-interface-switcher-{WAYLAND_DISPLAY}.sock`; a line on it fires
`SwitcherTriggered`.
@@ -337,7 +343,16 @@ contraction are ANIMATED over ~140ms, `menu_anim`/`menu_closing` stepped in
module context menus and tray icon DBusMenus alike (fetched/flattened by `cloud.rs::
fetch_tray_menu_pages` into `MenuPage`/`MenuRow` pages riding a
`CustomEvent::TrayMenuFetched`; submenus paginate in place; row clicks send the
-DBusMenu "clicked" via `send_tray_menu_event`). The compositor treats a status
+DBusMenu "clicked" via `send_tray_menu_event`). **A menu's marks and page
+turns are cce-icons glyphs**, by cce-ui's context-menu conventions
+(`cloud::menu_row_glyphs`): a label leading with `context_menu::MARK_CHECK` /
+`MARK_ON` / `MARK_OFF` is drawn with the check / circle / circle-outline glyph
+and the text without it (a DBusMenu toggle becomes one by its `toggle-type`,
+`cloud::toggle_mark`; the window module's Mode page is a radio group), a
+`Submenu` row ends in chevron-right, and a `Back` row reads "Back" after
+chevron-left. A page where any row has a left glyph reserves the column on
+every row, so the labels share an edge. They were `[x]` / `[ ]`, `Label >`
+and `< Back` text until 2026-10-05. The compositor treats a status
segment thicker than the bar as expanded: frozen slot, no size enforcement,
raised above overlapped windows; the bar must reset its own height on close.
In droplet style the expanded panel is a FLAT glass sheet: `spec_at_reference_height`
diff --git a/src/cloud.rs b/src/cloud.rs
index 5e43dd7..fb0b4c5 100644
--- a/src/cloud.rs
+++ b/src/cloud.rs
@@ -33,6 +33,9 @@ pub(crate) struct MenuItem {
label: String,
enabled: bool,
is_separator: bool,
+ /// `toggle-type`: "checkmark", "radio", or "" for an item that is not
+ /// a toggle.
+ toggle_type: String,
toggle_state: i32, // -1 if not toggleable, 0 if unchecked, 1 if checked
children: Vec<MenuItem>,
}
@@ -64,6 +67,13 @@ pub(crate) fn parse_menu_item(
})
.unwrap_or(true);
+ let toggle_type: String = properties.remove("toggle-type")
+ .and_then(|v| {
+ let s: Result<String, _> = v.try_into();
+ s.ok()
+ })
+ .unwrap_or_default();
+
let toggle_state: i32 = properties.remove("toggle-state")
.and_then(|v| {
let i: Result<i32, _> = v.try_into();
@@ -86,6 +96,7 @@ pub(crate) fn parse_menu_item(
label,
enabled,
is_separator,
+ toggle_type,
toggle_state,
children,
})
@@ -123,11 +134,51 @@ pub(crate) struct MenuPage {
pub rows: Vec<MenuRow>,
}
+/// The toolkit's leading mark (`cce_ui::widget::context_menu::MARK_*`) for a
+/// DBusMenu toggle: a checked checkmark item wears the check, a radio item
+/// the filled or the outlined circle, and an unchecked checkmark item and a
+/// plain item nothing.
+pub(crate) fn toggle_mark(toggle_type: &str, toggle_state: i32) -> &'static str {
+ use cce_ui::widget::context_menu::{MARK_CHECK, MARK_OFF, MARK_ON};
+ match (toggle_type, toggle_state) {
+ ("radio", 1) => MARK_ON,
+ ("radio", 0) => MARK_OFF,
+ (_, 1) => MARK_CHECK,
+ _ => "",
+ }
+}
+
+/// The row that turns a page back to `parent`: the word "Back", drawn after
+/// the chevron-left glyph.
+pub(crate) fn back_row(parent: usize) -> MenuRow {
+ MenuRow {
+ label: "Back".to_string(),
+ enabled: true,
+ separator: false,
+ action: MenuRowAction::Back(parent),
+ }
+}
+
+/// How a menu row is drawn around its text: the glyph at its left (a mark
+/// the label leads with, or the back chevron), the label without the mark,
+/// and the glyph at its right end (the chevron of a row that leads to a
+/// page). The same conventions as cce-ui's context menus, laid out here
+/// because the bar draws its menus in its own surface.
+pub(crate) fn menu_row_glyphs(row: &MenuRow) -> (Option<&'static str>, &str, Option<&'static str>) {
+ let (mark, text) = cce_ui::widget::context_menu::split_mark(&row.label);
+ match row.action {
+ MenuRowAction::Back(_) => (Some("chevron-left"), text, None),
+ MenuRowAction::Submenu(_) => (mark, text, Some("chevron-right")),
+ _ => (mark, text, None),
+ }
+}
+
/// Fetch a tray icon's DBusMenu and flatten it into in-surface pages: page 0
/// is the root; each enabled submenu becomes its own page (capped at 16)
-/// reached by a `Submenu` row and left by the "< Back" row. Separators and
+/// reached by a `Submenu` row and left by a `Back` row. Separators and
/// disabled items are kept as rows for visual fidelity; toggle states become
-/// `[x]`/`[ ]` label prefixes, exactly like the popup renderer they replace.
+/// the toolkit's leading marks (`toggle_mark`), which the menu draws as
+/// glyphs, as it draws the page and back chevrons — see `menu_row_glyphs`.
pub(crate) async fn fetch_tray_menu_pages(
conn: &zbus::Connection,
destination: &str,
@@ -154,12 +205,7 @@ pub(crate) async fn fetch_tray_menu_pages(
) {
let mut rows = Vec::new();
if let Some(parent_page) = parent {
- rows.push(MenuRow {
- label: "< Back".to_string(),
- enabled: true,
- separator: false,
- action: MenuRowAction::Back(parent_page),
- });
+ rows.push(back_row(parent_page));
}
// Reserve this page's slot before recursing so child pages number
// depth-first after it.
@@ -178,15 +224,8 @@ pub(crate) async fn fetch_tray_menu_pages(
});
continue;
}
- let mut label = if child.toggle_state == 1 {
- format!("[x] {}", child.label)
- } else if child.toggle_state == 0 {
- format!("[ ] {}", child.label)
- } else {
- child.label.clone()
- };
+ let label = format!("{}{}", toggle_mark(&child.toggle_type, child.toggle_state), child.label);
if !child.children.is_empty() && child.enabled && pages.len() < 16 {
- label = format!("{} >", label);
let child_page = pages.len();
pages.push(MenuPage { title: String::new(), rows: Vec::new() });
rows.push(MenuRow {
diff --git a/src/icons.rs b/src/icons.rs
index 13d4af0..8960968 100644
--- a/src/icons.rs
+++ b/src/icons.rs
@@ -1,95 +1,45 @@
//! Bundled cce-icons glyphs, tinted for the bar.
//!
-//! The stat modules draw a cce-icons glyph with their value superimposed on it,
-//! and the glyph has to wear the same color as the number — `module
-//! { text_color }`, the battery's accent when it is low or charging, the
-//! volume's `disabled_color` while muted. `cce_ui::upload_icon` cannot do that:
-//! a `Prim::Image` carries alpha but no color, and the bundled artwork is
-//! white. So this rasterizes the SVG itself, multiplies the pixels by the
-//! color, and uploads the result — one texture per `(name, px, color)`,
-//! cached for the life of the process (the key space is a handful of colors
-//! times one size, so nothing is ever freed).
+//! Every symbol the bar draws is a cce-icons glyph: the stat readouts' units,
+//! the OSD's, the tray's stand-in for an item that brings no icon of its own,
+//! and the in-surface menu's marks and page chevrons. A glyph has to wear
+//! the colour of the text beside it — `module { text_color }`, the battery's
+//! accent when it is low or charging, the volume's `disabled_color` while
+//! muted — and a `Prim::Image` carries alpha but no colour, so the tint is
+//! baked into the texture: `cce_ui::upload_icon_tinted`, cached per `(name,
+//! px, tint)` and keyed on the renderer epoch, so a reconnect (cce-ui
+//! repairs a lost transport by opening a new session, and a new renderer,
+//! around the same `Application`) re-uploads rather than drawing ids the
+//! new renderer never had. Until 2026-10-05 this module rasterized and
+//! uploaded the SVGs itself through a cache that outlived the renderer, and
+//! `renderer_init` had to empty it by hand.
//!
//! The artwork comes from the cce-icons crate via [`cce_ui::icons_dir`]
//! (`$CCE_ICONS_DIR`, else `~/projects/cce/cce-icons/svg`). A glyph that is
//! missing or unparsable yields `None` — the caller keeps its text readout as
-//! the fallback — and is logged once, since the miss is cached too.
+//! the fallback — and is logged once per name.
-use std::collections::HashMap;
+use std::collections::HashSet;
use std::sync::Mutex;
-/// `(name, px, tint)` → the uploaded texture, or `None` for a glyph that could
-/// not be loaded (the miss is cached too, so the warning is logged once).
-type Key = (String, u32, [u8; 3]);
-static CACHE: Mutex<Option<HashMap<Key, Option<(u32, u32, u32)>>>> = Mutex::new(None);
-
-/// Rasterize `<name>.svg` from cce-icons at `px` on its longer side, tinted
-/// to `rgb` (raw sRGB, like every text color here — uploaded images are
-/// sampled as sRGB), and upload it as a renderer texture. Returns the image
-/// id plus the pixel size for `PaintCtx::image`.
-pub(crate) fn tinted_icon(name: &str, px: u32, rgb: [u8; 3]) -> Option<(u32, u32, u32)> {
- let key = (name.to_string(), px, rgb);
- let mut guard = CACHE.lock().unwrap();
- let cache = guard.get_or_insert_with(HashMap::new);
- if let Some(hit) = cache.get(&key) {
- return *hit;
- }
- let loaded = (|| {
- let path = format!("{}/{name}.svg", cce_ui::icons_dir());
- let data = match std::fs::read(&path) {
- Ok(d) => d,
- Err(e) => {
- log::warn!("[icons] {path}: {e} — falling back to the text readout");
- return None;
+/// `<name>.svg` from cce-icons, `side` LOGICAL px on its longer side, tinted
+/// `rgb` (raw sRGB, like every text colour here, so a glyph matches the label
+/// beside it). Returns the image id and the glyph's logical size.
+pub(crate) fn glyph(name: &str, side: f32, rgb: [u8; 3]) -> Option<(u32, f32, f32)> {
+ let scale = cce_ui::scale::scale_factor();
+ let px = (side * scale).round().max(1.0) as u32;
+ match cce_ui::upload_icon_tinted(name, px, rgb) {
+ Some((image, w, h)) => Some((image, w as f32 / scale, h as f32 / scale)),
+ None => {
+ static WARNED: Mutex<Option<HashSet<String>>> = Mutex::new(None);
+ let mut warned = WARNED.lock().unwrap();
+ if warned.get_or_insert_with(HashSet::new).insert(name.to_string()) {
+ log::warn!(
+ "[icons] {}/{name}.svg could not be loaded — drawing without it",
+ cce_ui::icons_dir()
+ );
}
- };
- let (mut rgba, w, h) = cce_ui::rasterize_svg(&data, px).or_else(|| {
- log::warn!("[icons] {path}: unparsable SVG — falling back to the text readout");
None
- })?;
- // The artwork is white, so multiplying is tinting; anything the
- // glyph shades darker (a cut-through keyhole) stays proportionally
- // darker in the tint.
- for p in rgba.chunks_exact_mut(4) {
- for (c, &t) in p[..3].iter_mut().zip(rgb.iter()) {
- *c = ((*c as u16 * t as u16 + 127) / 255) as u8;
- }
}
- Some((cce_ui::vk::upload_rgba(rgba, w, h), w, h))
- })();
- cache.insert(key, loaded);
- loaded
-}
-
-/// The `[u8; 3]` a text color becomes for tinting — the same conversion
-/// `StyledLabel` applies to its color, so glyph and number match exactly.
-pub(crate) fn tint_of(color: [f32; 4]) -> [u8; 3] {
- [
- (color[0] * 255.0).round() as u8,
- (color[1] * 255.0).round() as u8,
- (color[2] * 255.0).round() as u8,
- ]
-}
-
-/// Forget every uploaded glyph, freeing its texture.
-///
-/// The cache holds **renderer** image ids, and a renderer does not outlive its
-/// session: `cce-ui`'s `window_runner` repairs a lost Wayland transport by
-/// opening a new session around the same `Application`, which rebuilds the
-/// renderer and with it the image table. The cached ids then name images that
-/// no longer exist, and a draw for an unknown id is skipped rather than
-/// reported — so a bar that reconnected came back with its numbers and no
-/// glyphs at all, until the process was restarted.
-///
-/// Called from `Application::renderer_init` when the renderer it is handed is a
-/// *replacement*; the first renderer of the process is the one the uploads
-/// queued from `new()` are waiting for, so dropping them there would only
-/// upload, destroy and re-upload the same five glyphs before the first frame.
-pub(crate) fn drop_textures() {
- let mut guard = CACHE.lock().unwrap();
- let Some(cache) = guard.as_mut() else { return };
- for (id, _, _) in cache.values().flatten() {
- cce_ui::vk::free_image(*id);
}
- cache.clear();
}
diff --git a/src/main.rs b/src/main.rs
index 1480e64..3ebbda4 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -78,7 +78,7 @@ pub struct SystemStats {
}
/// A bundled cce-icons glyph placed in the bar: `image` is the tinted
-/// texture from `icons::tinted_icon`, the rect is logical px, `alpha` the
+/// texture from `icons::glyph`, the rect is logical px, `alpha` the
/// ghosting under a superimposed readout. Retained like `text_prims` and
/// replayed by `display_list`, drawn before the text so the number sits on
/// the glyph.
@@ -437,8 +437,8 @@ struct StatusApp {
adjust_position_mode: bool,
/// Whether a renderer has been handed to this app yet. The first one is
/// the one `new()`'s glyph uploads are queued for; every later one is a
- /// reconnect, and the ids cached in `icons` name images that died with
- /// the renderer being replaced — see `renderer_init`.
+ /// reconnect, and the ids retained in `icon_prims` name images that died
+ /// with the renderer being replaced — see `renderer_init`.
seen_renderer: bool,
}
@@ -854,11 +854,25 @@ impl StatusApp {
// Wide enough for the longest row label — fixed minimums
// truncated window titles in the picker.
let tx_probe = ModuleContextMenu::PAD + 8.0;
+ // The glyph columns (see `menu_row_glyphs`): one at the
+ // left, reserved on every row of a page where any row
+ // has a mark or a back chevron so the labels share one
+ // edge, and a chevron at the right end of a page row.
+ let mark_side = (font_size * 0.95).round();
+ let chevron_side = (font_size * 0.8).round();
+ let glyph_gap = 6.0;
+ let lead_w = if menu.rows().iter().any(|r| !r.separator && menu_row_glyphs(r).0.is_some()) {
+ mark_side + glyph_gap
+ } else {
+ 0.0
+ };
let mut label_w: f32 = 0.0;
for page_row in menu.rows().to_vec() {
if !page_row.separator {
- let l = cce_ui::widget::StyledLabel::new_with_family(&mut self.font_system, &page_row.label, font_size, [0.0, 0.0, 0.0, 1.0], &font_family);
- label_w = label_w.max(l.w);
+ let (_, text, trail) = menu_row_glyphs(&page_row);
+ let l = cce_ui::widget::StyledLabel::new_with_family(&mut self.font_system, text, font_size, [0.0, 0.0, 0.0, 1.0], &font_family);
+ let trail_w = if trail.is_some() { glyph_gap + chevron_side } else { 0.0 };
+ label_w = label_w.max(lead_w + l.w + trail_w);
}
}
let menu_w = module_box_w.max(menu.min_w).max(label_w + 2.0 * tx_probe);
@@ -970,12 +984,39 @@ impl StatusApp {
self.menu_hover_rect =
Some((anim_x + 6.0, iy + 1.0, anim_w - 12.0, h - 2.0));
}
+ let tint = if row.enabled { text_u8 } else { dim_u8 };
+ let (lead, text, trail) = menu_row_glyphs(row);
+ if let Some(name) = lead {
+ let side = if lead == Some("chevron-left") { chevron_side } else { mark_side };
+ if let Some((image, gw, gh)) = crate::icons::glyph(name, side, tint) {
+ self.icon_prims.push(IconPrim {
+ image,
+ x: tx + (mark_side - gw) / 2.0,
+ y: iy + (h - gh) / 2.0,
+ w: gw,
+ h: gh,
+ alpha: 1.0,
+ });
+ }
+ }
+ if let Some(name) = trail {
+ if let Some((image, gw, gh)) = crate::icons::glyph(name, chevron_side, tint) {
+ self.icon_prims.push(IconPrim {
+ image,
+ x: anim_x + anim_w - (ModuleContextMenu::PAD + 8.0) - gw,
+ y: iy + (h - gh) / 2.0,
+ w: gw,
+ h: gh,
+ alpha: 1.0,
+ });
+ }
+ }
self.text_prims.push((
- row.label.clone(),
+ text.to_string(),
font_size,
- tx,
+ tx + lead_w,
iy + (h - font_size) / 2.0,
- if row.enabled { text_u8 } else { dim_u8 },
+ tint,
Some(font_family.clone()),
None,
None,
@@ -1758,14 +1799,14 @@ impl cce_ui::engine::Application for StatusApp {
/// A reconnect is a new session around the SAME app (cce-ui's
/// `window_runner` repairs a lost transport rather than restarting the
- /// process), and the renderer is rebuilt with it — so the glyph textures
- /// `icons::tinted_icon` cached ids for no longer exist. A draw for an
- /// unknown image id is skipped silently, which is why a reconnected bar
- /// kept its numbers and lost every glyph. Drop the cache and rebuild, so
- /// the next layout uploads into the renderer just created.
+ /// process), and the renderer is rebuilt with it — so the glyph ids the
+ /// retained `icon_prims` hold name nothing. A draw for an unknown image
+ /// id is skipped silently, which is why a reconnected bar once kept its
+ /// numbers and lost every glyph. The upload cache follows the renderer
+ /// epoch by itself (`icons::glyph`); rebuilding the layout is what asks
+ /// it again, uploading into the renderer just created.
fn renderer_init(&mut self, _renderer: &mut cce_ui::vk::VkRenderer) {
if std::mem::replace(&mut self.seen_renderer, true) {
- crate::icons::drop_textures();
self.needs_rebuild = true;
}
}
@@ -2040,25 +2081,23 @@ impl cce_ui::engine::Application for StatusApp {
// Page 0 is the root built below; the mode page is
// the only extra, so it is always page 1.
rows.insert(0, MenuRow {
- label: format!("{} >", self.layout),
+ label: self.layout.clone(),
enabled: true,
separator: false,
action: MenuRowAction::Submenu(1),
});
- let mut mode_rows = vec![MenuRow {
- label: "< Back".to_string(),
- enabled: true,
- separator: false,
- action: MenuRowAction::Back(0),
- }];
+ let mut mode_rows = vec![cloud::back_row(0)];
for mode in MODES {
let current = mode == self.layout;
+ // A radio group: the toolkit's on and off
+ // marks, drawn as the circle glyphs.
+ let mark = if current {
+ cce_ui::widget::context_menu::MARK_ON
+ } else {
+ cce_ui::widget::context_menu::MARK_OFF
+ };
mode_rows.push(MenuRow {
- label: format!(
- "{} {}",
- if current { "[x]" } else { "[ ]" },
- mode
- ),
+ label: format!("{mark}{mode}"),
// The current mode is a marker, not a
// target — disabled rows never match a
// click.
@@ -2303,6 +2342,63 @@ fn main() {
mod tests {
use super::*;
+ /// The bar's menus draw their marks and page turns as cce-icons glyphs,
+ /// by the toolkit's context-menu conventions, and never spell them as
+ /// characters: a DBusMenu toggle becomes a leading mark, a submenu row
+ /// ends in chevron-right, a back row starts with chevron-left.
+ #[test]
+ fn menu_marks_and_page_turns_are_glyphs() {
+ use cce_ui::widget::context_menu::{MARK_CHECK, MARK_OFF, MARK_ON};
+ assert_eq!(toggle_mark("checkmark", 1), MARK_CHECK);
+ assert_eq!(toggle_mark("checkmark", 0), "");
+ assert_eq!(toggle_mark("radio", 1), MARK_ON);
+ assert_eq!(toggle_mark("radio", 0), MARK_OFF);
+ assert_eq!(toggle_mark("", -1), "");
+
+ let row = |label: &str, action: MenuRowAction| MenuRow {
+ label: label.to_string(),
+ enabled: true,
+ separator: false,
+ action,
+ };
+ assert_eq!(
+ menu_row_glyphs(&row(&format!("{MARK_CHECK}Sync"), MenuRowAction::Item(3))),
+ (Some("check"), "Sync", None)
+ );
+ assert_eq!(
+ menu_row_glyphs(&row("Tiled", MenuRowAction::Submenu(1))),
+ (None, "Tiled", Some("chevron-right"))
+ );
+ assert_eq!(menu_row_glyphs(&back_row(0)), (Some("chevron-left"), "Back", None));
+ assert_eq!(menu_row_glyphs(&row("Quit", MenuRowAction::Item(9))), (None, "Quit", None));
+ }
+
+ /// A tray item with no icon of its own stands in a cce-icons glyph,
+ /// guessed from its icon name, and every one it can name is in the set.
+ #[test]
+ fn a_tray_item_without_an_icon_is_a_glyph() {
+ use modules::tray_fallback_glyph as g;
+ assert_eq!(g(None), "gear");
+ assert_eq!(g(Some("audio-volume-muted")), "volume-muted");
+ assert_eq!(g(Some("audio-volume-high")), "volume");
+ assert_eq!(g(Some("network-wireless")), "wifi");
+ assert_eq!(g(Some("battery-full")), "battery");
+ assert_eq!(g(Some("bluetooth-active")), "bluetooth");
+ assert_eq!(g(Some("mail-unread")), "mail");
+ assert_eq!(g(Some("discord")), "chat");
+ assert_eq!(g(Some("steam_tray_mono")), "gamepad");
+ assert_eq!(g(Some("dropboxstatus-idle")), "cube");
+ assert_eq!(g(Some("something-else")), "gear");
+ // The workspace's own icon set, where the build has it.
+ let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../cce-icons/svg");
+ if dir.is_dir() {
+ for name in ["gear", "volume-muted", "volume", "wifi", "battery", "bluetooth", "mail", "chat",
+ "gamepad", "cube", "check", "circle", "circle-outline", "chevron-left", "chevron-right"] {
+ assert!(dir.join(format!("{name}.svg")).is_file(), "{name}.svg is not in cce-icons");
+ }
+ }
+ }
+
#[test]
fn tray_clicks_report_screen_points_below_the_bar() {
let listing = concat!(
diff --git a/src/modules.rs b/src/modules.rs
index b90ebcb..250f43e 100644
--- a/src/modules.rs
+++ b/src/modules.rs
@@ -5,7 +5,7 @@ use cce_ui::widget::StyledLabel as Label;
use crate::{
RectWidget, RoundedBox, SystemStats, TrayItem,
- TrayIconBounds, make_text_buffer,
+ TrayIconBounds,
};
/// Vertical offset that centers a text run in a box `box_h` tall. The engine
@@ -286,10 +286,9 @@ impl StatusModule for ClockModule {
/// A stat module's readout: a cce-icons glyph with its value beside it —
/// the glyph IS the unit, so the number is bare ("87" next to the battery,
-/// not "Bat 87%"). The glyph is tinted the readout's color; see `icons.rs`
-/// for why the tint is done here rather than through `cce_ui::upload_icon`.
+/// not "Bat 87%"). The glyph is tinted the readout's color; see `icons.rs`.
///
-/// The pre-glyph text form rides along as the FALLBACK: `tinted_icon` returns
+/// The pre-glyph text form rides along as the FALLBACK: `icons::glyph` returns
/// `None` when the icon set is missing or unparsable, and a readout that
/// silently loses its glyph would be a bare number nobody can attribute — so
/// it degrades to the old "Cpu 45%" instead.
@@ -313,10 +312,7 @@ impl IconReadout {
/// `module { icon_size }` on the longer side. `None` = no glyph, text
/// fallback.
fn glyph(&self) -> Option<(u32, f32, f32)> {
- let scale = cce_ui::scale::scale_factor();
- let px = (crate::read_icon_size_from_config() * scale).round().max(1.0) as u32;
- let (image, w, h) = crate::icons::tinted_icon(self.icon, px, crate::icons::tint_of(self.color))?;
- Some((image, w as f32 / scale, h as f32 / scale))
+ crate::icons::glyph(self.icon, crate::read_icon_size_from_config(), cce_ui::icon_tint(self.color))
}
/// The number's font size — `module { icon_font_size }`, else the
@@ -389,7 +385,7 @@ impl IconReadout {
ns,
nx,
centered_text_y(bar_h, ns),
- crate::icons::tint_of(self.color),
+ cce_ui::icon_tint(self.color),
Some(font_family.to_string()),
None,
None,
@@ -616,13 +612,14 @@ impl IconStat for BatteryModule {
None => (100, false),
};
// Accent while charging or nearly flat; charging also swaps in the
- // bolt glyph, the icon form of the text readout's "⚡" prefix.
+ // bolt glyph. The text fallback (no icon set) says so in a word, as
+ // wide as "Bat" so the stable slot still holds it.
let color = if !charging && cap > 10 { normal_color } else { color::TEXT_ACCENT };
Some(IconReadout {
icon: if charging { "battery-charging" } else { "battery" },
number: Some(cap.to_string()),
color,
- fallback: format!("{} {cap}%", if charging { "⚡" } else { "Bat" }),
+ fallback: format!("{} {cap}%", if charging { "Chg" } else { "Bat" }),
fallback_template: "Bat 100%",
})
}
@@ -779,14 +776,14 @@ impl StatusModule for TrayModule {
_w: f32,
_stats: &Option<SystemStats>,
_title: &str,
- font_system: &mut FontSystem,
- font_family: &str,
- font_size: f32,
+ _font_system: &mut FontSystem,
+ _font_family: &str,
+ _font_size: f32,
_normal_color: [f32; 4],
bar_h: f32,
scale_factor: f64,
- text_prims: &mut Vec<crate::TextPrim>,
- _icon_prims: &mut Vec<crate::IconPrim>,
+ _text_prims: &mut Vec<crate::TextPrim>,
+ icon_prims: &mut Vec<crate::IconPrim>,
_rects: &mut Vec<RectWidget>,
overlay_rects: &mut Vec<RectWidget>,
tray_items: &HashMap<String, TrayItem>,
@@ -898,60 +895,60 @@ impl StatusModule for TrayModule {
}
if !drawn_pixmap {
- let symbol = if let Some(ref name) = item.icon_name {
- let name_lower = name.to_lowercase();
- if name_lower.contains("volume") || name_lower.contains("sound") || name_lower.contains("audio") {
- if name_lower.contains("mute") { "🔇" } else { "🔊" }
- } else if name_lower.contains("wifi") || name_lower.contains("network") || name_lower.contains("ethernet") {
- "📶"
- } else if name_lower.contains("battery") {
- "🔋"
- } else if name_lower.contains("bluetooth") {
- "ᛒ"
- } else if name_lower.contains("mail") || name_lower.contains("envelope") {
- "✉"
- } else if name_lower.contains("chat") || name_lower.contains("messenger") || name_lower.contains("discord") || name_lower.contains("slack") || name_lower.contains("telegram") {
- "💬"
- } else if name_lower.contains("steam") || name_lower.contains("game") {
- "🎮"
- } else if name_lower.contains("dropbox") {
- "📦"
- } else {
- "⚙"
- }
- } else {
- "⚙"
- };
-
- // Measure the throwaway buffer for centering, then emit a text prim.
- let buf = make_text_buffer(font_system, symbol, font_size, font_family);
- let scale = cce_ui::scale::scale_factor();
- let tw = buf.layout_runs().next().map(|r| r.line_w).unwrap_or(0.0) / scale;
- let tx = icon_x + (icon_size - tw) / 2.0;
- // Plain centering within the icon box: the box itself already
- // carries the `text_raise` lift, and `centered_text_y` here
- // would apply it a second time.
- let ty = icon_y + (icon_size - font_size) / 2.0;
- text_prims.push((
- symbol.to_string(),
- font_size,
- tx,
- ty,
- [
- (color::TEXT_ACCENT[0] * 255.0) as u8,
- (color::TEXT_ACCENT[1] * 255.0) as u8,
- (color::TEXT_ACCENT[2] * 255.0) as u8,
- ],
- Some(font_family.to_string()),
- None,
- None,
- None,
- ));
+ // An item that brought neither a pixmap nor an icon the theme
+ // resolves: stand in a cce-icons glyph for what its icon NAME
+ // says it is, in the accent colour. The item's own art is the
+ // app's and is drawn above whenever there is any; this is
+ // cce's, so it is a glyph, never an emoji. Without the icon
+ // set the slot is left empty — a 16 px slot has no room for a
+ // word, and the item still answers clicks and names itself in
+ // its menu.
+ let glyph_name = tray_fallback_glyph(item.icon_name.as_deref());
+ if let Some((image, gw, gh)) =
+ crate::icons::glyph(glyph_name, icon_size, cce_ui::icon_tint(color::TEXT_ACCENT))
+ {
+ icon_prims.push(crate::IconPrim {
+ image,
+ x: icon_x + (icon_size - gw) / 2.0,
+ y: icon_y + (icon_size - gh) / 2.0,
+ w: gw,
+ h: gh,
+ alpha: 1.0,
+ });
+ }
}
}
}
}
+/// The cce-icons glyph a tray item with no icon of its own is drawn as,
+/// guessed from its icon NAME (`None`: it sent none) — the gear for anything
+/// unrecognised.
+pub(crate) fn tray_fallback_glyph(icon_name: Option<&str>) -> &'static str {
+ let Some(name) = icon_name else { return "gear" };
+ let name = name.to_lowercase();
+ let has = |words: &[&str]| words.iter().any(|w| name.contains(w));
+ if has(&["volume", "sound", "audio"]) {
+ if name.contains("mute") { "volume-muted" } else { "volume" }
+ } else if has(&["wifi", "network", "ethernet"]) {
+ "wifi"
+ } else if name.contains("battery") {
+ "battery"
+ } else if name.contains("bluetooth") {
+ "bluetooth"
+ } else if has(&["mail", "envelope"]) {
+ "mail"
+ } else if has(&["chat", "messenger", "discord", "slack", "telegram"]) {
+ "chat"
+ } else if has(&["steam", "game"]) {
+ "gamepad"
+ } else if name.contains("dropbox") {
+ "cube"
+ } else {
+ "gear"
+ }
+}
+
pub struct LightSourceModule;
pub(crate) fn get_light_source_pos_from_config() -> f32 {
diff --git a/src/osd.rs b/src/osd.rs
index 4c9bbf7..906a19e 100644
--- a/src/osd.rs
+++ b/src/osd.rs
@@ -322,9 +322,6 @@ pub(crate) struct OsdApp {
generation: u64,
sender: calloop::channel::Sender<OsdEvent>,
font_system: FontSystem,
- /// See `StatusApp::seen_renderer`: a later renderer is a reconnect, and
- /// the cached glyph ids died with the old one.
- seen_renderer: bool,
width: u32,
height: u32,
/// Where the surface goes, worked out once at startup: a slider that
@@ -373,7 +370,6 @@ impl cce_ui::engine::Application for OsdApp {
generation: 0,
sender,
font_system: cce_ui::create_font_system(),
- seen_renderer: false,
width,
height,
placement,
@@ -488,9 +484,9 @@ impl cce_ui::engine::Application for OsdApp {
// still says which level it is by moving when that key is pressed.
let raise = crate::read_text_raise_from_config() * k;
let mut x = padding;
- let glyph_px = (crate::read_icon_size_from_config() * k * scale as f32).round().max(1.0) as u32;
- if let Some((image, gw, gh)) = crate::icons::tinted_icon(self.level.icon(), glyph_px, crate::icons::tint_of(color)) {
- let (gw, gh) = (gw as f32 / scale as f32, gh as f32 / scale as f32);
+ // The glyph is looked up every frame: the cache is keyed on the
+ // renderer epoch, so a reconnect's new renderer gets its own upload.
+ if let Some((image, gw, gh)) = crate::icons::glyph(self.level.icon(), crate::read_icon_size_from_config() * k, cce_ui::icon_tint(color)) {
pc.image(
image,
Rect { x, y: (h - gh) / 2.0 - raise, width: gw, height: gh },
@@ -515,7 +511,7 @@ impl cce_ui::engine::Application for OsdApp {
right - tw,
(h - font_size) / 2.0 - raise,
font_size,
- crate::icons::tint_of(color),
+ cce_ui::icon_tint(color),
Some(font_family.clone()),
None,
cce_ui::scene::paint::TextAttrs { italic: false, weight },
@@ -537,12 +533,6 @@ impl cce_ui::engine::Application for OsdApp {
Some(pc.finish())
}
- fn renderer_init(&mut self, _renderer: &mut cce_ui::vk::VkRenderer) {
- if std::mem::replace(&mut self.seen_renderer, true) {
- crate::icons::drop_textures();
- }
- }
-
fn display_list_text(&self) -> bool {
true
}