node-based graph editor
git clone https://git.lucas.co/cce-graph.git
feat: open new projects on a blank canvas; add CLAUDE.md
An empty KDL document parses to exactly the loader's defaults, so the seed file
no longer needs the three placeholder nodes — it just has to exist.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
CLAUDE.md | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/main.rs | 34 ++++-----------------
2 files changed, 104 insertions(+), 29 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..2e3a877
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,99 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## What this is
+
+`cce-graph` is a node-graph / mood-board editor client for the CCE Wayland desktop
+environment: a grid-aligned canvas of connected nodes plus free-floating images, saved
+as KDL project directories. It is one crate of the multi-repo `cce` workspace (cloned
+side-by-side with its siblings; remote: `codeberg.org/lsgalante/clear-graph`). Read the
+workspace-level `../CLAUDE.md` first — workspace layout, the `cce-ui` toolkit, config
+conventions, and the multi-repo rules (each crate is its own git repo; commit here, not
+at the workspace root) all live there.
+
+The entire app is **one file, `src/main.rs`**: a `GraphApp` struct implementing
+`cce-ui`'s `Application` trait, run via `cce_ui::engine::run::<GraphApp>()`. There are
+no tests. The actual node-canvas widget (`Graph`, `GraphNode`) lives in `cce-ui`, not
+here — this crate is orchestration: menu bar + File/Edit/View dropdowns, the `Graph`
+widget, an image overlay, and a floating "control panel" showing the selected
+node/image.
+
+## Build and run
+
+```sh
+cargo build -p cce-graph # from the workspace root (shared ../target/)
+cargo run -p cce-graph # optionally pass a project path as the first arg
+make install # release build + copy ../target/release/cce-graph to ~/.local/bin
+```
+
+Building from inside this directory also works (standalone clone case). `cargo run`
+needs a running Wayland session — ideally the `cce` compositor.
+
+## Persistence model
+
+- **A "project" is a directory** containing `state.kdl` plus `assets/` and `code/`
+ subdirs. `save_project_to_path` creates all three; images added while a project is
+ loaded are copied into `assets/` and referenced by relative path. Legacy
+ `state.json` projects still load; saving writes `state.kdl` and deletes the old
+ JSON.
+- The KDL schema is hand-rolled in `load_project_from_kdl_path` /
+ `save_project_to_kdl_path` (top-level `name`/`show_grid`/`uniform_background`/
+ `opacity`, then `node` and `image` blocks). Keep both functions in sync when
+ changing it.
+- With no CLI arg, the app loads (creating if missing)
+ `~/.config/cce/cce-graph/default.kdl` — a bare KDL state file, not a project dir.
+- View settings persist to the **shared** `~/.config/cce/config.kdl` under
+ `layout` (`graph_show_grid`, `graph_snap_enabled`, `graph_network_opacity`,
+ `graph_gap_width`) and `style.surface.graph.uniform_background` — see
+ `load_config()` / `write_config_value()`.
+- The delete-node keybinding resolves through `input.kdl`'s `cce-graph.delete_node`
+ (via `cce_ui::input::app_chord`), falling back to the legacy config.kdl value.
+- Recent files are shared toolkit state (`cce_ui::config::load_recent_files`),
+ capped at 10, surfaced inside the File dropdown's options list.
+
+## Architecture: the "dissolved" Phase-6 style
+
+This app is the reference for cce-ui's post-Phase-6 shape — no container widgets, one
+paint path. When editing, preserve these invariants (the inline comments citing phase
+numbers, e.g. "6l pattern", "6m recipe", document them deliberately):
+
+- **Single paint path**: everything renders in `display_list()` — relayout when
+ `needs_rebuild`/resize, then the window plate is emitted as raw prims, top-level
+ widgets are walked with `paint_root_into` (shared borrows), and finally the control
+ panel and images are drawn on top. There is no `view()`; text renders from the
+ paint walk (`display_list_text()` returns true).
+- **No Backplate/Plate containers**: top-level widgets register **parentless** in
+ `UiContext` (one-time `register_widget` block guarded by `widgets_registered`,
+ using raw pointers — the widgets must stay owned fields of `GraphApp` so those
+ pointers stay valid). The former control-panel Plate is "dissolved": its rect,
+ drag state, and visual are app fields (`panel_*`, `panel_visual()`), its plate is
+ emitted as prims, and only its `Label` is a real walked widget.
+- **Popovers are ui_context-only**: open dropdowns call
+ `ui_context.register_popover` each frame. Do NOT also register them globally —
+ that spawns a render-only xdg popup that swallows clicks on the open menu.
+- **Routed events**: input goes through `ui_context.propagate_event(&event, root_id)`
+ with `WidgetId` roots (dropdowns get priority when `over_menu`; otherwise the
+ graph). Two drags are deliberately app-owned rather than widget-routed: the control
+ panel and loaded images (`dragging_image_idx`). The router owns node drags —
+ `is_dragging` forces rebuilds mid-drag, and DragEnd commits before the release
+ reaches `Graph`.
+- **Dropdown selection protocol**: menu dropdowns use sentinel `selected = 999`
+ ("nothing chosen"); on `take_change()` the app maps the selected option to an
+ `AppMessage` and resets to 999. File-menu entries are matched by option **text**
+ (recent-file paths are pushed straight into `options`), so renaming an entry means
+ updating the match arm.
+- **Rebuild flags are dual**: handlers set both the `*needs_rebuild` out-param (frame
+ redraw) and `self.needs_rebuild` (relayout in `display_list`). Set both.
+
+## Quirks worth knowing
+
+- Images are decoded, downscaled to max 96px on the long edge, and drawn as
+ **per-pixel quads** clipped to the graph rect — image size on the canvas is in grid
+ cells (width drives height via aspect ratio). Positions are (column, row) floats;
+ snap rounds to half-cells.
+- Blocking file dialogs run on spawned threads and send results back through the
+ calloop message channel (`AppMessage::OpenRecent` / `SaveToPath` /
+ `AddImageFromPath`); don't call `cce_ui::file_dialog` on the UI thread.
+- `main()` creates a tokio runtime and enters it before `engine::run` — cce-ui
+ (e.g. its MCP server) expects an ambient runtime.
diff --git a/src/main.rs b/src/main.rs
index dd1f2ba..c99ddab 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -94,35 +94,11 @@ fn ensure_default_project_file(path: &std::path::Path) -> Result<(), Box<dyn std
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
- let default_kdl = r#"name "default"
-show_grid true
-uniform_background false
-opacity 0.95
-
-node "Data Source" {
- position 1.0 1.0
- inputs 0
- outputs 1
- geom_visible true
-}
-
-node "Filter" {
- position 3.0 1.0
- inputs 1
- outputs 1
- geom_visible true
- parameter "input" value="Data Source" type="string"
-}
-
-node "Render Output" {
- position 5.0 2.0
- inputs 1
- outputs 1
- geom_visible true
- parameter "input" value="Filter" type="string"
-}
-"#;
- std::fs::write(path, default_kdl)?;
+ // Seeded empty: an empty KDL document parses to exactly the loader's defaults
+ // (`name "default"`, `show_grid true`, `uniform_background false`, `opacity 0.95` — see
+ // `load_project_from_kdl_path`), so the app opens on a blank canvas. The file itself still has
+ // to exist, since loading reads it directly.
+ std::fs::write(path, "")?;
Ok(())
}