git.lucas.co / cce-grid
desktop grid client
git clone https://git.lucas.co/cce-grid.git

CLAUDE.md (12.5K)

  1 # CLAUDE.md
  2 
  3 `cce-grid` is the desktop-grid client of the cce desktop: a cce-ui app the
  4 compositor world-anchors to the virtual desktop. It renders grid *patches* —
  5 virtual-rect regions at a compositor-chosen resolution — and is never in the
  6 pan/zoom loop: the compositor transforms the committed buffer per frame like
  7 any window content.
  8 
  9 The contract (cce window-management protocol, manager v6 / toplevel v4):
 10 `Application::grid() -> true` declares the role; `grid_patch` events say what
 11 to render; cce-ui's runner resizes, forwards to `Application::grid_patch`,
 12 and acks so the next commit latches at the new anchor. The compositor keeps
 13 its own rect grid as the fallback whenever this client is absent or has not
 14 latched a patch yet, and its gap-colored backdrop always draws beneath as
 15 the safety net beyond patch edges.
 16 
 17 Rendering is a pure function of (patch, style config): flat rounded cells,
 18 with the relief on the LINES — ONE `Prim::Lattice` for the whole patch
 19 (cce-ui shader mode 13): the pixel is folded into the grid period and the
 20 wall is measured from the NEAREST cell's edge outward over the roll, so
 21 the rail between two cells and the crossing where four meet are a single
 22 profile evaluation and join as true mitres. It replaced one `Recess` ring
 23 per cell (2026-09-14): those were N free overlays whose rounded corners
 24 stacked in colour space at every crossing and read as several overlapping
 25 effects, and whose walls — straddling a boundary inflated by the roll —
 26 overlapped each other down the rail centre whenever the roll exceeded a
 27 quarter gap. Cell floors carry NO relief (user decision); the rails read as
 28 raised grout. The compositor fallback still draws its expanded-ring scenefx
 29 chamfer (a different renderer, shown only until this client latches). `style.surface.desktop.line_relief` overrides
 30 the lip for the grid alone: a plain integer is the lip width in logical
 31 px (0 = no lip; unset = follow the DE-wide relief material), and a
 32 `(relief)` value carries a full custom material — width, depth, and wall
 33 profile (`cce_ui::relief_spec::ReliefSpec`), installed process-wide by
 34 this client (the grid and the desktop items below are all it draws) and
 35 edited in place with
 36 `cce-relief --key style.surface.desktop.line_relief`. The compositor
 37 fallback honors the integer form and a `(relief)` value's width (its
 38 scenefx chamfer has no custom profile to install). It reads the same `style.surface.desktop.*` /
 39 root-plate-radius keys as the fallback. Keep it that way — no camera
 40 state, no timers (`tick` is empty). Input is the one exception, and only
 41 over the desktop items below; the surface is transparent to the pointer
 42 everywhere else.
 43 
 44 ## The board (`src/board.rs`) — the desktop as a JSON Canvas
 45 
 46 Everything pinned to the desktop lives in ONE JSON Canvas file
 47 (jsoncanvas.org, Obsidian's `.canvas`): `Desktop.canvas` at the root of
 48 the notes vault (config.kdl `vault { path }`, as cce-notes reads it), so
 49 Obsidian and the user's other devices open the desktop as a canvas; with
 50 no vault, `$XDG_DATA_HOME/cce/desktop.canvas`. Obsidian-on-cce milestone 5.
 51 
 52 - **Nodes are items, one to one, node order = z-order.** `file` nodes are
 53   images, note cards (`.md`) or plain file cards by extension; `text`,
 54   `link`, `group` are their own kinds (`items::Kind`); an unknown type is
 55   kept as `Kind::Other`, undrawn, so a newer Obsidian's nodes survive.
 56   Edges (`board::Edge`) keep ids, sides, labels and `toEnd`.
 57 - **A save edits the canvas it read** (`apply_model` over
 58   `cce_vault::canvas`, which holds raw field text): untouched fields,
 59   unknown keys and number formatting go back byte for byte (a test pins
 60   it); a node whose type changed drops the old type's fields.
 61 - **Paths:** vault-relative inside the vault, absolute outside it. The
 62   desktop folder's images are outside, so Obsidian shows those nodes as
 63   missing files while cce draws them (user decision, 2026-10-01).
 64 - **Migration:** the first run with no board converts the old
 65   `desktop-items.json` (images only) and renames it `.json.migrated`. An
 66   unreadable board is never overwritten: the app runs with nothing.
 67 - **Outside edits:** a `cce_vault::VaultWatcher` on the vault reloads the
 68   board when it changed and this client did not write it (`Board::seen`),
 69   keeping textures by node id, and drops a note card's layout when its
 70   note changes. The save's temp file is a dot-file so watchers skip it.
 71 - **Cards** (note, text, link, file) draw through cce-ui's `MarkdownView`
 72   (`widget::markdown`, the `markdown` feature): laid out in virtual units
 73   (`layout_cards`, cached per node/width/text), painted with
 74   `paint_scaled(k = patch.scale)`. A link card lays out the URL alone,
 75   `link_glyph_room` narrower, and `paint_card` leads it with the `link`
 76   cce-icons glyph (it was a "🔗 " emoji in the text until 2026-10-05).
 77   Two traps, both fixed here:
 78   - **The scale factor.** The runner renders this surface at scale 1 but
 79     the toolkit-wide factor follows the output (2 on the laptop), and text
 80     shapes at it: `display_list` pins it to the surface's own scale, or
 81     text shapes at twice the size it is placed at.
 82   - **Text draws over every plate.** A card cannot hide the text of a card
 83     below it by covering it, so each card's text (and its label, and edge
 84     labels) is clipped to what higher items leave uncovered (`uncovered`,
 85     rect subtraction).
 86 - **Edges** are straight, side midpoint to side midpoint (the named side,
 87   or the one facing the other end), with an arrowhead; `touch` damages the
 88   box spanning both ends so a drag repaints the line.
 89 - **Input:** right-click menus by kind (Open in Notes / Open link, Convert
 90   to note for a text card, Connect to…, Disconnect, New note card, Remove);
 91   "Connect to…" is finished by the next press on another item;
 92   double-click opens a note in cce-notes (its socket, or a launch) and a
 93   link or file in its default app. Drops: a `.md` file becomes a note
 94   card, plain text a text card, a web URL that is not an image a link
 95   card. Card corners resize freely; images keep their aspect. Groups take
 96   no input and are not reported to the compositor.
 97 - Text cards are not edited in place (the surface never takes the
 98   keyboard): **Convert to note** makes a vault note of one, and **New note
 99   card** makes an empty note and opens it in cce-notes.
