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

CLAUDE.md (12.9K)

  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` (~4.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 ## Build, test, run
 32 
 33 ```sh
 34 cargo build                      # standalone (this repo is its own workspace root)
 35 cargo build -p cce-cloud         # from the workspace root (avoids compositor rebuild)
 36 cargo test                       # unit tests live at the bottom of main.rs
 37 cargo test test_json_layout      # single test
 38 make install                     # release build, then `ccebuild install --no-build cce-cloud`
 39 ```
 40 
 41 Running requires a Wayland session (layer-shell). Quick manual checks:
 42 
 43 ```sh
 44 cce-cloud --path                         # launcher over $PATH executables
 45 printf "a\nb\nc\n" | cce-cloud --dmenu   # dmenu mode; selection prints to stdout
 46 echo '{"widgets":[{"type":"button","id":"ok","text":"OK"}]}' | cce-cloud --json
 47 cce-cloud --daemon                       # run the daemon (normally started by the DE)
 48 ```
 49 
 50 ## Architecture
 51 
 52 ### Daemon / client / standalone
 53 
 54 `main()` (bottom of `src/main.rs`) picks one of three roles:
 55 
 56 - `--daemon`: `run_daemon()` binds `/run/user/$UID/cce-cloud.socket` (fallback
 57   `/tmp/cce-cloud-$UID.socket`), holds one long-lived Wayland connection, and serves
 58   **one popup at a time, serially**. A new client connection **preempts** the currently
 59   open popup (global single-popup semantics); a client hangup closes the popup
 60   (`StdinState.client_gone`).
 61 - No `--daemon`: `run_client()` connects to that socket, sends one JSON line
 62   `{"args": [...], "initial_stdin": "..."}`, streams any further stdin lines over the
 63   socket, and blocks until the daemon writes the selection back — which it prints to
 64   stdout. So callers get dmenu semantics whether or not the daemon is running.
 65 - If the socket connect fails, it falls back to `run_standalone()`: the same UI
 66   in-process, selection printed directly to stdout.
 67 
 68 The daemon re-parses the forwarded args with the same flag loop as standalone — **flag
 69 changes must be made in both `run_standalone()` and `run_daemon()`** (and, for the
 70 needs-stdin decision, in `run_client()`).
 71 
 72 ### Modes (`LauncherMode`)
 73 
 74 - `Dmenu` — items from stdin, selection echoed out. Magic stdin lines
 75   `__cce_switcher_next__` / `__cce_switcher_select_and_close__` drive the compositor's
 76   window switcher (`--switcher` starts in Dmenu with Super held).
 77 - `Path` — executables scanned from `$PATH`.
 78 - `Apps` — `.desktop` files from the standard application dirs, sorted by launch
 79   frecency persisted in `~/.cache/cce-cloud-apps.json`; selecting spawns the app's
 80   `Exec` (spawn output logged to `$XDG_RUNTIME_DIR/cce/spawn.log`, via
 81   `cce_ui::config::cce_runtime_dir()` — it was `/tmp/cce-spawn.log` before
 82   2026-08-22). Each entry's `Icon=` is
 83   resolved through `cce_ui::icon` and drawn in a gutter left of the label — the
 84   gutter is applied to every row, so one unresolvable icon doesn't rag the text
 85   edge. This is the *only* mode with icons: Dmenu/Path items are arbitrary
 86   strings with nothing to look up, and `set_item_icons` leaves their gutter at 0.
 87   Icons are uploaded per popup on purpose (`cce_ui::icon::upload_themed` caches
 88   the decode, not the image id) because `Drop` destroys this app's `VkRenderer`
 89   between popups and an id cached across them would name freed GPU resources.
 90   Apps is also the one **tabbed** mode: the list carries an *Apps* page and a
 91   *System* page of DE verbs (`SYSTEM_COMMANDS` — window-manager actions through
 92   `ccectl`, plus session/power commands), and **Tab / Shift+Tab step between
 93   them**. See "Tabs" below.
 94 - `Json` — a `JsonLayoutConfig` read from stdin builds a widget panel; clicking a
 95   button prints one JSON object with the button id and every control's state
 96   (`{"button", "checkboxes", "spinboxes", "colors", "sliders"}`) and closes.
 97 
 98 Key flags: `-p/--prompt`, `-s/--select <item>`, `-x/-y` (position → forces layer-shell
 99 anchoring), `--align-right`, `--parent-app-id` (app_id becomes `cce-cloud:<parent>`),
100 `--switcher`, and mode flags `--apps|--path|--dmenu|--json` (or `--mode <m>`).
101 
102 ### Tabs
103 
104 `FuzzelWidget::tabs` splits the list into `TabPage`s. Fewer than two draws no
105 strip and claims no height (`tab_strip_h()` is 0), which is what leaves Dmenu,
106 Path and the Super-Tab window switcher laid out and keyed exactly as they were —
107 **Tab only switches tabs where tabs exist**, and falls back to its old job of
108 cycling the highlight everywhere else. That fallback is load-bearing: the
109 switcher is Dmenu mode, and its Tab is the key the whole feature is named for.
110 
111 Three things to know before touching it:
112 
113 - **The active page's items and query live in `all_items` / `query`, not in its
114   `TabPage`.** Every pre-existing caller reads them there, and only
115   `switch_tab` moves them across — so a page's own copies are stale for as long
116   as it is the active one. Parking the query is what makes switching back land
117   on the same filtered rows.
118 - **The stdin/socket feed addresses tab 0, through `set_tab_items`,** never
119   `set_items` directly. `check_stdin_updates` skips the ingest when the items
120   match what it last pushed; compared against the *active* tab that test would
121   differ on every poll and clobber the page the user is reading.
122 - **A tab click is handled in the pointer branch, ahead of the row branch** —
123   next to the scrollbar press, and for the same reason. Any press
124   `FuzzelWidget::on_event` resolves is treated there as a row selection and (in
125   Dmenu/switcher mode) committed, so a tab click routed through it would choose
126   a row and close the popup.
127 
128 The list geometry is derived from `search_y()` / `list_y()` / `list_h()` rather
129 than the `let pad = 15.0; let search_h = 35.0;` locals that used to be repeated
130 in each of the paint, scroll and hit-test paths: the strip shifts the whole list
131 down by its own height, and a path that missed the shift would put the rows, the
132 clip and the click out of step. The icon gutter is per-tab
133 (`recompute_icon_gutter`), so the System page's rows sit flush left while the
134 Apps page keeps its column.
135 
136 The strip is painted by hand in the toolkit's recessed `ButtonStrip` idiom — one
137 well carved into the window plate, segments on its floor, the active one a
138 raised `control_plate` — because this widget paints straight onto the `PaintCtx`
139 and has no child layout pass to host a real `ButtonStrip`.
140 
141 `SYSTEM_COMMANDS` is hardcoded, not config-driven: the rows are the DE's own
142 verbs, and a row naming a command `ccectl` does not have is one that silently
143 does nothing when picked (`every_system_row_runs_something` pins the lookup the
144 commit path makes). The window verbs act on the window *behind* the popup — the
145 compositor's `focused_window` skips overlay UI and names `cce-cloud` among it,
146 falling back to the most recent real window — which is the only reason "Close
147 Window" from a launcher means anything.
148 
149 ### Rendering: hand-rolled loop on `cce_ui::vk`, not the cce-ui engine runner
150 
151 Unlike most cce clients, this app does **not** implement the `Application` trait or use
152 `cce_ui::engine::run`. It owns its event loop directly (smithay-client-toolkit handlers
153 + calloop) and renders through **`cce_ui::vk::VkRenderer`** (the toolkit's raw-Vulkan
154 backend): `collect_display_list` builds a `PaintCtx` (beveled window plate, then the
155 active widget's `paint_self` walk — bevel/recess prims included), `upload_vertices`
156 runs it through `tessellate_display_list` into `State::vertex_data` + `frame_batches`
157 (`Batch2D`, physical-px scissors) + `plate_features`, `prepare_text` builds `TextSpan`s
158 from the paint walk (buffers shaped at logical size, spans scaled to physical), and
159 `render` is `draw_frame_2d(Frame2D { .. })`. Nothing in this path knows about the
160 open/close fade any more — see "Fading in and out" below. `tessellate` must carry the tessellator's image
161 list across to `Frame2D::images`: images ride a separate pipeline from the vertex
162 batches, and that return value was dropped (with `images: &[]` hardcoded) until
163 2026-08-16, which made `PaintCtx::image` a silent no-op *in this app only* while
164 it worked in every engine-runner client. `State::renderer` is an `Option`
165 solely so `Drop` can tear the swapchain down before destroying the `wl_surface` (daemon
166 mode churns one `State` per popup). It still reuses cce-ui pieces à la carte: the
167 narrow widget traits (`Layout`/`Paint`/`Input` via `Adapted<T>`), the scene paint walk
168 (`append_widget_text`) for text extraction, `color`/`layout`/`scale` getters, and the
169 `zcce_window_manager_v1` protocol. Follow existing cce-ui conventions when touching
170 widget code, but don't try to "port" this app onto the engine runner.
171 
172 Surface choice: an XDG toplevel flagged as popup via the cce window-management
173 protocol when the compositor global is present and no `-x/-y` was given; otherwise a
174 layer-shell **Overlay** surface with exclusive keyboard. The window continuously
175 auto-sizes to its content (`update_desired_size` — list rows measured with
176 `cce_ui::cosmic_text` buffers, the JSON panel's widgets with
177 `cce_ui::widget::display::measure_text`)
178 and closes through the DE-wide dissolve (`trigger_close`, below).
179 
180 ### Fading in and out
181 
182 The popup dissolves in when it maps and back out when it closes, and **neither
183 is drawn by this app** — both are the compositor ramping the opacity of the
184 surface's scene node, which carries the backdrop blur behind the popup down
185 with it. All this side does on close is ask and then wait:
186 `trigger_close` calls `cce_ui::ipc::request_close_fade()` (one `fade-out` line
187 on the compositor's control socket, answered with a duration in ms), stores
188 the deadline in `fade_until`, and keeps the surface mapped until the loop
189 sees it pass. Nothing is redrawn in between — the pixels stay put while the
190 scene node fades under them.
191 
192 This replaced a hand-rolled fade that multiplied every vertex and image alpha
193 by a factor and then dropped the SDF-plate batches outright. A plate batch
194 **is** its cover quad, and the window background is a plate, so the first
195 frame of every close fade deleted the entire background and left the rows and
196 text dissolving over nothing. That is the failure mode to remember: per-element
197 alpha cannot express a window fade, because a client's surface stays fully
198 present to the compositor no matter how transparent it draws itself — the blur
199 behind it does not fade, and shader-lit output (plate rims, specular) never
200 honoured the vertex alpha in the first place.
201 
202 `-x/-y` is a *cursor* position (the compositor hands the raw pointer to the desktop
203 and window-border context menus), not a final window origin — `Placement` fits it to
204 the output: grow away from the anchor, flip to its other side when the window would
205 overhang, clamp only when it fits on neither side, with the flip latched for the
206 popup's life so an auto-sizing list can't snap back and forth across the cursor.
207 Because the size is not known until the content is measured, the anchor/margins are
208 set by `apply_placement()` after the first `update_desired_size()` and re-applied by
209 `resize_window()` on every subsequent resize — a one-shot placement at surface
210 creation would use the pre-layout estimate and clip. The surface asks for
211 `exclusive_zone(-1)` so that the box it is clamped against (the wl_output logical
212 geometry) is the same one the compositor places it in.
213 
214 ### State flow
215 
216 Stdin/socket items land in a shared `Arc<Mutex<StdinState>>` written by a reader
217 thread; a calloop channel pings the main loop, which ingests via
218 `check_stdin_updates()` → refilter → resize → re-upload vertices. Rendering is
219 demand-driven off a `redraw` flag with a 16ms dispatch timeout.