git.lucas.co / cce-notes
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]]` / `![](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.