status bar
git clone https://git.lucas.co/cce-status-interface.git
CLAUDE.md (34.1K)
1 # CLAUDE.md
2
3 This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
5 ## What this is
6
7 `cce-status-interface` is the status bar of the `cce` Wayland desktop environment. It
8 is one crate in the multi-repo `cce` workspace — see `../cce-compositor/WORKSPACE.md` for
9 the workspace layout, the multi-repo git rules (commit here, never `git init` at the root),
10 and the `cce-ui` toolkit this app is built on. This crate is deliberately small:
11 `src/main.rs` (the `StatusApp` application, layout/input, launcher daemon),
12 `src/modules.rs` (the `StatusModule` trait and its eleven implementations),
13 `src/config.rs` (pointer-first config readers), `src/tray.rs` (SNI host),
14 `src/cloud.rs` (menu page building), `src/stats.rs` (system stat readers —
15 numbers, not strings; the modules do the formatting), `src/icons.rs` (tinted
16 cce-icons glyph textures), `src/listeners.rs` (status/switcher socket tasks),
17 `src/osd.rs` (the volume/brightness slider).
18
19 ## Build, test, run
20
21 ```sh
22 cargo build --release # standalone build (or `-p cce-status-interface` from the workspace root)
23 cargo test # 50 tests: main.rs (parsers, droplet geometry, menu glyphs), stats.rs, config.rs, tray.rs, osd.rs, the tray bridge's x11.rs and title.rs
24 make install # release build, then `ccebuild install --no-build cce-status-interface`
25 ```
26
27 Running it requires a live cce compositor session (`$WAYLAND_DISPLAY` plus the cce
28 sockets); there is no meaningful headless mode.
29
30 CI (`.github/workflows/ci.yml`, since 2026-10-08) builds every target and runs the tests,
31 and runs clippy with warnings as errors, on the crate alone (its git pin of cce-ui, not
32 the workspace's). The crate allows only cce-ui's two house-style lints (too many
33 arguments, complex types, in `Cargo.toml`); anything else clippy reports is fixed —
34 locally, `cargo clippy -p cce-status-interface --all-targets -- -D warnings`.
35
36 ## `cce-xembed-tray` — legacy X11 tray icons in the bar
37
38 A second binary (`src/bin/cce-xembed-tray/`, run by `cce-xembed-tray.service`)
39 bridges the **XEmbed system tray** into the SNI tray `tray.rs` hosts. X11 apps
40 older than StatusNotifierItem — Wine and Proton programs above all — dock their
41 icons with whichever X client owns `_NET_SYSTEM_TRAY_S0`; with no owner, Wine
42 shows a fallback window of its own holding the icons, which is how a blank white
43 window came to sit beside Ubisoft Connect (2026-09-26). The bridge owns that
44 selection and, per docked icon:
45
46 - **reparents the icon window into a container** — an override-redirect window
47 with `WM_CLASS` `cce-xembed-tray`, which the compositor never shows
48 (`cce-compositor/src/server/xwayland_override_redirect.rs`,
49 `is_xembed_tray_container`; keep the class in step). X needs it mapped or
50 the icon never draws. It also gets an **empty input region**, so X never
51 routes the pointer into it: a hidden container stacked over a real X window
52 would otherwise swallow that window's clicks.
53 - **reads its pixels** with `GetImage` whenever X Damage reports a redraw, in
54 the ARGB visual it advertises through `_NET_SYSTEM_TRAY_VISUAL` (so icons keep
55 their transparency), and publishes them as `IconPixmap` — skipped while the
56 icon is still fully transparent, so an undrawn icon is never an empty slot.
57 - **forwards clicks** as `SendEvent` button presses to the icon: `Activate` is
58 button 1, `ContextMenu` button 3 (the app draws its own menu, which is why
59 there is deliberately no `Menu` property — its presence makes the bar fetch a
60 D-Bus menu instead), `Scroll` buttons 4-7. An app opens its menu at the
61 root position the event reports, so the click is placed at the host's
62 Activate/ContextMenu point — scaled into X pixels by `Xft.dpi`/96 — and the
63 container is moved under it first. That point is only right because the
64 bar sends SCREEN coordinates, as SNI asks: its segment's position from
65 `ccectl windows --json` plus the pointer's x, at the bar's bottom edge
66 (`tray_click_point`). It sent segment-local ones until 2026-09-26, and
67 Ubisoft Connect's menu opened ~700 px from its icon. With no point (0,0)
68 the container stays where it docked, at the top-right of the X screen.
69 - **looks after the popup the click opens** (`PopupWatch`): the next
70 override-redirect window to map within 1.5 s of a forwarded click is that
71 click's popup. Windows tray apps open their menu UPWARD from the pointer
72 (the taskbar is at the bottom there) and clamp it to the screen top, so on
73 a top bar it lands over the icon — a popup reaching above the bar's bottom
74 edge is moved down to it. The app keeps working in the moved window, since
75 X reports pointer positions relative to the window. Wine's `_NET_WORKAREA`
76 does not help: Ubisoft Connect places its own menu, and ignored a work area
77 that excluded the bar. The popup is also closed on the compositor's
78 `clickaway` status topic (a press on no X11 surface): the bridge addresses
79 it a press just outside itself, which the app — holding the mouse capture
80 while its menu is up — reads as a click outside. Xwayland never delivers a
81 press on a Wayland window, so before this only a click on one of the app's
82 own X windows closed the menu.
83 **Tooltips are not the popup** (`is_tooltip`). A forwarded click makes
84 Wine's `explorer.exe` — the prefix's tray host — show the icon's tooltip,
85 and Wine gives it the same window type (`DIALOG`) and Win32 styles as the
86 app's menu; only its owner tells them apart. So a window from a Wine
87 plumbing process (`title::is_wine_plumbing`: a `WINEPREFIX` process that
88 `is_wine_program` does not count as an app) is skipped, as is one typed
89 `_NET_WM_WINDOW_TYPE_TOOLTIP`. Until 2026-09-28 the watch took whichever
90 mapped LAST, so a tooltip mapping over an open menu replaced it and the
91 click-away closed the tooltip, leaving the menu up. A headless shadow never
92 shows the tooltip (Wine's tooltip checks the real X pointer); reproduce it
93 by mapping explorer's `DIALOG` window override-redirect yourself while a
94 test app's menu is open.
95 - **names it after its app** (`title.rs`), since icon windows are untitled
96 and their WM_CLASS names the toolkit (`steam_proton` for every Proton
97 program). A Wine icon is not even the app's window: Wine's tray lives in the
98 prefix's `explorer.exe`, which creates every program's icon windows. So the
99 name is the most common title among the top-level windows of the programs
100 sharing the icon's `WINEPREFIX` — drive-letter exes outside `C:\windows\`,
101 which leaves out explorer, Proton's `steam.exe` shim and xalia — or of the
102 icon's own process for a native app. Ubisoft Connect titles its windows
103 "Ubisoft Connect" even while hidden in the tray. With no titled window yet,
104 the exe's stem stands in and the lookup is retried at 2, 5, 10 and 30 s.
105
106 Each item is its own session-bus connection registering by object **path**, so
107 the watcher records its unique name — the only kind of name whose disappearance
108 `spawn_status_tray` notices. Closing the connection is the whole of
109 unregistering. On SIGTERM the bridge hands every icon back to the root window,
110 unmapped, which XEmbed clients read as "the tray is gone". Another tray already
111 owning the selection is waited out, not displaced.
112
113 Verify it in a shadow with `cce-shadow start --xwayland` and
114 `cce-compositor/verify/clients`' `xembed-icon`, under `dbus-run-session` so the
115 test bar and bridge never reach the live session's bus.
116
117 ## Process model (the most important thing to know)
118
119 One binary, four modes, selected by CLI args in `main()`:
120
121 - **No args — launcher daemon.** Spawns one child process per module
122 (`--module window`, `--module clock`, …), polls every 500ms and restarts crashed
123 children with exponential backoff (500ms doubling to 30s; 30s of healthy uptime
124 resets it). This is the normal production mode: each module is its own process and
125 its own Wayland surface.
126 - **`--module <name>`** — a single-module bar segment. Valid names: `window`, `tray`,
127 `stats`, `cpu`, `memory`, `brightness`, `volume`, `wifi`, `battery`, `clock`,
128 `light_source` (the daemon launches `stats`, not the six it combines).
129 - **`--trigger-switcher`** — one-shot: writes `trigger` to the switcher socket of the
130 running instance and exits (used as a keybinding target).
131 - **`--osd <level>`** — the volume/brightness slider (below). Started by the
132 launcher daemon, never supervised: it exits on its own.
133
134 (The old `--monolithic` all-modules-in-one-window mode is gone, along with the
135 app-side super+drag module reordering that only made sense there.)
136
137 The compositor places each segment by its Wayland `app_id`, computed in
138 `StatusApp::get_app_id()`: `cce-status-{side}-{name}` (e.g. `cce-status-left-window`). If
139 `/tmp/cce-status-interface-{WAYLAND_DISPLAY}.sock` exists, the `cce-status-interface-`
140 prefix is used instead — keep both spellings in mind when matching app_ids. A module's
141 side comes from the config (`get_module_side`, which also maps snap positions like
142 `top-left`/`bottom-right` to left/right); default is `window` → left, everything else →
143 right. **`light_source` is the exception**: it short-circuits ahead of all of
144 that and takes its side from `/window_manager/light_source_position` — the
145 angle points at a side — so a `layout { status_bar light_source=… }` entry is
146 read and then ignored, which looks like the key not working.
147
148 ## The volume/brightness slider (`osd.rs`)
149
150 A transient bubble — glyph, track, number — that appears when either level
151 moves and leaves `osd { timeout_ms }` (1500) after the last change. It is a
152 layer-shell surface on the **OVERLAY** layer, which the compositor stacks
153 above `layers.fullscreen`, so it shows over fullscreen games and video where
154 the bar is hidden. Keyboard interactivity is `None` (a fullscreen window
155 must never yield focus to it) and the input region is empty (clicks pass
156 through).
157
158 - **Trigger**: the launcher daemon runs `spawn_level_watchers` — the same
159 fast path the readouts use, now taking a callback (`LevelChange`) instead
160 of a calloop sender — so a key, `brightnessctl` in a terminal or a mixer
161 all show it. Each change is forwarded as one line (`brightness 40`,
162 `volume 55 0`, `volume - 1`) to the slider's instance socket
163 (`/tmp/cce-status-osd-<display>.sock`, `cce_ui::ipc::instance`), or, with
164 nothing listening, `--osd <line>` is spawned. Queued changes collapse to
165 the newest.
166 - **It exits rather than hides**: a mapped surface, even fully transparent,
167 keeps a fullscreen window off direct scanout. It gives up its socket
168 BEFORE the close fade, so a change during the fade starts a fresh slider
169 instead of being answered and dropped.
170 - **Placement** is worked out once, at startup (`placement`,
171 `beside_segment`): by default (`osd { position "status" }`) it sits beside
172 the `stats` segment — else the lone `volume`/`brightness` one — read from
173 `ccectl windows --json`, centered on it and `module { spacing }` away:
174 below a segment in the top half of its output, above one in the bottom
175 half, clamped on screen. The rect is converted to margins on the output
176 it lies in (`ccectl outputs --json`), but the layer surface is created
177 with no output, so on a multi-monitor setup the compositor's choice of
178 output must be the segment's for it to line up. `"top"`/`"bottom"`/
179 `"center"` center it on the screen instead, `osd { margin }` (96) from
180 that edge; an unlisted segment falls back to `"bottom"`.
181 - Looks: the bar's `module { }` box (droplet, bevel or plain), colors, font
182 and glyphs, scaled by `osd { height }` (default 1.5 × bar height) over
183 the bar height; `osd { width }` (260). `osd { enabled false }` turns it
184 off. Muted reads in `disabled_color`.
185 - Verify in a shadow by running `--osd volume 55 0` directly (export
186 `CCE_ICONS_DIR`), or the launcher plus a real level change — the watchers
187 read the machine's real backlight and sink, which the shadow shares.
188
189 ## Rendering
190
191 The app implements `cce_ui::engine::Application` on the **`display_list()` paint path**
192 (Phase 6ak) — the legacy `view*()`/`text_items()` methods are gone. The flow:
193
194 1. `rebuild_layout()` runs the two-pass module layout — for each module first
195 `StatusModule::width()`, then `StatusModule::render()` — filling retained buffers on
196 `StatusApp`: `rects`, `rounded_boxes`, `text_prims`
197 (the `TextPrim` tuple type; build them with `draw_label()` from a
198 `cce_ui::widget::StyledLabel`), `icon_prims` (`IconPrim` — a tinted
199 cce-icons glyph texture at a logical rect), plus `input_regions`,
200 `module_bounds`, `tray_item_bounds`.
201 2. `display_list()` replays those buffers into a `PaintCtx` each frame (and triggers
202 `rebuild_layout()` when size/scale changed or `needs_rebuild` is set).
203 `overlay_quads()` remains a separate on-top pass (used for drag feedback).
204
205 **Module boxes hug their content.** `StatusModule::width()` is the STABLE slot
206 width — widest-plausible templates for the stat modules, 24px title buckets for
207 the window module — and it alone sizes the surface, which is what keeps the
208 compositor's configure-echo jitter out of the loop; `content_width()` (default:
209 `width()`) measures the live text, and the drawn bubble takes that width,
210 centered in the slot, so the padding on each side of the text is
211 `module { padding }` rather than padding-plus-template-surplus. The drawn width
212 is eased over ~120ms in `tick` (`bubble_w_now`/`bubble_w_target` — one pair of
213 fields, sound because a `StatusApp` hosts exactly one module), and the
214 in-surface menu expansion grows out of `collapsed_box` — the bubble actually
215 drawn — not out of the slot, so the box-grows-into-the-menu continuity holds.
216
217 Orientation is dynamic: `is_vertical()` compares the surface size against the
218 configured bar thickness; every module renders along one axis using `bar_h`/`coord`
219 accordingly.
220
221 **The stat modules read out as a glyph with the number beside it, not a
222 label.** `cpu`, `memory`, `brightness`, `volume`, `wifi` and `battery` are
223 `IconStat` implementations: each names a cce-icons glyph, the bare number
224 and a color, and `IconReadout` draws the glyph (tinted that color, at
225 `module { icon_alpha }`) with the number `module { icon_gap }` to its right
226 — no unit symbol, since the glyph IS the unit ("87" beside the battery, not
227 "Bat 87%"). **The launcher runs them as ONE segment**, `stats`
228 (`StatsModule`): every readout in a single bubble, `module { icon_spacing }`
229 apart, in the order cpu, memory, brightness, volume, wifi, battery — the
230 order the compositor's `RIGHT_ORDER` gave the five separate segments, with
231 wifi (added 2026-10-02, after that order) beside volume, and `stats` has
232 its own slot there between `tray` and `clock` (cce-window-manager
233 2026-09-16; `wifi` joined it between `volume` and `battery` in
234 cce-window-manager@1279fc5). The single names stay valid `--module` values for a bar
235 that wants them apart; a blanket `impl<T: IconStat> StatusModule for T`
236 lays a lone readout out through the same `readouts_width` /
237 `render_readouts` the combined segment uses. (Superimposing the number on a
238 ghosted glyph, with a bold weight and a dark pocket under the digits, was
239 tried first on 2026-09-16 and replaced the same day by the side-by-side
240 form; `icon_weight` survives as an opt-in, the pocket is gone.) The muted
241 sink swaps to `volume-muted`, the charging battery to `battery-charging`;
242 the battery also keeps its accent color while charging or under 10%. Wifi
243 is the link's signal strength, and a disconnected adapter reads like a
244 muted sink: `wifi-off`, dimmed, no number. A
245 reader with nothing (no battery, no backlight, no pactl, no wireless
246 adapter) returns `None`
247 and drops out of the row — a lone module with nothing has width 0, i.e. it
248 is hidden rather than an empty bubble; a reader that answers without a
249 number (cpu with no /proc/stat, a sink with no level) draws the glyph
250 alone.
251
252 The glyphs come from the **cce-icons** crate via `cce_ui::icons_dir()`
253 (`$CCE_ICONS_DIR`, else `~/projects/cce/cce-icons/svg`), through
254 `icons::glyph` — `cce_ui::upload_icon_tinted` at a logical size: a
255 `Prim::Image` has alpha and no color, and the artwork is white, so the
256 readout's raw-sRGB color is baked into the texture, cached per `(name, px,
257 color)` and keyed on the renderer epoch. A reconnect (cce-ui repairs a lost
258 transport by opening a new session around the same `Application`) rebuilds
259 the renderer and its image table; the cache then misses and re-uploads, and
260 `renderer_init` forces a layout rebuild so the retained `icon_prims` stop
261 naming the old renderer's ids — a draw for an unknown id is skipped rather
262 than reported, which is how a reconnected bar once kept its numbers and lost
263 every glyph. (Until 2026-10-05 `icons.rs` rasterized and uploaded the SVGs
264 itself, through a cache `renderer_init` had to empty by hand.) A
265 glyph that fails to load falls back to the old text readout ("Cpu 45%",
266 "Chg 87%" for a charging battery), so
267 a bar started without the icon set is still attributable; **a shadow session
268 needs `CCE_ICONS_DIR` exported into the spawn**, its HOME being elsewhere,
269 exactly as it needs `CCE_FONTS_DIR`. Slot stability holds as before: the
270 stable width sizes every number at the "100" template, so a value crossing
271 a digit boundary never resizes the surface, and the bubble eases to the
272 live row. `memory` reads as a percentage of the total in use (used = total
273 less free, buffers and page cache) since 2026-09-16 — the "Mem 10/62G"
274 gigabyte form went with the label.
275
276 ## Events and IPC
277
278 `update()` consumes `CustomEvent`s sent over a calloop channel from tokio tasks spawned
279 in `new()` — which tasks run depends on the selected module, so a clock process doesn't
280 listen to tray D-Bus, etc.:
281
282 - **Compositor status feed** (`spawn_status_listener`): connects to
283 `/tmp/cce-status[-interface]-{WAYLAND_DISPLAY}.sock` and subscribes, one task
284 per topic, line-oriented — `layout` and `title` only in the process that owns
285 the window module, `dismiss` in every one. Reconnects back off
286 1s doubling to 30s, reset the moment a connection delivers a line: a
287 compositor that does not know a topic drops the subscription on sight, so a
288 flat retry made a bar running ahead of its compositor reconnect once a second
289 from every module process, forever. The compositor also offers `modifiers`
290 (`status_server.rs`), but nothing here subscribes to it and the match over
291 topics ends in `unreachable!()` — adding a subscription means adding its arm
292 first. (The old `viewport` topic is gone with the viewport-tag feature.)
293 - **System stats** (`spawn_system_stats`): `/proc/stat`, `/proc/meminfo`,
294 `/sys/class/power_supply/BAT*`, `/sys/class/backlight`, `pactl` for volume/mute,
295 and `/sys/class/net/*/wireless` + `/proc/net/wireless` for wifi (`read_wifi`:
296 the link is connected when its `operstate` is `up`, and the quality column is
297 on cfg80211's 0..=70 scale, so 70 is 100%).
298 `SystemStats` carries numbers (`cpu_pct`, `memory`, `battery: (capacity,
299 charging)`, `volume: (level, muted)`, `brightness`, `wifi: (signal,
300 connected)`), each `Option` where
301 the source can be absent; only the clock arrives pre-formatted. The loop
302 reads only what its segment paints (`module_reads`, the same split as
303 `stats_signature`) — the `clock` segment reads nothing but the time, and
304 wakes once a minute, on the minute. Until 2026-10-05 every stats segment
305 ran the whole poll, two `pactl` spawns included, every second. A CPU
306 reading reaches the bar only when it moves by `CPU_STEP` points or has been
307 held back `CPU_HOLD_S`, so idle jitter does not redraw the segment (and
308 re-bake the compositor's blur behind it) each second. The loop is
309 once a second, which is fine for a clock or a load average and far too slow
310 for the two values a KEYPRESS moves — so the backlight and the sink have a
311 fast path beside it (`spawn_level_watchers`), each pushing its own
312 one-field event (`BrightnessUpdated` / `VolumeUpdated`) that patches
313 `stats` in place. `watch_brightness` blocks in `poll(POLLPRI)` on
314 `actual_brightness`, which the kernel's `backlight_generate_event`
315 `sysfs_notify`s on every write to `brightness` (measured ~20 ms after a
316 `brightnessctl set`, nothing at rest); it falls back to the old 100 ms poll
317 only when that cannot be opened. `watch_volume` follows `pactl subscribe` and
318 re-reads only on a `sink`/`server` event (NOT `sink-input`, which fires
319 throughout playback, and NOT `client`, which the bar's own `pactl` runs
320 generate — matching either would put the reader in a loop with itself).
321 Both send only a CHANGED value, so an idle desktop never wakes the event
322 loop, and `update()` asks `paints_stat` whether this module shows the field
323 before redrawing — the one-field counterpart to `stats_signature`, and a
324 test holds the two in agreement. The subscription burst is coalesced for
325 30ms before the read (a held volume key emits a stream of events, and one
326 `pactl` spawn per event would fall behind); the child carries
327 `PR_SET_PDEATHSIG` as well as `kill_on_drop`, because a subscription whose
328 reader was killed outright is reparented to init and sits there rather than
329 noticing. Where the fast path runs, the one-second loop does NOT read
330 either value (`has_fast_levels`) and `update()` keeps them across a stats
331 push; each subscription re-reads the sink when it (re)starts, so a gap
332 while `pactl subscribe` was down is caught up. Measured in a shadow:
333 ~45ms for the backlight, ~55ms for the sink, against a second before.
334 - **Tray** (`spawn_status_tray`): a full StatusNotifierItem/Watcher host over `zbus`,
335 including DBusMenu fetching. Icons arrive as pixmaps or theme names (rendered via
336 `resvg`/`png`) — the app's own art, drawn as it comes. An item that brings
337 neither a pixmap nor a name the theme resolves is drawn as a cce-icons glyph
338 guessed from its icon name (`modules::tray_fallback_glyph`: volume, wifi,
339 battery, bluetooth, mail, chat, gamepad, cube for Dropbox, else the gear),
340 in the accent colour. Those were emoji until 2026-10-05; without the icon
341 set the slot is left empty, since 16 px holds no word.
342 - **Switcher** (`spawn_switcher_listener`): binds
343 `/tmp/cce-status-interface-switcher-{WAYLAND_DISPLAY}.sock`; a line on it fires
344 `SwitcherTriggered`.
345
346 Outbound actions shell out to `ccectl` (`windows --json`, `focus-window`,
347 `window-switcher`, `status-hide-mode`, `adjust-position-mode`), resolved from `~/.local/bin` first (`get_ccectl_cmd`).
348 `ccectl windows --json` returns one JSON object per line; the text format is kept only
349 as a parse fallback for older compositors (`parse_ccectl_window_any_line` handles
350 both). Keyboard alt-tab switching is delegated to the compositor
351 (`ccectl window-switcher`) — don't reimplement it here.
352
353 **Right-click menus are IN-SURFACE** (`ModuleContextMenu`): the module's own
354 surface expands below the bar strip to contain the menu — the module box
355 literally grows into the menu (one continuous rounded box; the expansion and
356 contraction are ANIMATED over ~140ms, `menu_anim`/`menu_closing` stepped in
357 `tick`, eased in `rebuild_layout`, surface resized per-frame via
358 `desired_size`; the menu object drops only when the contraction lands) —
359 module context menus and tray icon DBusMenus alike (fetched/flattened by `cloud.rs::
360 fetch_tray_menu_pages` into `MenuPage`/`MenuRow` pages riding a
361 `CustomEvent::TrayMenuFetched`; submenus paginate in place; row clicks send the
362 DBusMenu "clicked" via `send_tray_menu_event`). **A menu's marks and page
363 turns are cce-icons glyphs**, by cce-ui's context-menu conventions
364 (`cloud::menu_row_glyphs`): a label leading with `context_menu::MARK_CHECK` /
365 `MARK_ON` / `MARK_OFF` is drawn with the check / circle / circle-outline glyph
366 and the text without it (a DBusMenu toggle becomes one by its `toggle-type`,
367 `cloud::toggle_mark`; the window module's Mode page is a radio group), a
368 `Submenu` row ends in chevron-right, and a `Back` row reads "Back" after
369 chevron-left. A page where any row has a left glyph reserves the column on
370 every row, so the labels share an edge. They were `[x]` / `[ ]`, `Label >`
371 and `< Back` text until 2026-10-05. The compositor treats a status
372 segment thicker than the bar as expanded: frozen slot, no size enforcement,
373 raised above overlapped windows; the bar must reset its own height on close.
374 In droplet style the expanded panel is a FLAT glass sheet: `spec_at_reference_height`
375 fades `dome` and `gleam` to zero (continuously in the growth factor, gone by
376 twice the bar height) because the SDF-gradient dome creases on a long-sided
377 box — full strength drew a blocky lit picture-frame with the band pinned, and
378 envelope folds across the body with the band grown; both were tried. The panel
379 keeps the silhouette-hugging water terms (clarity, rim crest, core, contact
380 shadow), and the hovered row's highlight is a rounded pill inset from the
381 panel edge (`menu_hover_rect`, drawn before the text), not a full-width rect.
382
383 **No cce-cloud popups remain in this app**: the window picker (window-module
384 click → `MenuReady` rows of `Ccectl(["focus-window", id])`) is an in-surface
385 menu too. Menu width sizes to
386 the longest row label. Expanded segments stack in the compositor's popups
387 layer (cce-fx@74a0f75) so click-away-close works across the whole surface,
388 including the strip band over neighboring segments. Plain Escape closes open
389 menus too — compositor-side like click-away (cce-fx@7db8c03), arriving here as
390 the same `dismiss` push; this app never sees the key itself, since status
391 segments hold no keyboard focus.
392
393 ## Config
394
395 Config comes from the shared `~/.config/cce/config.kdl` with the app's own
396 `~/.config/cce/cce-status-interface/config.kdl` merged over it (cce-ui does the
397 merge by executable name; both files' mtimes drive the live-reload poll via
398 `config_files_modified`). App-native keys live in the app file — currently
399 `module { corner_radius }` (overall module box radius; deliberately NO shared
400 fallback — the old `status_box_corner_radius` rung was removed) and `module { spacing }` (the gap between segments;
401 the COMPOSITOR reads this one for its arrange pass — bar-side it only affects a
402 multi-module surface — applied on `ccectl reload`) and `module { height }` (the
403 bar height; read by BOTH sides — bar surfaces live via the mtime poll, the
404 compositor's segment height + reserved strip on `ccectl reload` — falls back to
405 the shared `layout { bar_height }`) and `module { padding }` (text inset inside
406 each module box, bar-side only, falls back to the shared
407 `/style/status/padding`) and
408 `module { font_size }` (module text size, bar-side only; beats even the size
409 embedded in the shared font string, which remains the fallback) and
410 `module { font }` (module text family; an embedded size ranks below
411 module { font_size } in the size chain) and `module { background_color }` (the
412 module box fill, rgba; linearized like every quad color, and the
413 background_blur tint scaling still applies on top) and `module { text_color }`
414 (module text, raw-sRGB like every text color, falls back to the shared
415 `/style/status/normal_color`) and `module { droplet }` (the water-droplet module
416 style — cce-ui's `Prim::Droplet`, shader mode 10; the key's PRESENCE enables
417 it, its value is whitespace-separated `k=v` pairs onto `DropletSpec` — sag,
418 belly, belly_w, blend, sheet_r, attach, clarity, dome, band, gleam, shine,
419 rim, bow, curve, core, refr, ghost, shadow; defaults = the oval dewdrop (no
420 belly; attach 0.42 + sheet_r 0.58 fill the height so there is NO straight
421 side; bow arcs the bottom; curve 2.6 = superellipse joins, so everything but
422 the flat top is one continuous curve), belly>0 brings back the pendant-pool
423 look — warn-and-skip on unknown keys. Three of those knobs are not this
424 side's: `refr` (rim refraction, logical px) and `ghost` (the inverted lens
425 image in the belly) are read by the COMPOSITOR, whose scenefx droplet node
426 bends the backdrop behind the drop — a Wayland client cannot see behind its
427 own surface, so this side parses them and draws nothing. `shadow` (0-1, the
428 contact shadow under the drop's lower arc) IS drawn here, and it is why the
429 drop box does not fill the surface: the box is inset by 1px for the
430 silhouette's AA feather plus `DropletSpec::shadow_gap()` for the shadow's
431 falloff (`main.rs`, three call sites — left, right, and the expanded menu
432 box, which becomes the drop growing))
433 and `module { icon_size }` (glyph height for the icon readouts, logical px,
434 default 16 = the tray's fixed icon size, so the two read as one set) and `module { icon_font_size }` (the number beside
435 the glyph, default `module { font_size }`) and `module { icon_gap }` (glyph
436 to number, logical px, default 4) and `module { icon_spacing }` (between
437 readouts in the `stats` bubble, default `module { spacing }`) and
438 `module { icon_alpha }` (glyph opacity 0-1, default 1) and
439 `module { icon_weight }` (OpenType weight of the number, unset = regular)
440 and `module { text_raise }` (lifts module text above vertical center, logical
441 px, bar-side only — every module funnels through `centered_text_y`) and
442 `module { backdrop_compress }` (the minimum WCAG contrast ratio the text must
443 hold against any backdrop pixel, e.g. 10; unset = off — read by the
444 COMPOSITOR only, see "Text contrast" below). Everything is read through
445 `cce_ui::config::cached_config()`; KDL is converted to JSON
446 (`cce_ui::config::parse_kdl_to_json`) and looked up by **explicit JSON
447 pointer only**: every key names its canonical nesting
448 (`/style/status/background_color`, `/module/height`,
449 `/window_manager/light_source_position`, `/layout/status_bar/<module>` for
450 per-module sides, …), and a key parked anywhere else simply does not resolve.
451 (The legacy fuzzy `json_find_key` — snake_case split across nesting, then
452 depth-first search — was deleted 2026-08-18 after its fallback warnings went
453 quiet; don't reintroduce it.) Shared keys used here, written as the pointers
454 they are actually looked up by — the flat snake_case spellings this list used
455 to carry (`status_padding`, `status_font`, …) appear nowhere in the config or
456 the code: `/layout/bar_height`, `/style/status/font` (also via fontconfig alias
457 `status-interface`), `/style/status/font_size`, `/style/status/padding`,
458 `/style/status/module_spacing`, `/style/status/normal_color`,
459 `/style/status/disabled_color`, `/style/status/background_color`,
460 `/style/status/background_blur`, `/style/status/box_bevel`(`_depth`),
461 `/window_manager/light_source_position`, and `/layout/status_bar/<module>` for
462 the per-module sides. (The whole-bar
463 background chain is gone: a `StatusApp` is always a single `--module` segment,
464 so the surface bg is permanently transparent and only module boxes paint.)
465
466 Color space (one rule, enforced in `config.rs`): **text colors stay raw sRGB**
467 (`text_color_from` — cosmic-text consumes sRGB `[u8; 3]`), **quad/box colors
468 are linearized** (`quad_color_from` via `cce_ui::color::parse_hex_rgba_linear`,
469 for the Vulkan pipeline). No local gamma math — the old scattered `.powf(2.2)`
470 is gone; `text_colors_stay_srgb_and_quad_colors_are_linearized`, in
471 `config.rs`, is the spec. Config changes are picked up by polling the file
472 mtime in `tick()`, so there is no reload event to wire up.
473
474 ## Text contrast: backdrop compression
475
476 The bar draws into its own buffer and can never see what it is composited
477 over, so a module box at `background_color` alpha `30` leaves its text at the
478 mercy of whatever the desktop shows through it. The fix lives in the
479 compositor, which CAN see: `module { backdrop_compress }` makes `cce-fx`
480 compress the luminance of each segment's backdrop — the blurred one, or the
481 refracted one when the droplet lens is live — so the module text keeps that
482 contrast ratio over every pixel of it. For light text the backdrop's bright
483 parts are pulled down under a ceiling; for dark text its shadows are lifted
484 instead. Below a knee (half the ceiling) nothing moves, so a backdrop that is
485 already dark enough is left exactly as it is, and the curve approaches the
486 ceiling asymptotically with no seam. The color is scaled, not desaturated, so
487 a bright backdrop keeps its hue. The text color is `module { text_color }`
488 (else the shared `/style/status/normal_color`), read compositor-side;
489 `backdrop_compress_params` in cce-compositor's `config.rs` turns the pair into
490 the shader's ceiling, and scenefx's `tex.frag`/`droplet.frag`
491 (`compress_backdrop`) apply it. It rides the backdrop blur, so a bar with
492 `/style/status/background_blur` at 0 has nothing to compress. The bubble's own
493 translucent fill and the droplet's lighting composite on top of the compressed
494 backdrop and lift it, so the ratio reached is well under the one asked for:
495 measured in a shadow on a pure-white desktop with this repo's droplet style,
496 `backdrop_compress 4.5` reached 2.7:1 and `10` reached 4.7:1 (`15`: 6.4:1).
497 Over a backdrop already dark enough, on and off are pixel-identical.
498
499 This replaced the bar-side scrim on 2026-10-01: a dark feathered pool
500 (`text_scrim`, deepened by `text_contrast` from a per-segment `backdrop`
501 luminance measurement the compositor pushed on the status socket) that
502 darkened the whole bubble even over a backdrop that needed no help. Compression
503 is per pixel, so it needs neither the pool nor the measurement loop. (Before
504 the scrim, `text_relief`'s letterpress underlay and `text_halo`'s four-copy
505 outline decorated the letterforms; they went 2026-08-28. Don't reintroduce a
506 per-letterform treatment without a reason the backdrop cannot serve.)
507
508 ## Interactions worth knowing before touching input code
509
510 - **Super + left-drag on a segment is handled by the compositor**, not this app: it
511 starts the same segment drag as adjust-position mode (snap to an edge on release,
512 persisted to `layout.status_bar.<module>` in config.kdl). This app never sees those
513 presses and no longer tracks the super key — with two exceptions since
514 2026-09-16 (cce-fx `cursor.rs`): a press that travels under 6px is a CLICK,
515 replayed to the segment as press+release instead of snapped (a still click on
516 a top-edge segment used to re-home it to top-center), and an EXPANDED segment
517 (menu open) is never grabbed at all, so the "Done" row can end adjust mode.
518 - Tray icons left-click activate / right-click open their DBusMenu. (The old
519 layout-mode menu and viewport tabs are gone with the viewport-tag feature.)
520 - `ToggleHideModules` / `ToggleAdjustPositionMode` mirror their state to the compositor
521 via `ccectl status-hide-mode|adjust-position-mode true|false`; the adjust-mode state
522 is read back with `ccectl adjust-position-mode query` (the compositor is the single
523 source of truth — the old `/tmp/cce-status-interface-adjust-mode` sentinel file is
524 no longer consulted).