100 
101 ## Desktop items (`src/items.rs`)
102 
103 Images pinned to the world canvas. The compositor routes a drag over the
104 desktop background onto this client (its `Scene::at`, which has hit-tested the
105 grid layer through its input region since cce-compositor@b82a0ee — the separate
106 `at_including_grid` entry point it used to need is gone), so a
107 drop arrives at `handle_drop`; `drop_mimes()` declares the accepted flavors in
108 preference order. Pixels win whenever they are offered (`image/png`,
109 `image/jpeg`, `image/gif`, `image/webp`) — no fetch, no ambiguity. Below them
110 `text/html` is preferred over `text/uri-list` because it names the IMAGE: a
111 thumbnail wrapped in a link (Google Images' exact markup) puts the result page
112 in uri-list, and fetching that yields HTML rather than a picture. For an
113 unwrapped image the two agree, so the preference never does worse.
114 
115 A dropped image is saved into the desktop folder (`$XDG_DESKTOP_DIR` when
116 user-dirs exports one, else `~/Desktop`) AND recorded on the board with the
117 VIRTUAL-canvas position it landed at — so it comes back in the same world
118 spot next session. Fetching
119 shells out to `curl` rather than linking an HTTP stack: this process is a
120 background renderer that otherwise needs no network at all, and for a
121 once-in-a-while user action an async runtime plus a TLS stack would be the
122 largest thing in the binary.
123 
124 Items draw as GPU-textured quads. Decode happens on a worker thread and the
125 pixels return through `Message::ItemReady`, because the upload
126 (`cce_ui::vk::upload_rgba`) has to happen on the main loop — which is also why
127 `renderer_init` re-uploads every image restored from the board.
128 
129 They are the only reason this client takes input at all. `input_regions()`
130 returns exactly the item rects (and an empty list when there is no patch), so
131 the pointer passes straight through everywhere else. Over an item, left-drag
132 moves it — the grab offset is held in VIRTUAL units, so the gesture survives a
133 pan or zoom mid-drag — and right-click opens a one-button `cce-cloud --json`
134 popup at `ccectl pointer-location`, whose reply comes back as
135 `Message::MenuAction { node, action }`. It carries the canvas node id rather
136 than an index because that menu blocks on its own thread, and the list can be
137 reordered by a drag or grown by a drop while it is open.
138 
139 **Resize handles.** The compositor's `adjust` status topic (`on`/`off` as
140 window-adjust mode — overview, or Super held — comes and goes) is the one
141 thing this client subscribes to (`spawn_adjust_listener`, a plain std
142 thread on the status socket, reconnecting with backoff): it never holds
143 keyboard focus, so it cannot read Super for itself. While on, the item
144 UNDER THE POINTER (`hover_item` — the compositor shows its ring on the
145 hovered window the same way) draws four corner discs (`handle_discs`) in
146 the same `style.surface.border` colours and `handle_width` as the windows'
147 handles, the hovered one lit; a leave arrives as the off-screen move cce-ui
148 synthesizes and clears it;
149 a press on a disc starts a `Resize`, which scales the image
150 PROPORTIONALLY (the mean of the two edge ratios the drag asks for),
151 anchored on the opposite corner, and saves the board on release. A press
152 on the body still moves. The discs are sized in virtual units, so they
153 scale with the canvas rather than holding a screen size the way the
154 compositor's do — this client never learns the camera zoom.
155 
156 **Selection.** The compositor's overview drag-selection picks the items
157 up beside windows and a group move carries them (since 2026-09-30; see
158 `cce-compositor/CLAUDE.md`, "Overview drag-selection"). The compositor
159 knows the items only as this surface's input region, so this client
160 REPORTS them: `grid-items <id>:<x>:<y>:<w>:<h> ...` on the control socket
161 (`report_items`), virtual units, the whole list in draw order on every
162 change — load, a drop, a remove, a drag or resize of its own (a drag
163 release re-reports even unmoved, since the press raised the item and the
164 compositor's hit test wants the order). `DesktopItem::id` is a
165 per-process counter (`assign_id`): the board's identity is the canvas
166 node id. Reports go through one thread (`spawn_reporter`) so
167 they land in order and a compositor that is not up yet is retried, only
168 the latest pending. The other direction is the `selection` status topic
169 (`spawn_topic_listener`, which `adjust` now shares): `move <id>:<x>:<y>
170 ...` sets the positions as a group move steps (rect damage, like a drag of
171 this client's own — `Message::SelectionMove`) and `drop` saves the board
172 and reports afresh (`Message::SelectionDrop`). The highlight is the
173 compositor's, drawn over the image like a window's wash; this client draws
174 nothing for it. A press on a selected item never reaches this client (the
175 compositor takes it as the group grab); a press on an unselected one drops
176 the selection there and arrives here as an ordinary drag.
177 
178 One trap, spelled out on `Patch::surface_per_virtual`: pointer events and
179 input regions are surface-local px, and for THIS surface that means BUFFER
180 px at every output scale — the grid surface is pinned at buffer_scale 1
181 (cce-ui ignores scale events for grid apps; patch.scale is the sole
182 resolution authority), so `Patch::scale` is the one conversion for paint,
183 regions, and pointer math alike. This replaced a `/ui` division that had
184 been calibrated against the compositor's old hit-test, which handed out raw
185 layout offsets: numerically buffer/ui only at camera zoom 1 on the pow2
186 patch quantization, and at any other camera state it displaced the input
187 region off the items (presses read as background — in overview they EXITED
188 it) and tore the press position apart from the drag deltas, flinging the
189 grabbed item thousands of virtual units. The compositor's hit-test speaks
190 true surface coordinates since cce-compositor@feab593; do not reintroduce
191 output-scale terms here.
192 
193 This directory is its own git repository whose `origin` is GitHub; a
194 post-commit hook pushes each commit (git.lucas.co mirrors it hourly).
195 `cce-grid.service` autostarts it with the session
196 (WantedBy=cce-session.target); ccebuild installs both.
197 
198 Corner radii follow the DE-wide convention: the caller widens the nominal
199 radius by `cce_ui::layout::corner_span_factor()` (superellipse span
200 compensation) before handing it to the primitives, clamped to a quarter
201 sweep — same as the compositor's `widen_corner_radius`. An unwidened radius
202 reads nearly square at corner_shape > 2 and misses the window corners.