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

commit5ec9d759d8f56f75d7b336f17b158ef70117b80f
parent262a14c64a
authorLucas Galante <[email protected]>
date2026-07-13 15:42
docs: add CLAUDE.md and the cleanup proposal

Co-Authored-By: Claude Fable 5 <[email protected]>

 CLAUDE.md   | 125 ++++++++++++++++++++++++++++++++++++++++++++
 PROPOSAL.md | 170 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 295 insertions(+)

diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..a2967e0
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,125 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## What this is
+
+`cce-status-interface` is the status bar of the `cce` Wayland desktop environment. It
+is one crate in the multi-repo `cce` workspace — see `../CLAUDE.md` for the workspace
+layout, the multi-repo git rules (commit here, never `git init` at the root), and the
+`cce-ui` toolkit this app is built on. This crate is deliberately small: `src/main.rs`
+(the `StatusApp` application + IPC + config parsing) and `src/modules.rs` (the
+`StatusModule` trait and its nine implementations).
+
+## Build, test, run
+
+```sh
+cargo build --release                 # standalone build (or `-p cce-status-interface` from the workspace root)
+cargo test                            # the tests live in main.rs (e.g. test_status_config)
+make install                          # installs ../target/release/cce-status-interface to ~/.local/bin
+```
+
+Running it requires a live cce compositor session (`$WAYLAND_DISPLAY` plus the cce
+sockets); there is no meaningful headless mode.
+
+## Process model (the most important thing to know)
+
+One binary, four modes, selected by CLI args in `main()`:
+
+- **No args — launcher daemon.** Spawns one child process per module
+  (`--module window`, `--module clock`, …), polls every 500ms and restarts crashed
+  children. This is the normal production mode: each module is its own process and its
+  own Wayland surface.
+- **`--module <name>`** — a single-module bar segment. Valid names: `window`, `tray`,
+  `cpu`, `memory`, `brightness`, `volume`, `battery`, `clock`, `light_source`.
+- **`--monolithic`** — all modules in one window (window on the left, the rest on the
+  right). Useful for debugging layout without nine processes.
+- **`--trigger-switcher`** — one-shot: writes `trigger` to the switcher socket of the
+  running instance and exits (used as a keybinding target).
+
+The compositor places each segment by its Wayland `app_id`, computed in
+`StatusApp::get_app_id()`: `cce-status-{side}-{name}` (e.g. `cce-status-left-window`),
+or plain `cce-status` for the monolithic bar. If
+`/tmp/cce-status-interface-{WAYLAND_DISPLAY}.sock` exists, the `cce-status-interface-`
+prefix is used instead — keep both spellings in mind when matching app_ids. A module's
+side comes from the config (`get_module_side`, which also maps snap positions like
+`top-left`/`bottom-right` to left/right); default is `window` → left, everything else →
+right.
+
+## Rendering
+
+The app implements `cce_ui::engine::Application` on the **`display_list()` paint path**
+(Phase 6ak) — the legacy `view*()`/`text_items()` methods are gone. The flow:
+
+1. `rebuild_layout()` runs the two-pass module layout — for each module first
+   `StatusModule::width()`, then `StatusModule::render()` — filling retained buffers on
+   `StatusApp`: `rects`, `rounded_boxes`, `separators`, `text_prims`
+   (the `TextPrim` tuple type; build them with `draw_label()` from a
+   `cce_ui::widget::StyledLabel`), plus `input_regions`, `module_bounds`,
+   `tray_item_bounds`, `viewport_bounds`.
+2. `display_list()` replays those buffers into a `PaintCtx` each frame (and triggers
+   `rebuild_layout()` when size/scale changed or `needs_rebuild` is set).
+   `overlay_quads()` remains a separate on-top pass (used for drag feedback).
+
+Orientation is dynamic: `is_vertical()` compares the surface size against the
+configured bar thickness; every module renders along one axis using `bar_h`/`coord`
+accordingly.
+
+## Events and IPC
+
+`update()` consumes `CustomEvent`s sent over a calloop channel from tokio tasks spawned
+in `new()` — which tasks run depends on the selected module, so a clock process doesn't
+listen to tray D-Bus, etc.:
+
+- **Compositor status feed** (`spawn_status_listener`): connects to
+  `/tmp/cce-status[-interface]-{WAYLAND_DISPLAY}.sock`, subscribes to `viewport`,
+  `layout`, `title`, `modifiers` (line-oriented, auto-reconnects every 1s). The
+  `viewport` payload is Pango-ish markup parsed by `parse_viewport_text()`.
+- **System stats** (`spawn_system_stats`): `/proc/stat`, `/proc/meminfo`,
+  `/sys/class/power_supply/BAT*`, `/sys/class/backlight`, and `pactl` for volume/mute.
+- **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`).
+- **Switcher** (`spawn_switcher_listener`): binds
+  `/tmp/cce-status-interface-switcher-{WAYLAND_DISPLAY}.sock`; a line on it fires
+  `SwitcherTriggered`.
+
+Outbound actions shell out to `ccectl` (`view <viewport>`, `windows`, `focus-window`,
+`viewport-layout`, `window-switcher`) and `cce control ...`; both binaries are resolved
+from `~/.local/bin` first (`get_ccectl_cmd`/`get_cce_cmd`). Keyboard alt-tab switching
+is delegated to the compositor (`ccectl window-switcher`) — don't reimplement it here.
+
+**Popups are `cce-cloud` processes**, not surfaces of this app: the window picker, tray
+context menus, and the layout-mode menu each spawn `cce-cloud`, pipe it a JSON page
+description on stdin, and track it via the `CloudSpawned`/`CloudClosed` events
+(`active_cloud_pid`/`active_cloud_source`). Clicking again toggles the popup off by
+killing the pid; closing restores focus with `ccectl focus-window`. Follow this pattern
+for any new popup.
+
+## Config
+
+Everything reads the shared `~/.config/cce/config.kdl` through
+`cce_ui::config::cached_config()`; KDL is converted to JSON
+(`cce_ui::config::parse_kdl_to_json`) and looked up with the local `json_find_key`,
+which splits snake_case keys across nesting — `status_background_color` matches
+`style { status background_color=... }`. Keys used here: `bar_height`, `status_font`
+(also via fontconfig alias `status-interface`), `status_font_size`, `status_padding`,
+`status_module_spacing`, `status_normal_color`, `status_separator_color`,
+`status_background_color`, `status_background_blur`, `status_box_corner_radius`,
+`background_color`/`low_color`/`desktop_gap_color` (bar bg fallback chain),
+`light_source_position`, and per-module-name position/side entries.
+
+Two gotchas: background/box colors are gamma-corrected (`.powf(2.2)`) while
+`status_normal_color` is plain sRGB — match the existing `parse_*_color_from_key`
+helper for the kind of color you add. Config changes are picked up by polling the file
+mtime in `tick()`, so there is no reload event to wire up.
+
+## Interactions worth knowing before touching input code
+
+- **Super + left-drag** moves a module along the bar (`dragged_module`,
+  `ModifiersUpdated` tracks the super key from the compositor feed).
+- Viewport tabs in the window module are clickable (`viewport_bounds` → `ccectl view`);
+  the layout indicator opens the layout-mode menu; tray icons left-click activate /
+  right-click open their DBusMenu.
+- `ToggleHideModules` / `ToggleAdjustPositionMode` mirror their state to the compositor
+  via `cce control status-hide-mode|adjust-position-mode true|false`.
diff --git a/PROPOSAL.md b/PROPOSAL.md
new file mode 100644
index 0000000..d867f30
--- /dev/null
+++ b/PROPOSAL.md
@@ -0,0 +1,170 @@
+# Proposal: cce-status-interface cleanup
+
+Fixes for the issues identified in the 2026-07 review: `main.rs` carrying five jobs,
+a fuzzy config-lookup layer that partially duplicates `cce-ui`, stringly-typed
+coordination with the compositor, inconsistent color gamma handling, mixed
+`eprintln!`/`log::` output, and near-zero test coverage.
+
+Ordered so that each phase is independently commitable and the risky changes land on
+top of a test safety net. Phases 1, 2, 5, and 6 touch only this repo; phase 3 also
+touches `cce/`; phase 4 also touches `cce-ui/`. Per the multi-repo rules, each repo
+gets its own commits and must keep building standalone.
+
+---
+
+## Phase 0 — Characterization tests (safety net)
+
+Today there is one test (`test_status_config`). Before moving anything, pin down the
+behavior the later phases will refactor:
+
+- `parse_viewport_text`: pango spans, JSON-wrapped payloads, malformed/unterminated
+  spans, plain-text fallback.
+- `json_find_key`: exact match, snake_case split across nesting
+  (`status_background_color` → `status { background_color }`), collision/traversal
+  order, miss → `None`. These tests become the spec for phase 2's replacement.
+- `get_module_side`: config side values, snap-position aliases
+  (`top-left`/`bottom-right`/…), defaults (`window` → left, rest → right).
+- The `ccectl windows` line parser in `trigger_switcher` (extract the per-line parse
+  into a free function first so it's testable): `app_id=`/`title="…"`/`focused=`/
+  `window id=` extraction, filtered app_ids. These tests get retired in phase 3 when
+  the parser is replaced by JSON, but until then they document the wire format.
+- Color parsing: one test asserting which keys are gamma-corrected and which are raw
+  sRGB, so phase 2's fix is a deliberate, visible change rather than an accident.
+
+**Effort:** small. **Risk:** none (test-only).
+
+## Phase 1 — Split `main.rs` (mechanical, no behavior change)
+
+`main.rs` is ~3,400 lines. Split by existing seams, keeping `modules.rs` as-is:
+
+| New file | Contents (moved, not rewritten) | ~lines |
+|---|---|---|
+| `src/tray.rs` | zbus proxies/traits, `StatusNotifierWatcher`/host impl, `spawn_status_tray`, `fetch_tray_item`, icon decode | ~850 |
+| `src/cloud.rs` | `show_cce_cloud_menu`, `MenuItem` parsing/paging, window-picker spawn body from `trigger_switcher` | ~500 |
+| `src/stats.rs` | `spawn_system_stats`, `read_cpu_ticks`, `read_memory_usage`, `read_battery_details`, `read_volume`, brightness | ~300 |
+| `src/config.rs` | all `read_*_from_config`, `parse_*_color_from_key`, `json_find_key`, `parse_font_for_alias`, `get_*_cmd` | ~350 |
+| `src/listeners.rs` | `spawn_status_listener`, `spawn_switcher_listener` | ~100 |
+| `main.rs` (remains) | `StatusApp`, `Application` impl, layout/input, `CustomEvent`, launcher-daemon `main()` | ~1,300 |
+
+Rule for the phase: `git diff` should show only moves, `use` changes, and visibility
+bumps (`fn` → `pub(crate) fn`). No logic edits — those come later, reviewable on their
+own.
+
+**Effort:** medium (mostly mechanical). **Risk:** low with phase 0 in place.
+
+## Phase 2 — Config: replace the local layer with `cce-ui` accessors
+
+`cce_ui::config` already provides `cached_config()`, JSON-pointer accessors
+(`get_f32`, `get_bool`, `get_string`, `get_color`) and a recursive `find_key`. The
+local layer in this crate re-implements the lookup with an extra behavior — splitting
+snake_case keys across nesting — and hand-rolls gamma with `.powf(2.2)`.
+
+1. **Make each config key an explicit pointer.** Replace
+   `json_find_key(&val, "status_background_color")` with
+   `cce_ui::config::get_color("/style/status/background_color")` (etc.), encoding the
+   real nesting once instead of discovering it by recursive search. Keys whose actual
+   KDL location is unclear get resolved by looking at a real `config.kdl` and the
+   compositor's reader — that's the point: today nobody can grep where a key lives.
+2. **Keep a thin fallback during migration.** A local
+   `get_color_fuzzy(key)` that first tries the pointer, then falls back to the old
+   `json_find_key`, with a `log::warn!` when only the fallback hits. After one release
+   of quiet logs, delete the fallback and `json_find_key` entirely.
+3. **Fix gamma in one place.** `get_color` returns raw sRGB by its own doc; apply
+   `cce_ui::color::srgb_to_linear` at the single point where colors enter the render
+   state (`rebuild_layout`), replacing the scattered `.powf(2.2)` and the
+   `status_normal_color`-is-linear-but-background-isn't inconsistency. Verify visually
+   against the current bar before/after (screenshot compare) since this may shift
+   perceived colors that users have tuned; if `status_normal_color` was correct as-is,
+   document that in the code rather than leaving it implicit.
+4. **Delete local duplicates:** `parse_hex`, `parse_hex_rgba` wrappers,
+   `parse_srgb_color_from_key`, `parse_rgba_color_from_key`, `parse_json` — all become
+   calls into `cce_ui::config`/`cce_ui::color`.
+
+**Effort:** medium. **Risk:** medium (visible color shifts possible — mitigated by the
+phase-0 gamma test and a manual screenshot check). Cross-crate impact: none; other
+clients using the fuzzy pattern can migrate later on their own schedule.
+
+## Phase 3 — Structured `ccectl` output (cross-repo: `cce/`)
+
+The window picker parses `ccectl windows` free text with `find("app_id=")` and
+friends; titles containing `"` or spaces in unexpected places break it silently.
+
+1. **In `cce/` (owns `run_cce_ctl` and the control socket):** add `--json` to the
+   read commands this crate consumes — `windows` first; `viewports`/others as needed.
+   Output: one JSON object per line or a single array —
+   `{"id": …, "app_id": "…", "title": "…", "focused": bool}`. Text output stays the
+   default so nothing else breaks.
+2. **In this crate:** replace the line parser with `serde_json` deserialization into a
+   small `WindowEntry` struct; fall back to the text parser if `--json` is rejected
+   (running against an older compositor), so the two repos can ship independently.
+3. **Same pass, smaller items:**
+   - Replace `Command::new("kill").arg(pid)` with a direct `SIGTERM` via `libc::kill`
+     (or the `nix` crate) — no shell-out, and an error result instead of a silent
+     failure.
+   - Replace the `/tmp/cce-status-interface-adjust-mode` sentinel-file read in
+     `ToggleAdjustPositionMode` with a `ccectl` query, so mode state has one source of
+     truth (the compositor).
+
+**Effort:** medium, split across two repos. **Risk:** low (fallback keeps old/new
+combinations working).
+
+## Phase 4 — A shared popup helper (cross-repo: `cce-ui/`)
+
+The `cce-cloud` popup pattern (spawn, write JSON pages to stdin, track pid via
+`CloudSpawned`/`CloudClosed`, toggle-off by kill, restore focus on close) is
+hand-rolled here in three places (window picker, tray menus, layout menu) and will be
+wanted by other clients.
+
+- Add `cce_ui::process::CloudPopup` (the crate already has `process.rs`):
+  `spawn(pages, position, source) -> CloudPopup`, `toggle_off()`, `is_running()`,
+  completion callback for focus-restore. Internals lifted from this crate's working
+  implementation — it's extraction, not redesign.
+- Port the three call sites here; `active_cloud_pid`/`active_cloud_source` and the
+  `/proc/<pid>/comm` checks move behind the helper.
+
+Deliberately **not** proposing a socket protocol between bar and popups: the
+pid-plus-stdin model is working, and the goal is to stop *re-implementing* it, not to
+replace it. Revisit only if popups need richer two-way communication.
+
+**Effort:** medium. **Risk:** low-medium (behavior-preserving extraction, three call
+sites to verify by hand: window picker, a tray menu, layout menu).
+
+## Phase 5 — Logging unification
+
+51 `eprintln!` vs 12 `log::` calls today, and `env_logger` is already initialized.
+
+- Convert `eprintln!` → `log::debug!` (chatty per-event traces: `[status-listener]`,
+  `[cloud-event]`, stats updates) or `log::info!`/`log::warn!` (lifecycle, failures).
+- Keep the existing bracketed subsystem tags as message prefixes; they're useful.
+- Default filter stays `Info`, so the net effect is a much quieter stderr with
+  `RUST_LOG=debug` restoring today's firehose.
+
+**Effort:** small, mechanical. **Risk:** none.
+
+## Phase 6 — Follow-ups (explicitly out of scope for now)
+
+- **Tray as its own crate** (`cce-tray`): worth doing only when a second client needs
+  SNI. Phase 1's `tray.rs` makes the later extraction cheap.
+- **Upstreaming the fuzzy key-split into `cce_ui::config::find_key`**: rejected —
+  phase 2 goes the other way (explicit pointers), and two lookup semantics in the
+  toolkit is worse than one.
+- **Launcher-daemon supervision polish** (backoff on crash-looping modules instead of
+  unconditional 500ms restarts): cheap, but wait until after phase 1 so it lands in a
+  small `main()`.
+
+---
+
+## Sequencing and verification
+
+```
+0 tests ─→ 1 split ─→ 2 config/gamma ─→ 5 logging
+                └────→ 3 ccectl --json (needs a cce/ commit first)
+                └────→ 4 CloudPopup    (needs a cce-ui/ commit first)
+```
+
+Phases 3 and 4 are independent of 2 and of each other; 5 can land any time after 1.
+
+Each phase ends with: `cargo test` green, `cargo build --release` standalone, and a
+manual smoke test in a live session — bar renders in both orientations, viewport tabs
+switch, a tray menu opens and closes, the window picker toggles, super+drag moves a
+module, and a config edit hot-reloads.