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.