structured data editor
git clone https://git.lucas.co/cce-data-editor.git
CLAUDE.md (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-data-editor` is the KDL config editor for the CCE Wayland desktop environment: a
8 GUI app for browsing and editing KDL files (primarily `~/.config/cce/config.kdl` and
9 `input.kdl`). It is one crate of the multi-repo `cce` workspace — this repo is cloned
10 side-by-side with its siblings and depends on `cce-ui = { path = "../cce-ui" }`, so a
11 checkout of `../cce-ui` must exist to build. The workspace-level
12 `../cce-compositor/WORKSPACE.md` (when present) covers the whole desktop environment; commit
13 in THIS repo, never at the workspace root.
14
15 The entire app is **one file: `src/main.rs`** (~3.1k lines) — a `DataEditorApp` struct
16 implementing `cce_ui::engine::Application`, run by `cce_ui::engine::run::<DataEditorApp>()`.
17 There are no modules to navigate; use the section landmarks below.
18
19 ## Build, run, test
20
21 ```sh
22 cargo build --release # binary lands in ../target/release/ (shared workspace target)
23 cargo run -- <file.kdl> # optional file argument auto-loads on startup
24 make install # release build, then `ccebuild install --no-build cce-data-editor`
25 cargo test # see caveat below
26 ```
27
28 Running requires a live Wayland session (it is a raw Wayland client, not X11/toolkit).
29
30 **Test caveat**: both `#[cfg(test)]` tests read the user's real
31 `~/.config/cce/config.kdl` (via `cce_ui::config::get_config_path()`) and
32 `test_kdl_roundtrip` additionally asserts a specific key
33 (`style.status.background_color`) exists in it. They are environment-dependent
34 integration checks of the flatten/unflatten/span machinery, not hermetic unit tests —
35 failures may mean the local config changed, not that code broke.
36
37 ## Architecture
38
39 ### Data model: KDL ⇄ JSON ⇄ flat keys
40
41 The core state is `flat_keys: Vec<(String, serde_json::Value)>` — the KDL document
42 parsed to JSON (`cce_ui::config::parse_kdl_to_json`) then flattened to dotted paths
43 with bracket indices (`style.status.background_color`, `outputs[0].mode`). Three
44 representations are kept in sync:
45
46 - **Raw KDL text** — the right-pane multiline `TextBox` (`raw_json_editor`; the name
47 is historical, it holds KDL). Ground truth for save/format.
48 - **`flat_keys`** — drives the left-pane `TreeList` and all value edits.
49 - **On-screen selection** — `selected_key_idx` into `flat_keys`.
50
51 Edits flow one of two ways:
52 - Tree/editor side: mutate `flat_keys[idx].1` → `update_raw_from_flat()` →
53 `unflatten_json` → `json_to_kdl_string_with_annotations` regenerates the raw text
54 (preserving KDL type annotations harvested from the current text).
55 - Raw side: `raw_json_editor.take_change()` in `tick()` reparses the text and rebuilds
56 `flat_keys` (only if the KDL parses), re-resolving the selection by key name.
57
58 `parse_path` / `find_kdl_span` map a flat path to a byte span in the KDL source. This
59 powers bidirectional selection sync: clicking a tree row highlights its span in the raw
60 editor (`sync_preview_selection`), and clicking in the raw editor selects the tree row
61 whose span most tightly contains the cursor (smallest-span-wins search in
62 `handle_mouse_input`).
63
64 Save/format refuse to run when the text is not valid KDL; an invalid document still
65 loads (best-effort parse) with an error in the status bar.
66
67 ### Type-driven inline value editors
68
69 Eleven editor widgets exist permanently as fields (`selected_*_editor`); exactly one is
70 positioned inline over the selected tree row, the rest are parked off-screen at
71 `(-1000, -1000)` (the "hide" convention — there is no visibility flag). Which editor
72 appears is decided in two places that must stay in agreement: the layout block in
73 `display_list` and the click handler in `handle_mouse_input`. The decision keys off:
74
75 - **KDL type annotations** in the document (read via
76 `cce_ui::config::get_kdl_type_annotation`): `menu:a,b,c` → `Dropdown`,
77 `button` / `button:<shell-cmd>` → `Button` (clicking spawns the command),
78 `keybind` → `KeybindRecorder`, `f64:min-max` → clamps applied values,
79 `bevel` → `BevelPreview` (a mini relief cross-section of the
80 "shoulder,base,bias" value; clicking opens `cce-relief`, whose Save is picked
81 up by the disk-sync watch). The annotation stays `bevel` — it is a config key,
82 and did not follow the tool's rename from `cce-bevel`.
83 `relief` → the same `BevelPreview`, seeded from the `(relief)` value's `k=`
84 knob triple (`cce_ui::relief_spec::ReliefSpec`), but clicking opens
85 `cce-relief --key <flat.path>` so Save rewrites that single value instead of
86 the file's shared material. Keys NAMED `line_relief` get this treatment even
87 while their value is still a plain integer (the annotation only exists once
88 a material was saved) — same key-name-heuristic convention as fonts.
89 `ramp` → `RampPreview` (the spec's value curve as a polyline); clicking
90 opens `cce-ramp --key <flat.path>`, whose Save writes the spec back with
91 the `(ramp)` annotation. Ramp-named keys (`ramp` / `*_ramp`) whose string
92 value parses as a ramp spec get the treatment before the annotation exists
93 (`overview_ramp` shipped unannotated).
94 A **unit annotation** (`px` / `mm` / `cm` / `in` / `pt`, e.g. `width=(mm)2.0`), or
95 any string value `cce_ui::units::Len` parses (`"2mm"` — which is exactly what
96 `kdl_to_json` makes of the annotated number) → a two-decimal `Spinbox`
97 (`selected_len_editor`) beside a unit `Dropdown` (`selected_unit_editor`).
98 Changing the number writes `"<n><unit>"`, which the KDL writer emits as
99 `(<unit>)<n>`; changing the unit keeps the LENGTH — converted through
100 `cce_ui::units::metric()`, the display's px-per-mm — and rewrites the number,
101 so 2 mm switched to inches reads 0.08 and a px value switched to mm reads what
102 it measures on this panel. Both sites (`display_list`, click) key off
103 `is_len_type`; the commit paths are `commit_len` / `sync_len_editors`.
104 - **Key-name heuristics**: `font` / `*_font` / `*.font` → `FontSelector`; keybind-ish
105 names (`key`, `shortcut`, `brightness_up`, …) → `KeybindRecorder`.
106 - **Value shape**: `#`-prefixed string → `ColorSelector`, bool → `Checkbox`,
107 integer → `Spinbox`, everything else → plain `TextBox` (values are entered as JSON;
108 bare words fall back to strings).
109
110 Editor commits are polled in `tick()` via each widget's `take_change()`, all funneling
111 into the same mutate-`flat_keys` → `update_raw_from_flat()` → `sync_preview_selection()`
112 sequence.
113
114 ### cce-ui "Phase 6" conventions (post-container)
115
116 This app tracks the current cce-ui architecture; mirror these patterns when touching UI
117 code, and don't reintroduce the retired ones:
118
119 - **No root container.** The root plate container and the `SplitBox` are dissolved.
120 Top-level widgets are parentless, registered once with
121 `ui_context.register_widget(id, ptr)` in the first `display_list` call, and painted
122 as separate roots via `cce_ui::scene::painter::paint_root_into` (shared borrows).
123 - **Single paint path.** Everything renders through `display_list` into one `PaintCtx`
124 — window plate quad, widget walk, splitter divider quad, the toolbar "File:" label,
125 then popovers and the context menu drawn last directly into the list (the engine's
126 xdg-popup path is gone; `display_list_text()` returns `true`).
127 - **`SplitPane`** (app-owned struct at the top of the file) replaces `SplitBox`: it
128 keeps only the divider drag/hover state and fraction; pane rects come from the scene
129 solver.
130 - **Layout via the scene solver**: `display_list` builds a small
131 `cce_ui::scene::layout` arena (column: menubar 42px / content row with the two panes
132 growing by `split.frac` / statusbar 30px) and assigns solved rects with `set_rect`.
133 The inline value editors are hand-positioned at `row_x + 245.0` over the tree row.
134 - **Routed events**: input handlers build a `cce_ui::widget::Event` and call
135 `ui_context.propagate_event(&ev, root_id)` per root. Note the ordering contracts
136 documented inline: the key-input chain short-circuits on first handled;
137 Enter-applies-value is gated on `value_was_editing` captured *before* dispatch;
138 mouse events check `editor_handled` before letting the tree list see the click.
139 - **TextBox editing model**: while `editing`, live content is `edit_buffer`, not
140 `text` — hence the recurring
141 `let content = if editing { &edit_buffer } else { &text }` idiom. Keep it when
142 reading editor content.
143 - **Text shaping**: `refresh_widget_text()` calls `prepare_text(&mut self.font_system)`
144 on every text-bearing widget each rebuild — this is load-bearing for cursor↔pixel
145 mapping, not just rendering.
146
147 ### Shortcuts and app config
148
149 Keyboard shortcuts resolve once at startup (`DataEditorKeys::load`) from
150 `~/.config/cce/input.kdl` through the `cce-data-editor` domain (falling back to
151 `cce-ui`, then the hardcoded defaults: ctrl+o / ctrl+s / ctrl+q / ctrl+shift+f) via
152 `cce_ui::input::app_chord`. The font-size chords (ctrl +/-) are deliberately hardcoded.
153 The recent-files list (File dropdown) is shared toolkit state via
154 `cce_ui::config::load_recent_files` / `save_recent_files`, capped at 10 entries.