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

commit282ab9e004966215dbff12fbefc1fe886cb77567
parent0c593f24b1
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-05 22:11
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
     }