git.lucas.co / cce-cloud
cloud storage client
git clone https://git.lucas.co/cce-cloud.git

CLAUDE.md (21.3K)

  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-cloud` is the **launcher / popup app** of the cce Wayland desktop environment: a
  8 fuzzel-style fuzzy launcher, a dmenu replacement, a Super-Tab window switcher, and a generic
  9 JSON-defined popup panel — all in one binary. It is one crate of the cce multi-repo
 10 workspace; workspace-wide conventions (multi-repo layout, no `[workspace.dependencies]`,
 11 shared `../target/`) live in **`../cce-compositor/WORKSPACE.md`** — read that too.
 12 
 13 Two source files, all logic in `src/main.rs` (~5.6k lines):
 14 
 15 - `src/main.rs` — CLI parsing, daemon/client/standalone entry points, the Wayland
 16   surface and event loop, `FuzzelWidget` (the list UI: keyboard selection chip,
 17   pointer hover wash, icons, row labels — hover and click share one `row_at`
 18   predicate, so the lit row is the row a press picks), all input handling,
 19   app/PATH scanning. (Rendering itself is cce-ui's — this crate declares no
 20   graphics dependency of its own.)
 21 - `src/json_layout.rs` — `JsonLayoutWidget`: the JSON-config popup panel host
 22   (labels, checkboxes, buttons, spinboxes, color selectors, sliders, multi-page).
 23   App-owned copy of a dissolved cce-ui type; cloud is its only consumer.
 24 
 25 The list's scrollbar/viewport math is the toolkit's `cce_ui::widget::ScrollRegion`
 26 (`cce-ui/src/widget/scroll_region.rs`). This crate carried its own copy in
 27 `src/scroll_region.rs` until 74f9ad4 (2026-09-01); the shared one is what gives
 28 `get_draw_y` its partially-visible-rows contract, which paint, click and hover all
 29 virtualize on.
 30 
 31 Its bar is the DE's one scrollbar (cce-ui's CLAUDE.md, "Every scrollbar rides a
 32 centre line, behind the plate"): `with_sink_behind(true)`, so it rides the
 33 list's centre line over the rows, idles under the list's translucent bg fill
 34 (`push_scrollbar_prims` before the fill, every frame) and fades in over the
 35 rows while a scroll holds it (`push_scrollbar_fore` after them). Until
 36 2026-10-06 it sat at the right edge as flat squares on a hard flip, through the
 37 tuple path (`push_quads` / `push_scrollbar_quads`).
 38 
 39 ## Build, test, run
 40 
 41 ```sh
 42 cargo build                      # standalone (this repo is its own workspace root)
 43 cargo build -p cce-cloud         # from the workspace root (avoids compositor rebuild)
 44 cargo test                       # unit tests live at the bottom of main.rs
 45 cargo test test_json_layout      # single test
 46 make install                     # release build, then `ccebuild install --no-build cce-cloud`
 47 ```
 48 
 49 Running requires a Wayland session (layer-shell). Quick manual checks:
 50 
 51 ```sh
 52 cce-cloud --path                         # launcher over $PATH executables
 53 printf "a\nb\nc\n" | cce-cloud --dmenu   # dmenu mode; selection prints to stdout
 54 echo '{"widgets":[{"type":"button","id":"ok","text":"OK"}]}' | cce-cloud --json
 55 cce-cloud --daemon                       # run the daemon (normally started by the DE)
 56 ```
 57 
 58 ## Architecture
 59 
 60 ### Daemon / client / standalone
 61 
 62 `main()` (bottom of `src/main.rs`) picks one of three roles:
 63 
 64 - `--daemon`: `run_daemon()` binds `/run/user/$UID/cce-cloud.socket` (fallback
 65   `/tmp/cce-cloud-$UID.socket`), holds one long-lived Wayland connection, and serves
 66   **one popup at a time, serially**. A new client connection **preempts** the currently
 67   open popup (global single-popup semantics); a client hangup closes the popup
 68   (`StdinState.client_gone`).
 69 - No `--daemon`: `run_client()` connects to that socket, sends one JSON line
 70   `{"args": [...], "initial_stdin": "..."}`, streams any further stdin lines over the
 71   socket, and blocks until the daemon writes the selection back — which it prints to
 72   stdout. So callers get dmenu semantics whether or not the daemon is running.
 73 - If the socket connect fails, it falls back to `run_standalone()`: the same UI
 74   in-process, selection printed directly to stdout.
 75 
 76 **The daemon's life is one compositor's.** It holds one Wayland connection for good,
 77 so it has to notice that connection dying: the wait for the next request dispatches the
 78 Wayland source alongside the socket listener (a calloop `Generic` on a dup of it), and a
 79 dispatch error — the compositor gone — is `compositor_gone`, a clean exit with status 0.
 80 The unit is `PartOf=` / `WantedBy=cce-session.target`, which startcce starts and stops
 81 with each compositor, so the next session brings up a fresh daemon. Until 2026-09-25 the
 82 wait was a blocking `accept()` that never read the Wayland socket, and the unit hung off
 83 `graphical-session.target`, whose stop systemd skipped at one logout (a queued dropbox
 84 start made the transaction "destructive"): the daemon survived into the next session and
 85 panicked building its first popup on the dead connection (`No surface formats:
 86 ERROR_SURFACE_LOST_KHR`). A unit's `[Install]` change needs `systemctl --user reenable
 87 cce-cloud.service` — `ccebuild install` copies units but does not re-enable them.
 88 
 89 The daemon re-parses the forwarded args with the same flag loop as standalone — **flag
 90 changes must be made in both `run_standalone()` and `run_daemon()`** (and, for the
 91 needs-stdin decision, in `run_client()`).
 92 
 93 ### Modes (`LauncherMode`)
 94 
 95 - `Dmenu` — items from stdin, selection echoed out. Magic stdin lines
 96   `__cce_switcher_next__` / `__cce_switcher_select_and_close__` drive the compositor's
 97   window switcher (`--switcher` starts in Dmenu with its hold modifier down, and commits when neither Super nor Alt is held, so it works bound to Super+Tab or Alt+Tab; moving the pointer onto a row selects it — `FuzzelWidget::pointer_moved`, motion only, so a popup mapping under a resting pointer does not steal the row Super+Tab advanced to).
 98 - `--choose` — the **app chooser** ("Open with…") behind the AppChooser portal
 99   (cce-desktop-portal spawns `cce-cloud --choose [-p prompt] [-s last_id]`).
100   Dmenu mode with a different feed and answer: stdin carries desktop-file IDs,
101   each row shows that entry's `Name=` and icon (`desktop_entry_label`, NoDisplay
102   entries included — the portal's handler list is authoritative), and the pick
103   is printed back as the ID, not the label (`Chooser::answer`). `-s` takes an
104   ID too. Two entries sharing a name get `Name (id)` labels so every row maps
105   back to one app. Escape answers an empty line, which the portal reads as
106   cancelled. `State::new` takes `chooser_mode` because it ingests stdin
107   once itself: state set after construction misses that first feed and
108   shows the raw IDs.
109 - `Path` — executables scanned from `$PATH`.
110 **Key repeat** is the client's job on Wayland: the keyboard is bound with
111 `get_keyboard_with_repeat` on the calloop loop, which re-fires a held key at
112 the compositor's `repeat_info` rate into `repeat_key`. Only text, deletion
113 and cursor/list movement repeat (`key_repeats`); Return, Escape and Tab do
114 not. The loop is created BEFORE `AppState` on both paths, because the first
115 roundtrip is when the seat announces its keyboard. Until 2026-09-26 it was
116 bound with plain `get_keyboard`, and a held Backspace deleted one character.
117 
118 - `Apps` — `.desktop` files from the standard application dirs, sorted by launch
119   frecency persisted in `~/.cache/cce-cloud-apps.json`; selecting spawns the app's
120   `Exec` (spawn output logged to `$XDG_RUNTIME_DIR/cce/spawn.log`, via
121   `cce_ui::config::cce_runtime_dir()` — it was `/tmp/cce-spawn.log` before
122   2026-08-22). Every launch (`spawn_detached`) runs in **its own transient
123   scope**, `app-cce\x2dcloud-<app>-<n>.scope` in app.slice — `<app>` the
124   program the launch runs, not the `sh -c` wrapping it (`launch_name`) — `PartOf=`
125   `cce-session.target` (`scope_argv`, through `systemd-run --scope`): a daemon
126   restart never reaches it, and the session's end stops it. Until 2026-09-26
127   launches stayed in cce-cloud.service's own cgroup, where `KillMode=process`
128   (there so a restart would not kill them) also let them outlive logout — a
129   queued Proton launch then started in the next session before Xwayland
130   existed and ran invisible. Without `systemd-run` on PATH the app is started
131   directly, as before. Each entry's `Icon=` is
132   resolved through `cce_ui::icon` and drawn in a gutter left of the label — the
133   gutter is applied to every row, so one unresolvable icon doesn't rag the text
134   edge. Apps and the Super-Tab switcher are the only lists with icons: other
135   Dmenu/Path items are arbitrary strings with nothing to look up, and their
136   gutter stays 0. The switcher's rows are the compositor's `Title (app_id)`
137   lines; `split_switcher_row` splits one into title and id. The row text itself
138   is left as sent, since the compositor maps the echoed text back to a window —
139   only the drawn and measured label drops the suffix (`row_label`), so typing
140   an app_id still filters. `desktop_icon_index` maps the id to a `.desktop` entry's `Icon=` by file stem,
141   `StartupWMClass`, or last reverse-DNS component, falling back to the app_id
142   itself, which is the name cce's own apps install their icons under. The rows
143   stream in over stdin, so `resolve_switcher_icons` runs on each ingest.
144   Both paths first ask `icon_override` for a cce-icons glyph named after the
145   entry's **desktop-file ID** (`$XDG_DATA_HOME/icons/hicolor/scalable/apps/<id>.svg`,
146   one stat), which is how cce draws its own icon for an entry whose `Icon=` is
147   an absolute path, missing, or a generic name several apps share — see
148   `cce-icons/hicolor/README.md`. Overrides named after the `Icon=` value need
149   nothing here: the user's data dir is the theme search's first base dir.
150   Icons are uploaded once per renderer, not per popup: `icon_image` caches
151   the image id by icon name for as long as `cce_ui::vk::renderer_epoch` holds,
152   which in the daemon is its whole life (see "One renderer for every popup"
153   below), and the daemon warms it from a thread at startup. Uploading per
154   popup cost a synchronous GPU copy per icon at the first frame and a
155   device-idle wait per free at the next: ~90 stalls for 46 icons, most of the
156   launcher's time to first frame until 2026-10-05.
157   Apps is also the one **tabbed** mode: the list carries an *Apps* page and a
158   *System* page of DE verbs (`SYSTEM_COMMANDS` — window-manager actions through
159   `ccectl`, plus session/power commands), and **Tab / Shift+Tab step between
160   them**. See "Tabs" below.
161 - `Json` — a `JsonLayoutConfig` read from stdin builds a widget panel; clicking a
162   button prints one JSON object with the button id and every control's state
163   (`{"button", "checkboxes", "spinboxes", "colors", "sliders"}`) and closes.
164   A button with `target_page` turns the panel to that page instead.
165   **A button's marks and page turns are cce-icons glyphs**, by cce-ui's
166   context-menu conventions (`json_layout::button_glyphs`; since 2026-10-05):
167   a `text` that BEGINS with `"✓ "`, `"● "` or `"○ "` (`context_menu::MARK_CHECK`
168   / `MARK_ON` / `MARK_OFF`) is drawn with the check / circle / circle-outline
169   glyph and the text without the mark; a `target_page` LOWER than the
170   button's own page is a back row, chevron-left at its left; any other
171   `target_page` leads to a page, chevron-right at its right end. A page where
172   any button has a left glyph reserves the column on all of them, so the
173   labels share an edge, and the popup's width budgets the glyphs. Nothing new
174   in the schema — the marks are the toolkit's, the direction is the
175   `target_page` the protocol already had — so a layout must NOT spell its own
176   (`"Window Mode >"`, `"< Back"`, `"[x] "`), or it shows them twice; a back
177   row's text is a word ("Back"), which is also all that shows if the icon set
178   is missing. `cce-compositor/scripts/cce-desktop-menu` and `cce-app-menu`
179   are the consumers that page. Glyphs draw through `PaintCtx::icon`, whose
180   upload rides the shared queue this app's renderer drains each frame.
181 
182 Key flags: `-p/--prompt`, `-s/--select <item>`, `-x/-y` (position → forces layer-shell
183 anchoring), `--align-right`, `--parent-app-id` (app_id becomes `cce-cloud:<parent>`),
184 `--switcher`, `--choose`, and mode flags `--apps|--path|--dmenu|--json` (or `--mode <m>`).
185 
186 ### Tabs
187 
188 `FuzzelWidget::tabs` splits the list into `TabPage`s. Fewer than two draws no
189 strip and claims no height (`tab_strip_h()` is 0), which is what leaves Dmenu,
190 Path and the Super-Tab window switcher laid out and keyed exactly as they were —
191 **Tab only switches tabs where tabs exist**, and falls back to its old job of
192 cycling the highlight everywhere else. That fallback is load-bearing: the
193 switcher is Dmenu mode, and its Tab is the key the whole feature is named for.
194 
195 Three things to know before touching it:
196 
197 - **The active page's items and query live in `all_items` / `query`, not in its
198   `TabPage`.** Every pre-existing caller reads them there, and only
199   `switch_tab` moves them across — so a page's own copies are stale for as long
200   as it is the active one. Parking the query is what makes switching back land
201   on the same filtered rows.
202 - **The stdin/socket feed addresses tab 0, through `set_tab_items`,** never
203   `set_items` directly. `check_stdin_updates` skips the ingest when the items
204   match what it last pushed; compared against the *active* tab that test would
205   differ on every poll and clobber the page the user is reading.
206 - **A tab click is handled in the pointer branch, ahead of the row branch** —
207   next to the scrollbar press, and for the same reason. Any press
208   `FuzzelWidget::on_event` resolves is treated there as a row selection and (in
209   Dmenu/switcher mode) committed, so a tab click routed through it would choose
210   a row and close the popup.
211 
212 The list geometry is derived from `search_y()` / `list_y()` / `list_h()` rather
213 than the `let pad = 15.0; let search_h = 35.0;` locals that used to be repeated
214 in each of the paint, scroll and hit-test paths: the strip shifts the whole list
215 down by its own height, and a path that missed the shift would put the rows, the
216 clip and the click out of step. The icon gutter is per-tab
217 (`recompute_icon_gutter`), so the System page's rows sit flush left while the
218 Apps page keeps its column — and so is the row height: `item_h()` is
219 `ICON_ITEM_H` (36, for a 26px icon) while a gutter exists and `ITEM_H` (25)
220 otherwise. Every index→y path reads `item_h()`, and a gutter change re-runs
221 `update_scroll`, because the icons usually land after `set_items` has already
222 sized the scroll bounds for text rows.
223 
224 The strip is painted by hand in the toolkit's recessed `ButtonStrip` idiom — one
225 well carved into the window plate, segments on its floor, the active one a
226 raised `control_plate` — because this widget paints straight onto the `PaintCtx`
227 and has no child layout pass to host a real `ButtonStrip`.
228 
229 `SYSTEM_COMMANDS` is hardcoded, not config-driven: the rows are the DE's own
230 verbs, and a row naming a command `ccectl` does not have is one that silently
231 does nothing when picked (`every_system_row_runs_something` pins the lookup the
232 commit path makes). The window verbs act on the window *behind* the popup — the
233 compositor's `focused_window` skips overlay UI and names `cce-cloud` among it,
234 falling back to the most recent real window — which is the only reason "Close
235 Window" from a launcher means anything.
236 
237 ### Rendering: hand-rolled loop on `cce_ui::vk`, not the cce-ui engine runner
238 
239 Unlike most cce clients, this app does **not** implement the `Application` trait or use
240 `cce_ui::engine::run`. It owns its event loop directly (smithay-client-toolkit handlers
241 + calloop) and renders through **`cce_ui::vk::VkRenderer`** (the toolkit's raw-Vulkan
242 backend): `collect_display_list` builds a `PaintCtx` (beveled window plate, then the
243 active widget's `paint_self` walk — bevel/recess prims included), `upload_vertices`
244 runs it through `tessellate_display_list` into `State::vertex_data` + `frame_batches`
245 (`Batch2D`, physical-px scissors) + `plate_features`, `prepare_text` builds `TextSpan`s
246 from the paint walk (buffers shaped at logical size, spans scaled to physical), and
247 `render` is `draw_frame_2d(Frame2D { .. })`. Nothing in this path knows about the
248 open/close fade any more — see "Fading in and out" below. `tessellate` must carry the tessellator's image
249 list across to `Frame2D::images`: images ride a separate pipeline from the vertex
250 batches, and that return value was dropped (with `images: &[]` hardcoded) until
251 2026-08-16, which made `PaintCtx::image` a silent no-op *in this app only* while
252 it worked in every engine-runner client. `State::renderer` is an `Option`
253 so the daemon can take it back when a popup closes, and so `Drop` can tear the
254 swapchain down before destroying the `wl_surface` in standalone mode. It still reuses cce-ui pieces à la carte: the
255 narrow widget traits (`Layout`/`Paint`/`Input` via `Adapted<T>`), the scene paint walk
256 (`append_widget_text`) for text extraction, `color`/`layout`/`scale` getters, and the
257 `zcce_window_manager_v1` protocol. Follow existing cce-ui conventions when touching
258 widget code, but don't try to "port" this app onto the engine runner.
259 
260 ### One renderer for every popup
261 
262 The daemon keeps one `VkRenderer` for its whole life, like the font system:
263 `State::new` takes it and moves it onto the popup's new `wl_surface` with
264 `VkRenderer::attach_surface`, and when the popup closes the daemon takes it back
265 and calls `detach_surface` BEFORE the `State` drops (the drop destroys the
266 `wl_surface`, and a swapchain must not outlive it). `run_daemon` builds it at
267 startup on a scratch surface that is never mapped, so the first popup attaches
268 too. Until 2026-10-05 every popup built a new renderer, and its pipelines were
269 70-100 ms of a ~100 ms popup on an idle machine and several hundred under
270 load; a reopen is now one swapchain, and a menu popup is ready in ~2 ms and on
271 screen in 15-40 ms, the launcher in 30-60. **A popup draws nothing before its
272 surface's first configure** (`State::configured`, checked in `State::render`):
273 a buffer attached ahead of a layer surface's configure is a protocol error,
274 and the compositor disconnects the daemon, and with it every popup after.
275 The renderer used to take long enough to build that the configure always
276 won; once it was kept, the Super-Tab switcher's streamed rows asked for a
277 frame first and killed the daemon on its first open (fixed the same day,
278 before it reached a release that stayed installed). Note a shadow's idle
279 timeout turns its output off after 10 minutes, after which xdg toplevels
280 (the launcher) are never configured at all; `ccectl idle timeouts 0 0`
281 first. `open -> first frame with rows` is logged for list popups: a
282 streamed list's first frame can be empty. An image uploaded to it lives as
283 long as it does: upload through a cache like `icon_image`, never per popup,
284 or free it at close. Standalone mode still builds its own.
285 
286 Surface choice: an XDG toplevel flagged as popup via the cce window-management
287 protocol when the compositor global is present and no `-x/-y` was given; otherwise a
288 layer-shell **Overlay** surface with exclusive keyboard. The window continuously
289 auto-sizes to its content (`update_desired_size` — list rows measured with
290 `cce_ui::cosmic_text` buffers, the JSON panel's widgets with
291 `cce_ui::widget::display::measure_text`)
292 and closes through the DE-wide dissolve (`trigger_close`, below).
293 
294 ### Fading in and out
295 
296 The popup dissolves in when it maps and back out when it closes, and **neither
297 is drawn by this app** — both are the compositor ramping the opacity of the
298 surface's scene node, which carries the backdrop blur behind the popup down
299 with it. All this side does on close is ask and then wait:
300 `trigger_close` calls `cce_ui::ipc::request_close_fade()` (one `fade-out` line
301 on the compositor's control socket, answered with a duration in ms), stores
302 the deadline in `fade_until`, and keeps the surface mapped until the loop
303 sees it pass. Nothing is redrawn in between — the pixels stay put while the
304 scene node fades under them.
305 
306 This replaced a hand-rolled fade that multiplied every vertex and image alpha
307 by a factor and then dropped the SDF-plate batches outright. A plate batch
308 **is** its cover quad, and the window background is a plate, so the first
309 frame of every close fade deleted the entire background and left the rows and
310 text dissolving over nothing. That is the failure mode to remember: per-element
311 alpha cannot express a window fade, because a client's surface stays fully
312 present to the compositor no matter how transparent it draws itself — the blur
313 behind it does not fade, and shader-lit output (plate rims, specular) never
314 honoured the vertex alpha in the first place.
315 
316 `-x/-y` is a *cursor* position (the compositor hands the raw pointer to the desktop
317 and window-border context menus), not a final window origin — `Placement` fits it to
318 the output: grow away from the anchor, flip to its other side when the window would
319 overhang, clamp only when it fits on neither side, with the flip latched for the
320 popup's life so an auto-sizing list can't snap back and forth across the cursor.
321 Because the size is not known until the content is measured, the anchor/margins are
322 set by `apply_placement()` after the first `update_desired_size()` and re-applied by
323 `resize_window()` on every subsequent resize — a one-shot placement at surface
324 creation would use the pre-layout estimate and clip. The surface asks for
325 `exclusive_zone(-1)` so that the box it is clamped against (the wl_output logical
326 geometry) is the same one the compositor places it in.
327 
328 ### State flow
329 
330 Stdin/socket items land in a shared `Arc<Mutex<StdinState>>` written by a reader
331 thread; a calloop channel pings the main loop, which ingests via
332 `check_stdin_updates()` → refilter → resize → re-upload vertices. Rendering is
333 demand-driven off a `redraw` flag with a 16ms dispatch timeout.