git.lucas.co / cce-graph
node-based graph editor
git clone https://git.lucas.co/cce-graph.git

CLAUDE.md (7.7K)

  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-graph` is a node-graph / mood-board editor client for the CCE Wayland desktop
  8 environment: a grid-aligned canvas of connected nodes plus free-floating images, saved
  9 as KDL project directories. It is one crate of the multi-repo `cce` workspace (its own
 10 git repo side-by-side with its siblings; `origin` is GitHub and a post-commit hook
 11 pushes each commit, with git.lucas.co an hourly mirror). Read the workspace-level
 12 `../cce-compositor/WORKSPACE.md` first — workspace layout, the `cce-ui` toolkit, config
 13 conventions, and the multi-repo rules (each crate is its own git repo; commit here, not at
 14 the workspace root) all live there.
 15 
 16 The project editor is `src/main.rs`: a `GraphApp` struct implementing
 17 `cce-ui`'s `Application` trait, run via `cce_ui::engine::run::<GraphApp>()`.
 18 `src/wiring.rs` gives nodes their session ids and turns a wire drawn with the
 19 mouse (the widget's pending connection) into the port's parameter — before it,
 20 nodes loaded with empty ids and drawn wires were dropped. The actual node-canvas widget (`Graph`, `GraphNode`) lives in `cce-ui`, not
 21 here — this crate is orchestration: menu bar + File/Edit/View dropdowns, the `Graph`
 22 widget, an image overlay, and a floating "control panel" showing the selected
 23 node/image.
 24 
 25 ## Vault mode (`cce-graph --vault`)
 26 
 27 A second, separate `Application` (`src/vault.rs`, model in
 28 `src/linkgraph.rs`): the notes vault as a link graph, milestone 4 of the
 29 Obsidian-on-cce plan. `main` runs it when the arguments carry `--vault`
 30 (`cce-graph --vault [DIR] [--local [NOTE]]`; DIR defaults to the vault in
 31 config.kdl, as cce-notes reads it). Nothing it does touches the project
 32 editor.
 33 
 34 - **Model:** a node per note plus a grey ghost per link target that does
 35   not exist; one edge per linked pair. Built from `cce_vault::Index`, and
 36   rebuilt on every watcher batch keeping positions and pins.
 37 - **Layout:** force-directed (repulsion through a spatial grid of
 38   `REPULSE_RANGE` cells, springs, a centering pull), stepped in `tick`
 39   while `alpha` cools; once settled the app goes idle. The camera fits
 40   once the layout has spread and again when it settles, unless the user
 41   has panned or zoomed.
 42 - **Local graph:** N hops (Depth chip, Ctrl+=/−) around the note open in
 43   cce-notes, polled once a second as `current` on cce-notes' instance
 44   socket (`idle_poll_interval` runs only in local mode). A click on a node
 45   sends `open <abs path>` over that socket, or launches cce-notes.
 46 - **Single instance** on `/tmp/cce-graph-vault-<display>.sock`; a second
 47   launch forwards `local [note]` / `global` and exits — cce-notes' Ctrl+G
 48   relies on that.
 49 - **Filter box:** words match names, `#tag` / `tag:` tags (nested too),
 50   `path:` paths. Drag pins a node, right-click unpins.
 51 - Labels fade in from zoom 1.1; the hovered node's neighbourhood is lit
 52   and labelled whatever the zoom.
 53 
 54 `cargo test -p cce-graph` covers the model (build, ghosts, hops, the layout
 55 settling with links short, filters, hit-testing) and the editor's wiring.
 56 
 57 ## Build and run
 58 
 59 ```sh
 60 cargo build -p cce-graph        # from the workspace root (shared ../target/)
 61 cargo run -p cce-graph          # optionally pass a project path as the first arg
 62 make install                    # release build, then `ccebuild install --no-build cce-graph`
 63 ```
 64 
 65 Building from inside this directory also works (standalone clone case). `cargo run`
 66 needs a running Wayland session — ideally the `cce` compositor.
 67 
 68 ## Persistence model
 69 
 70 - **A "project" is a directory** containing `state.kdl` plus `assets/` and `code/`
 71   subdirs. `save_project_to_path` creates all three; images added while a project is
 72   loaded are copied into `assets/` and referenced by relative path. Legacy
 73   `state.json` projects still load; saving writes `state.kdl` and deletes the old
 74   JSON.
 75 - The KDL schema is hand-rolled in `load_project_from_kdl_path` /
 76   `save_project_to_kdl_path` (top-level `name`/`show_grid`/`opacity`, then `node` and `image` blocks). Keep
 77   both functions in sync when changing it. A `uniform_background` line in an
 78   older save is ignored: the graph has had no fill of its own since 2026-09-29.
 79 - With no CLI arg, the app loads (creating if missing)
 80   `~/.config/cce/cce-graph/default.kdl` — a bare KDL state file, not a project dir.
 81 - View settings persist to the **shared** `~/.config/cce/config.kdl` under
 82   `layout` (`graph_show_grid`, `graph_snap_enabled`, `graph_network_opacity`,
 83   `graph_gap_width`) — see
 84   `load_config()` / `write_config_value()`.
 85 - The delete-node keybinding resolves through `input.kdl`'s `cce-graph.delete_node`
 86   (via `cce_ui::input::app_chord`), falling back to the legacy config.kdl value.
 87 - Recent files are shared toolkit state (`cce_ui::config::load_recent_files`),
 88   capped at 10, surfaced inside the File dropdown's options list.
 89 
 90 ## Architecture: the "dissolved" Phase-6 style
 91 
 92 This app is the reference for cce-ui's post-Phase-6 shape — no container widgets, one
 93 paint path. When editing, preserve these invariants (the inline comments citing phase
 94 numbers, e.g. "6l pattern", "6m recipe", document them deliberately):
 95 
 96 - **Single paint path**: everything renders in `display_list()` — relayout when
 97   `needs_rebuild`/resize, then the window plate is emitted as raw prims, top-level
 98   widgets are walked with `paint_root_into` (shared borrows), and finally the control
 99   panel and images are drawn on top. There is no `view()`; text renders from the
