notes editor over the vault (Obsidian-compatible)
git clone https://git.lucas.co/cce-notes.git
CLAUDE.md (12.4K)
1 # cce-notes
2
3 The vault's notes editor — milestones 2, 3 and 6 of the Obsidian-on-cce
4 plan, built on `cce-vault` (milestone 1). Three panes: files or search on
5 the left, one note in the middle — shown as rendered Markdown (**reading
6 view**) or edited with **live preview** (cce-ui's `DocEditor`: markup
7 hidden except on the caret's lines), toggled with Ctrl+E as in Obsidian;
8 Ctrl+Shift+E turns the preview off (**source mode**, the same editor) —
9 and backlinks or the outline on the right. Agents reach the
10 same vault through its MCP tools.
11
12 Read `../cce-vault/CLAUDE.md` first: the index, watcher, writes and the
13 `markdown` document model this app draws all live there, along with their
14 invariants. The plan itself is a doc, "Obsidian-style notes on cce:
15 proposal".
16
17 ## Layout
18
19 | File | What it owns |
20 | --- | --- |
21 | `main.rs` | The `Application`: panes, modes, history, switcher + rename prompt, completion, autosave, conflicts, input; hosts the `DocEditor` |
22 | `reading.rs` | Lays out `cce_vault::markdown::Block`s at a width into draw items + click targets |
23 | `tree.rs` | The file tree's rows (folders first, case-insensitive, collapsible) |
24 | `panel.rs` | The scrollable header/title/line list both side panes draw through |
25 | `side.rs` | What the panes list: backlinks + unlinked mentions, outline, text and tag search |
26 | `complete.rs` | `[[` completion: the open link at the caret, the shortest link text, the splice |
27 | `mcp.rs` | MCP tools (`search`, `find_notes`, `read_note`, `backlinks`, `open_note`, `append_daily`, `current_note`) |
28 | `images.rs` | Embedded images: link text → vault path → decode thread → upload; the lookup the reading view and the editor share |
29 | `paste.rs` | Ctrl+V and drops of a picture or image files: what the clipboard / drop offers, storing into the attachment folder, where the embeds go |
30 | `instance.rs` | The CLI's commands, on the single-instance socket `/tmp/cce-notes-<WAYLAND_DISPLAY>.sock` (claim, forward and listener are `cce_ui::ipc::instance`) |
31
32 ## Behaviour worth knowing before changing it
33
34 - **The files are the source of truth; there is no daemon.** The app
35 embeds an `Index` and a `VaultWatcher`. A watcher batch that touches the
36 open note reloads it silently when the buffer is clean, and raises a
37 **conflict** when it is dirty: autosave stops, Ctrl+S writes ours over
38 the disk copy, Ctrl+R loads theirs. Never overwrite a dirty conflict
39 silently — Obsidian or Dropbox may be writing the same file.
40 - **Our own writes come back through the watcher.** They are recognised by
41 comparing the disk text with `saved` (the last text read or written), not
42 by timing.
43 - **Editing autosaves** 1.5 s after typing stops (`AUTOSAVE_AFTER`,
44 polled through `idle_poll_interval` only while dirty), on leaving a note,
45 on switching to reading, and in `on_exit`. Dirty is the editor's
46 `buf.revision` against `saved_rev` (no per-tick text compare); a save
47 of text equal to the disk copy writes nothing.
48 - **Rename (F2, or a click on the title in the band)** goes through
49 `Index::rename`, which rewrites every link to the note across the vault;
50 the prompt previews the count from `plan_rename` before Enter. History
51 entries follow the rename.
52 - **`[[` completion** opens while the caret sits in an unclosed `[[` on
53 its line (`complete::open_link` over the caret's line, char indices;
54 a `|` or `#` ends it). Enter/Tab inserts the shortest link text that
55 still resolves (`link_text`) and `]]` as one undo step
56 (`DocEditor::edit` replacing the line); Escape shuts it for that link.
57 The popup sits at `DocEditor::caret_rect`, which is only current after
58 `prepare` for this frame — `paint_note` prepares, then places it.
59 - **Popups over text:** text draws after every plate, so the completion
60 popup is a *hole*: the editor is painted four times, clipped to the
61 bands around the popup (clips intersect), and the popup fills the gap.
62 The quick switcher instead stops painting what it covers.
63 - **`load_text` clears the editor's undo history** (`DocEditor::set_text`
64 does); without that, Ctrl+Z after switching notes stepped back into the
65 previous note's text.
66 - **Undo reaches the editor through `Application::undo` / `redo`.** The
67 runner routes the undo chord to the focused widget, then those hooks,
68 and only then `handle_key_input`; the editor is not a registered
69 widget, so the hooks forward to it while it holds the keys
70 (`editor_focused`).
71 - **Links:** click in reading and on a rendered link in live preview;
72 Ctrl+click on the caret's (raw) line — the editor answers
73 `Response::Follow`. Unresolved links fade (a resolver passed at paint,
74 `paint_prepared_with`). An unresolved link creates the note at the vault
75 root and opens it in the editor, as Obsidian does. Ctrl state comes from `UiContext::ctrl_pressed` (the
76 Wayland modifiers event) — a key event's own `ctrl` flag is stale for the
77 Ctrl press itself.
78
79 ## Embedded images (`images.rs`)
80
81 A paragraph that is one `![[pic.png]]` / `` draws as the
82 picture in reading and in live preview (cce-ui's `markdown::layout_with`
83 and `DocEditor::set_images`; Obsidian's `|300` / `|300x200` sizes
84 honoured, too-wide images scaled to the column). An image embed inside a
85 sentence flows with the text in reading view (its line grows to fit) and
86 shows in a row below its line in live preview — the editor's rows are one
87 height, so a picture cannot sit inside one. Note embeds (`![[Note]]`)
88 still show as links.
89
90 - **Asked for while painting, loaded after.** A lookup the cache cannot
91 answer is only recorded; `Images::pump`, at the end of `display_list`,
92 resolves it with `Index::resolve_text` from the open note and decodes on
93 a thread. The decode returns as `Message::ImageDecoded` (which wakes an
94 idle loop) and is uploaded there; then the reading layout and the
95 editor's line layouts are dropped so the link becomes the picture.
96 - **A link that resolves to an image already decoded still needs a
97 relayout** — it drew as a link this frame. `pump` says so and the app
98 sends itself `Message::ImagesReady`. Without it, every vault change (all
99 links resolve afresh) turned loaded images back into links.
100 - **Ids die with the renderer.** `renderer_init` forgets them all on the
101 second and later renderer (`seen_renderer`). Verified with
102 `CCE_UI_FAULT_RECONNECT` at scale 2 against a control built without it:
103 the control's images went blank, these stayed.
104 - **Pasting (Ctrl+V, `paste.rs`)**: a picture on the clipboard is saved
105 as `Pasted image <YYYYMMDDHHMMSS>.<ext>`, and copied image files under
106 their own names, in Obsidian's attachment folder for the note
107 (`cce_vault::attachments`); each `![[…]]` goes on its own line at the
108 caret. Copied files are checked first (a file manager offers their paths
109 as `text/plain` too); a picture is taken only when no `text/plain` is
110 offered, so text copied with an image rendering (browsers, office apps)
111 still pastes as text. The link is the bare name unless another file of
112 that name would win it. Shadow-test with `cce-shadow run wl-copy --type
113 image/png < x.png` — the shadow has its own clipboard.
114 - **Dropping** (`drop_mimes` / `handle_drop`) takes the same two kinds,
115 pixels first (`image/png`… — a browser's dragged picture), then
116 `text/uri-list` (a file manager). The embed goes after the line under
117 the pointer, never mid-line; a drop on reading view switches to editing
118 and adds it at the end. Shadow-test with a GTK4 drag source and
119 `ccectl pointer-press` / `pointer-move-to` / `pointer-release`; offer
120 pixels with `Gdk.ContentProvider.new_for_bytes("image/png", …)` — a
121 texture `new_for_value` from Python advertises only GTK's private type.
122 - **A line that is only an embed is its own block** in the parsed document
123 (`cce_vault::markdown`), even inside a paragraph, so reading view and
124 live preview agree on what draws as a picture.
125 - Rasters over 2048 px are scaled down on decode (reported at their own
126 size, so sizing is unchanged); SVGs show at their intrinsic size,
127 rasterised at twice it. Formats: png, jpeg, gif (first frame), webp,
128 bmp, svg — not avif. The 32 most recently drawn stay decoded across
129 notes.
130
131 ## The reading view (`reading.rs`)
132
133 cce-ui's text prim draws one run in one style, so a paragraph is laid out
134 **word by word**: each word measured, wrapped greedily, and consecutive
135 words of one look merged back into one prim. Things that were bugs once:
136
137 - **Measure through the renderer's own shaping entry**
138 (`window_runner::get_text_buffer_attrs`) with a FontSystem that loads the
139 **same fonts** as the renderer. The app returns `load_system_fonts() =
140 true` because the DE sans (Noto Sans) has no bold/italic in the bundle —
141 without it `**bold**` fell back to a serif face — and `measure_fs` is
142 `create_font_system_with_system_fonts()` to match. Startup cost measured
143 at ~330 ms to first map.
144 - **A word may span styles** (`` `code`, `` or `**bold**.`): `tokens()`
145 glues them so punctuation never starts a line on its own.
146 - **Colours** for links, tags, highlights and callouts are written in sRGB
147 and pass through `lin()`; prims take linear colour. `TEXT_FG`/`TEXT_DIM`
148 are already linear.
149 - **Bullets are `Dot`s (`pc.circle`)**, not small rounded rects: the
150 squircle corner shape draws a few-px radius as a square.
151 - **Text cannot be hidden under a plate** (the glyph pass runs after all
152 geometry), so the quick switcher simply stops painting the note and tree
153 while it is open rather than registering a popover.
154
155 This is the app-local first cut of the `MarkdownView` the proposal puts in
156 cce-ui. Move it there when a second surface (grid note cards, graph hover
157 previews) needs it, and then shape a whole paragraph as one rich-text
158 buffer instead of a buffer per word (each word is a `BUFFER_CACHE` entry
159 today, which a long note can churn).
160
161 ## Commands
162
163 ```sh
164 cce-notes [--vault <dir>] [open] <note>[#heading] [line] # line is 1-based
165 cce-notes daily [YYYY-MM-DD]
166 cce-notes search <query> # the search pane holding it (#tag works)
167 cce-notes [show] # just bring the instance up
168 ```
169
170 `daily`, `search` and `show` are verbs, not note names: a note called one
171 of them opens with an explicit `open` (`cce-notes open show`).
172
173 A second launch forwards its command to the running instance and exits.
174 Keys come from input.kdl's `cce-notes` domain: `quick_switcher` (ctrl+o),
175 `toggle_mode` (ctrl+e), `toggle_source` (ctrl+shift+e: live preview ↔
176 source), `save` (ctrl+s), `reload` (ctrl+r), `back`
177 (alt+arrowleft), `forward` (alt+arrowright), `toggle_tree` (ctrl+\\),
178 `toggle_side` (ctrl+]), `search` (ctrl+shift+f), `rename` (f2), `graph`
179 (ctrl+g: `cce-graph --vault <this vault> --local`, single-instance), `daily`
180 (alt+d), `quit` (ctrl+q). Mouse back/forward walk the history too.
181
182 The instance socket also answers `current` (`ok <vault path>`) straight
183 from its listener thread, from a value `open_path`/`rename` keep up to
184 date (`instance::set_current`) — cce-graph's local graph polls it.
185
186 ## MCP
187
188 `claude mcp add --transport http cce-notes http://127.0.0.1:3002/mcp`.
189 Only the single instance serves it, and only with a vault. Tools run on
190 the app's event loop against the window's own index, so a write to a note
191 open and dirty in the window raises the usual conflict. A shadow instance
192 must move the port (`CCE_NOTES_MCP_PORT=3902`) — the live one holds 3002.
193
194 ## Testing
195
196 `cargo test -p cce-notes` covers layout (with a fixed-width `Measure`), the
197 tree and the socket commands. Anything visual goes through a shadow at
198 `--scale 2` with `CCE_FONTS_DIR` set, against a **copy** of the vault
199 (`CCE_VAULT=<copy>`) — never the live vault, which syncs to other devices.
200 Fullscreen the window first (`ctl set-mode fullscreen cce-notes`): the
201 scale-2 shadow output is only 640×360 logical, too narrow for the right
202 pane (it yields below a 360 px note) — check three-pane layout in a
203 scale-1 shadow (1280×720) and pointer/caret maths at scale 2.
204
205 ## Not done yet
206
207 In the editor: property values are edited as raw YAML (the table flips
208 to raw when the caret enters; Obsidian edits in place), tables and
209 callouts show raw, note embeds (`![[Note]]`) show as links (image embeds
210 render; mid-sentence ones below their line rather than inside it), and a fenced block has no language label or copy button. Also heading
211 completion (`[[Note#`), rendered snippets in the panes (they show
212 raw lines), search debounce for large vaults (it scans every note per
213 keystroke), the icon (`Icon=cce-notes` has no SVG in
214 cce-icons yet), and per-note scroll in the history.