100   paint walk (`display_list_text()` returns true).
101 - **No root-plate/Plate containers**: top-level widgets register **parentless** in
102   `UiContext` (one-time `register_widget` block guarded by `widgets_registered`,
103   using raw pointers — the widgets must stay owned fields of `GraphApp` so those
104   pointers stay valid). The former control-panel Plate is "dissolved": its rect,
105   drag state, and visual are app fields (`panel_*`, `panel_visual()`), its plate is
106   emitted as prims, and only its `Label` is a real walked widget.
107 - **Popovers are ui_context-only**: open dropdowns call
108   `ui_context.register_popover` each frame. Do NOT also register them globally —
109   that spawns a render-only xdg popup that swallows clicks on the open menu.
110 - **Routed events**: input goes through `ui_context.propagate_event(&event, root_id)`
111   with `WidgetId` roots (dropdowns get priority when `over_menu`; otherwise the
112   graph). Two drags are deliberately app-owned rather than widget-routed: the control
113   panel and loaded images (`dragging_image_idx`). The router owns node drags —
114   `is_dragging` forces rebuilds mid-drag, and DragEnd commits before the release
115   reaches `Graph`.
116 - **Dropdown selection protocol**: menu dropdowns use sentinel `selected = 999`
117   ("nothing chosen"); on `take_change()` the app maps the selected option to an
118   `AppMessage` and resets to 999. File-menu entries are matched by option **text**
119   (recent-file paths are pushed straight into `options`), so renaming an entry means
120   updating the match arm.
121 - **Rebuild flags are dual**: handlers set both the `*needs_rebuild` out-param (frame
122   redraw) and `self.needs_rebuild` (relayout in `display_list`). Set both.
123 
124 ## Quirks worth knowing
125 
126 - Images are decoded, downscaled to max 96px on the long edge, and drawn as
127   **per-pixel quads** clipped to the graph rect — image size on the canvas is in grid
128   cells (width drives height via aspect ratio). Positions are (column, row) floats;
129   snap rounds to half-cells.
130 - Blocking file dialogs run on spawned threads and send results back through the
131   calloop message channel (`AppMessage::OpenRecent` / `SaveToPath` /
132   `AddImageFromPath`); don't call `cce_ui::file_dialog` on the UI thread.
133 - `main()` creates a tokio runtime and enters it before `engine::run` — cce-ui
134   (e.g. its MCP server) expects an ambient runtime.