git.lucas.co / cce-notes
notes editor over the vault (Obsidian-compatible)
git clone https://git.lucas.co/cce-notes.git

commit3bc484b32c674d58412e91411cac6c6a94ad98b2
parentd48cd0ffb8
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-01 14:56
Editing is live preview: cce-ui's DocEditor replaces the TextBox source mode

Markup hides except on the caret's lines; Ctrl+Shift+E turns the preview
off (source mode, the same editor). [[ completion splices the caret's
line through DocEditor::edit and sits at its caret_rect; Ctrl+Z reaches
the editor through the Application undo/redo hooks; rendered links
follow on click, raw ones on Ctrl+click, unresolved ones fade. Dirty
is the buffer revision against the saved one, so autosave no longer
compares the whole text every tick. Pins cce-ui 5a52857 (doc_editor).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md   |  65 ++++++-----
 Cargo.lock  |   2 +-
 Cargo.toml  |   2 +-
 src/main.rs | 360 +++++++++++++++++++++++++++++++++---------------------------
 4 files changed, 241 insertions(+), 188 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 8f959d5..708d3bd 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,10 +1,12 @@
 # cce-notes
 
-The vault's notes editor — milestones 2 and 3 of the Obsidian-on-cce plan,
-built on `cce-vault` (milestone 1). Three panes: files or search on the
-left, one note in the middle — shown as rendered Markdown (**reading
-view**) or edited as text (**source mode**), toggled with Ctrl+E as in
-Obsidian — and backlinks or the outline on the right. Agents reach the
+The vault's notes editor — milestones 2, 3 and 6 of the Obsidian-on-cce
+plan, built on `cce-vault` (milestone 1). Three panes: files or search on
+the left, one note in the middle — shown as rendered Markdown (**reading
+view**) or edited with **live preview** (cce-ui's `DocEditor`: markup
+hidden except on the caret's lines), toggled with Ctrl+E as in Obsidian;
+Ctrl+Shift+E turns the preview off (**source mode**, the same editor) —
+and backlinks or the outline on the right. Agents reach the
 same vault through its MCP tools.
 
 Read `../cce-vault/CLAUDE.md` first: the index, watcher, writes and the
@@ -16,7 +18,7 @@ proposal".
 
 | File | What it owns |
 | --- | --- |
-| `main.rs` | The `Application`: panes, modes, history, switcher + rename prompt, completion, autosave, conflicts, input |
+| `main.rs` | The `Application`: panes, modes, history, switcher + rename prompt, completion, autosave, conflicts, input; hosts the `DocEditor` |
 | `reading.rs` | Lays out `cce_vault::markdown::Block`s at a width into draw items + click targets |
 | `tree.rs` | The file tree's rows (folders first, case-insensitive, collapsible) |
 | `panel.rs` | The scrollable header/title/line list both side panes draw through |
@@ -36,33 +38,39 @@ proposal".
 - **Our own writes come back through the watcher.** They are recognised by
   comparing the disk text with `saved` (the last text read or written), not
   by timing.
-- **Source mode autosaves** 1.5 s after typing stops (`AUTOSAVE_AFTER`,
+- **Editing autosaves** 1.5 s after typing stops (`AUTOSAVE_AFTER`,
   polled through `idle_poll_interval` only while dirty), on leaving a note,
-  on switching to reading, and in `on_exit`.
+  on switching to reading, and in `on_exit`. Dirty is the editor's
+  `buf.revision` against `saved_rev` (no per-tick text compare); a save
+  of text equal to the disk copy writes nothing.
 - **Rename (F2, or a click on the title in the band)** goes through
   `Index::rename`, which rewrites every link to the note across the vault;
   the prompt previews the count from `plan_rename` before Enter. History
   entries follow the rename.
 - **`[[` completion** opens while the caret sits in an unclosed `[[` on
-  its line (`complete::open_link`; a `|` or `#` ends it). Enter/Tab
-  inserts the shortest link text that still resolves (`link_text`) and
-  `]]`, recording one undo step (`TextBox::history.record`); Escape shuts
-  it for that link. It needs the caret's pixel position, which TextBox
-  does not expose: `completion_rect` recomputes it from the public
-  `wrap_text`/`char_width`/`line_height`/`scroll_*` the way TextBox's own
-  `selection_quads` does (monospace, 8 px inner pad) — change both if
-  TextBox's padding changes.
+  its line (`complete::open_link` over the caret's line, char indices;
+  a `|` or `#` ends it). Enter/Tab inserts the shortest link text that
+  still resolves (`link_text`) and `]]` as one undo step
+  (`DocEditor::edit` replacing the line); Escape shuts it for that link.
+  The popup sits at `DocEditor::caret_rect`, which is only current after
+  `prepare` for this frame — `paint_note` prepares, then places it.
 - **Popups over text:** text draws after every plate, so the completion
   popup is a *hole*: the editor is painted four times, clipped to the
   bands around the popup (clips intersect), and the popup fills the gap.
   The quick switcher instead stops painting what it covers.
-- **`load_text` clears the TextBox undo history** and re-syncs
-  `editor_state`; without that, Ctrl+Z after switching notes in source
-  mode stepped back into the previous note's text.
-- **Links:** click in reading, Ctrl+click in source (the caret's byte
-  offset is matched against `cce_vault::parse` link spans). An unresolved
-  link creates the note at the vault root and opens it in source mode, as
-  Obsidian does. Ctrl state comes from `UiContext::ctrl_pressed` (the
+- **`load_text` clears the editor's undo history** (`DocEditor::set_text`
+  does); without that, Ctrl+Z after switching notes stepped back into the
+  previous note's text.
+- **Undo reaches the editor through `Application::undo` / `redo`.** The
+  runner routes the undo chord to the focused widget, then those hooks,
+  and only then `handle_key_input`; the editor is not a registered
+  widget, so the hooks forward to it while it holds the keys
+  (`editor_focused`).
+- **Links:** click in reading and on a rendered link in live preview;
+  Ctrl+click on the caret's (raw) line — the editor answers
+  `Response::Follow`. Unresolved links fade (a resolver passed at paint,
+  `paint_prepared_with`). An unresolved link creates the note at the vault
+  root and opens it in the editor, as Obsidian does. Ctrl state comes from `UiContext::ctrl_pressed` (the
   Wayland modifiers event) — a key event's own `ctrl` flag is stale for the
   Ctrl press itself.
 
@@ -106,7 +114,8 @@ cce-notes search <query>         # the search pane holding it (#tag works)
 
 A second launch forwards its command to the running instance and exits.
 Keys come from input.kdl's `cce-notes` domain: `quick_switcher` (ctrl+o),
-`toggle_mode` (ctrl+e), `save` (ctrl+s), `reload` (ctrl+r), `back`
+`toggle_mode` (ctrl+e), `toggle_source` (ctrl+shift+e: live preview ↔
+source), `save` (ctrl+s), `reload` (ctrl+r), `back`
 (alt+arrowleft), `forward` (alt+arrowright), `toggle_tree` (ctrl+\\),
 `toggle_side` (ctrl+]), `search` (ctrl+shift+f), `rename` (f2), `graph`
 (ctrl+g: `cce-graph --vault <this vault> --local`, single-instance), `daily`
@@ -137,8 +146,10 @@ scale-1 shadow (1280×720) and pointer/caret maths at scale 2.
 
 ## Not done yet
 
-Heading completion (`[[Note#`), rendered snippets in the panes (they show
+In the editor: frontmatter shows raw (Obsidian draws a Properties
+table), tables and callouts show raw, embeds (`![[…]]`) do not render,
+and a fenced block has no language label or copy button. Also heading
+completion (`[[Note#`), rendered snippets in the panes (they show
 raw lines), search debounce for large vaults (it scans every note per
 keystroke), embedded images, the icon (`Icon=cce-notes` has no SVG in
-cce-icons yet), per-note scroll in the history, and the move of the
-reading view into cce-ui.
+cce-icons yet), and per-note scroll in the history.
diff --git a/Cargo.lock b/Cargo.lock
index 442b828..aca76af 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -425,7 +425,7 @@ dependencies = [
 [[package]]
 name = "cce-ui"
 version = "0.1.0"
-source = "git+https://github.com/lsgalante/cce-ui.git?rev=90aea0d03507846e7c49873400f0aa8d244bb5b2#90aea0d03507846e7c49873400f0aa8d244bb5b2"
+source = "git+https://github.com/lsgalante/cce-ui.git?rev=5a528578745f12d38afc78bd697fda37d4d187c8#5a528578745f12d38afc78bd697fda37d4d187c8"
 dependencies = [
  "ash",
  "bitflags 2.13.2",
diff --git a/Cargo.toml b/Cargo.toml
index f11832d..c3eaf08 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,7 +5,7 @@ edition = "2021"
 description = "The vault's notes editor: Obsidian-compatible reading view, source editing and links over cce-vault"
 
 [dependencies]
-cce-ui = { git = "https://github.com/lsgalante/cce-ui.git", rev = "90aea0d03507846e7c49873400f0aa8d244bb5b2", features = ["markdown"] }
+cce-ui = { git = "https://github.com/lsgalante/cce-ui.git", rev = "5a528578745f12d38afc78bd697fda37d4d187c8", features = ["markdown", "doc_editor"] }
 cce-vault = { git = "https://github.com/lsgalante/cce-vault.git", rev = "252827c1f6033514fbed46d1c7c1632241344fce" }
 calloop = "0.13.0"
 wayland-client = { version = "0.31", features = ["system"] }
diff --git a/src/main.rs b/src/main.rs
index 76f2031..8def49d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,17 +1,19 @@
 //! `cce-notes` — the vault's notes editor (Obsidian-on-cce, milestone 2).
 //!
 //! A file tree on the left and one note on the right, read as rendered
-//! Markdown (the reading view) or edited as text (source mode), toggled
-//! with Ctrl+E as in Obsidian. Links follow on click — or Ctrl+click in
-//! source — and a link to a note that does not exist creates it. Ctrl+O is
-//! the quick switcher; Alt+←/→ walk the history.
+//! Markdown (the reading view) or edited in live preview (markup shown
+//! only on the caret's lines, cce-ui's `DocEditor`), toggled with Ctrl+E as
+//! in Obsidian; Ctrl+Shift+E turns the preview off (source mode). Links
+//! follow on click — or Ctrl+click on the caret's line — and a link to a
+//! note that does not exist creates it. Ctrl+O is the quick switcher;
+//! Alt+←/→ walk the history.
 //!
 //! The files are the source of truth and there is no daemon: the app
 //! embeds a `cce_vault::Index` and a watcher, so an edit from Obsidian,
 //! Dropbox or another cce app arrives like any other change. A clean
 //! buffer reloads silently; a dirty one is flagged as a conflict and never
-//! overwritten. Source mode saves a moment after typing stops, on leaving
-//! the note, and on exit.
+//! overwritten. Editing saves a moment after typing stops, on leaving the
+//! note, and on exit.
 //!
 //! The vault comes from `--vault <dir>`, `$CCE_VAULT`, or
 //! `vault { path "…" }` in `~/.config/cce/config.kdl`. The app runs single
@@ -36,6 +38,7 @@ use cce_ui::widget::{
     Adapted, Bounds, ElementState, Event, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey, ScrollMotion,
     TextBox, WidgetHost,
 };
+use cce_ui::widget::doc_editor::{self, DocEditor, EditorTheme, Pos};
 use cce_vault::markdown::{Block, SpanLink};
 use cce_vault::{FileKind, Index, VaultWatcher};
 use wayland_client::QueueHandle;
@@ -62,7 +65,7 @@ const TAB_H: f32 = 30.0;
 const SEARCH_H: f32 = 30.0;
 const COMPLETE_W: f32 = 320.0;
 const COMPLETE_ROW_H: f32 = 24.0;
-/// Source mode saves once typing has paused this long.
+/// Editing saves once typing has paused this long.
 const AUTOSAVE_AFTER: Duration = Duration::from_millis(1500);
 
 const FG: [f32; 4] = cce_ui::colors::TEXT_FG;
@@ -92,6 +95,7 @@ static STARTUP: OnceLock<Startup> = OnceLock::new();
 struct Keys {
     switcher: String,
     toggle_mode: String,
+    toggle_source: String,
     save: String,
     reload: String,
     back: String,
@@ -111,6 +115,7 @@ impl Keys {
         Keys {
             switcher: get("quick_switcher", "ctrl+o"),
             toggle_mode: get("toggle_mode", "ctrl+e"),
+            toggle_source: get("toggle_source", "ctrl+shift+e"),
             save: get("save", "ctrl+s"),
             reload: get("reload", "ctrl+r"),
             back: get("back", "alt+arrowleft"),
@@ -129,6 +134,7 @@ impl Keys {
 #[derive(Clone, Copy, PartialEq, Eq, Debug)]
 enum Mode {
     Reading,
+    /// The editor: live preview, or source with the preview off.
     Source,
 }
 
@@ -146,9 +152,10 @@ enum SideTab {
     Outline,
 }
 
-/// `[[` completion in source mode: the link being typed and its choices.
+/// `[[` completion while editing: the link being typed and its choices.
 struct Completion {
-    /// Char index just past the `[[`.
+    /// The caret's line, and the char index in it just past the `[[`.
+    line: usize,
     start: usize,
     query: String,
     /// Vault paths, best first.
@@ -204,9 +211,11 @@ struct NotesApp {
     pending_line: Option<usize>,
     hover_hit: bool,
 
-    editor: Adapted<TextBox>,
-    /// The buffer as last seen by the autosave check, and when it changed.
-    edit_seen: String,
+    editor: DocEditor,
+    /// The editor's revision when its text last matched the disk.
+    saved_rev: u64,
+    /// The revision as last seen by the autosave check, and when it changed.
+    edit_seen: u64,
     edit_changed_at: Instant,
 
     history: Vec<String>,
@@ -229,9 +238,9 @@ struct NotesApp {
     side_dirty: bool,
 
     completion: Option<Completion>,
-    /// A `[[` whose completion was dismissed with Escape: it stays shut
-    /// until the caret leaves that link.
-    completion_dismissed: Option<usize>,
+    /// A `[[` (line, start) whose completion was dismissed with Escape: it
+    /// stays shut until the caret leaves that link.
+    completion_dismissed: Option<(usize, usize)>,
 
     conflict: bool,
     status: Option<(String, bool)>,
@@ -301,14 +310,14 @@ impl NotesApp {
         (x, m.note.y + READ_PAD, width)
     }
 
+    /// The editor fills the note pane; it centres its own column.
     fn editor_rect(&self, m: &Metrics) -> Rect {
-        let gap = cce_ui::layout::root_plate_gap();
-        Rect {
-            x: m.note.x + gap,
-            y: m.note.y + gap,
-            width: (m.note.width - 2.0 * gap).max(50.0),
-            height: (m.note.height - 2.0 * gap).max(50.0),
-        }
+        m.note
+    }
+
+    /// The editor takes keys: editing, with nothing else holding them.
+    fn editor_focused(&self) -> bool {
+        self.mode == Mode::Source && self.switcher.is_none() && !self.search_input.editing
     }
 
     fn rebuild_rows(&mut self) {
@@ -318,16 +327,12 @@ impl NotesApp {
         self.tree_hover = None;
     }
 
-    fn editor_text(&self) -> &str {
-        if self.editor.editing {
-            &self.editor.edit_buffer
-        } else {
-            &self.editor.text
-        }
+    fn editor_text(&self) -> String {
+        self.editor.text()
     }
 
     fn dirty(&self) -> bool {
-        self.mode == Mode::Source && self.current.is_some() && self.editor_text() != self.saved
+        self.mode == Mode::Source && self.current.is_some() && self.editor.buf.revision != self.saved_rev
     }
 
     fn set_status(&mut self, msg: impl Into<String>, error: bool) {
@@ -344,17 +349,12 @@ impl NotesApp {
     /// Show `text` as the current note's content, as on disk.
     fn load_text(&mut self, text: String) {
         self.blocks = cce_vault::markdown::blocks(&text);
-        self.editor.text = text.clone();
-        self.editor.edit_buffer = text.clone();
-        self.editor.cursor_idx = 0;
-        self.editor.select_anchor = None;
-        self.editor.all_selected = false;
-        self.editor.sync_editor_state();
-        // Undo must not step back into another note's (or the pre-reload)
-        // text.
-        self.editor.history.clear();
+        // Clears the undo history too: undo must not step back into another
+        // note's (or the pre-reload) text.
+        self.editor.set_text(&text);
+        self.saved_rev = self.editor.buf.revision;
         self.completion = None;
-        self.edit_seen = text.clone();
+        self.edit_seen = self.saved_rev;
         self.saved = text;
         self.conflict = false;
         self.side_dirty = true;
@@ -371,11 +371,18 @@ impl NotesApp {
         if !self.dirty() && !(force && self.mode == Mode::Source) {
             return true;
         }
-        let text = self.editor_text().to_string();
+        let text = self.editor_text();
+        let rev = self.editor.buf.revision;
+        if text == self.saved && !force {
+            // Edited back to what is on disk: nothing to write.
+            self.saved_rev = rev;
+            return true;
+        }
         let (Some(cur), Some(ix)) = (self.current.clone(), self.index.as_mut()) else { return false };
         match ix.write_text(&cur, &text) {
             Ok(()) => {
                 self.saved = text;
+                self.saved_rev = rev;
                 self.conflict = false;
                 true
             }
@@ -534,13 +541,12 @@ impl NotesApp {
             if !self.save() {
                 return;
             }
-            let text = self.editor_text().to_string();
+            let text = self.editor_text();
             self.blocks = cce_vault::markdown::blocks(&text);
-            self.editor.unfocus();
+            self.completion = None;
             self.invalidate();
         } else {
-            self.ui_context.set_focused(&mut self.editor);
-            WidgetHost::focus(&mut self.editor);
+            self.search_input.unfocus();
         }
         self.mode = mode;
         self.needs_rebuild = true;
@@ -636,10 +642,6 @@ impl NotesApp {
     fn close_switcher(&mut self) {
         self.switcher = None;
         self.switcher_input.unfocus();
-        if self.mode == Mode::Source {
-            self.ui_context.set_focused(&mut self.editor);
-            WidgetHost::focus(&mut self.editor);
-        }
         self.needs_rebuild = true;
     }
 
@@ -890,21 +892,13 @@ impl NotesApp {
     }
 
     /// Bring a 0-based source line of the open note into view: scroll the
-    /// reading view to its block, or put the source caret at its start.
+    /// reading view to its block, or put the editor's caret at its start.
     fn goto_line(&mut self, line: usize) {
         match self.mode {
             Mode::Reading => self.pending_line = Some(line),
             Mode::Source => {
-                let text = self.editor_text().to_string();
-                let idx: usize = text.split('\n').take(line).map(|l| l.chars().count() + 1).sum();
-                self.ui_context.set_focused(&mut self.editor);
-                WidgetHost::focus(&mut self.editor);
-                self.editor.edit_buffer = text;
-                self.editor.cursor_idx = idx.min(self.editor.edit_buffer.chars().count());
-                self.editor.select_anchor = None;
-                self.editor.all_selected = false;
-                self.editor.sync_editor_state();
-                self.editor.scroll_to_cursor();
+                self.search_input.unfocus();
+                self.editor.reveal_line(line);
             }
         }
         self.needs_rebuild = true;
@@ -912,27 +906,35 @@ impl NotesApp {
 
     // ---- [[ completion -----------------------------------------------------
 
+    /// The caret's line and its char index in it.
+    fn caret_chars(&self) -> (usize, usize) {
+        let p = self.editor.buf.caret;
+        let line = self.editor.buf.line(p.line);
+        (p.line, line[..p.col.min(line.len())].chars().count())
+    }
+
     /// Open, update or close the completion for the caret's position.
     fn update_completion(&mut self) {
-        if self.mode != Mode::Source || !self.editor.editing {
+        if !self.editor_focused() || self.editor.buf.selection().is_some() {
             self.completion = None;
             return;
         }
-        let found = complete::open_link(&self.editor.edit_buffer, self.editor.cursor_idx);
+        let (line, col) = self.caret_chars();
+        let found = complete::open_link(self.editor.buf.line(line), col);
         let Some((start, query)) = found else {
             self.completion = None;
             self.completion_dismissed = None;
             return;
         };
-        if self.completion_dismissed == Some(start) {
+        if self.completion_dismissed == Some((line, start)) {
             return;
         }
-        if self.completion.as_ref().is_some_and(|c| c.start == start && c.query == query) {
+        if self.completion.as_ref().is_some_and(|c| c.line == line && c.start == start && c.query == query) {
             return;
         }
         let Some(ix) = &self.index else { return };
         let choices = complete::choices(ix, &query);
-        self.completion = Some(Completion { start, query, choices, selected: 0 });
+        self.completion = Some(Completion { line, start, query, choices, selected: 0 });
         self.needs_rebuild = true;
     }
 
@@ -948,7 +950,7 @@ impl NotesApp {
             Key::Named(NamedKey::ArrowUp) => c.selected = (c.selected + n - 1) % n,
             Key::Named(NamedKey::Enter) | Key::Named(NamedKey::Tab) => self.accept_completion(),
             Key::Named(NamedKey::Escape) => {
-                self.completion_dismissed = Some(c.start);
+                self.completion_dismissed = Some((c.line, c.start));
                 self.completion = None;
             }
             _ => return false,
@@ -957,48 +959,37 @@ impl NotesApp {
         true
     }
 
+    /// Write the chosen link into the caret's line: one undo step.
     fn accept_completion(&mut self) {
         let Some(c) = self.completion.take() else { return };
         let (Some(ix), Some(path)) = (&self.index, c.choices.get(c.selected)) else { return };
         let link = complete::link_text(ix, self.current.as_deref(), path);
-        let e = &mut self.editor;
-        let before = cce_ui::widget::editor::TextEditorState {
-            buffer: e.edit_buffer.clone(),
-            cursor_idx: e.cursor_idx,
-            select_anchor: e.select_anchor,
-            all_selected: e.all_selected,
-        };
-        let (text, caret) = complete::apply(&e.edit_buffer, c.start, e.cursor_idx, &link);
-        e.history.record(before);
-        e.edit_buffer = text;
-        e.cursor_idx = caret;
-        e.select_anchor = None;
-        e.all_selected = false;
-        e.sync_editor_state();
-        e.scroll_to_cursor();
-    }
-
-    /// The completion popup's rect, under the caret and inside the note pane.
-    fn completion_rect(&self, m: &Metrics) -> Option<Rect> {
-        let c = self.completion.as_ref().filter(|c| !c.choices.is_empty())?;
-        let e = &self.editor;
+        let (line, col) = self.caret_chars();
+        if line != c.line {
+            return;
+        }
+        let text = self.editor.buf.line(line).to_string();
+        let (next, caret) = complete::apply(&text, c.start, col, &link);
+        let caret_byte = next.char_indices().nth(caret).map_or(next.len(), |(b, _)| b);
+        self.editor.edit(Pos::new(line, 0), Pos::new(line, text.len()), &next, Pos::new(line, caret_byte));
+    }
+
+    /// The completion popup's rect, under the caret and inside the note
+    /// pane. Valid after the editor is prepared for this frame.
+    fn completion_rect(&mut self, m: &Metrics) -> Option<Rect> {
+        let n = self.completion.as_ref().filter(|c| !c.choices.is_empty())?.choices.len();
         let r = self.editor_rect(m);
-        let (cw, lh) = (e.char_width(), e.line_height());
-        let max_chars = (((r.width - 16.0) / cw).floor() as usize).max(1);
-        let (_, map) = e.wrap_text(max_chars);
-        let (line, col) = *map.get(e.cursor_idx.min(map.len().saturating_sub(1)))?;
-        let caret_x = r.x + 8.0 + col as f32 * cw - e.scroll_x;
-        let caret_y = r.y + 8.0 + line as f32 * lh - e.scroll_y;
-        let h = c.choices.len() as f32 * COMPLETE_ROW_H + 8.0;
+        let caret = self.editor.caret_rect();
+        let h = n as f32 * COMPLETE_ROW_H + 8.0;
         let w = COMPLETE_W.min(r.width);
-        let x = caret_x.min(r.x + r.width - w).max(r.x);
+        let x = caret.x.min(r.x + r.width - w).max(r.x);
         // Below the caret line, or above it when there is no room below.
-        let below = caret_y + lh + 2.0;
-        let y = if below + h <= r.y + r.height { below } else { (caret_y - h - 2.0).max(r.y) };
+        let below = caret.y + caret.height + 2.0;
+        let y = if below + h <= r.y + r.height { below } else { (caret.y - h - 2.0).max(r.y) };
         Some(Rect { x, y, width: w, height: h })
     }
 
-    fn completion_row_at(&self, m: &Metrics, x: f32, y: f32) -> Option<usize> {
+    fn completion_row_at(&mut self, m: &Metrics, x: f32, y: f32) -> Option<usize> {
         let r = self.completion_rect(m)?;
         if !contains(r, x, y) {
             return None;
@@ -1054,20 +1045,6 @@ impl NotesApp {
         l.hit(x - ox, y - oy + self.read_scroll).cloned()
     }
 
-    /// In source mode, the link under the editor's caret (after a click).
-    fn link_at_caret(&self) -> Option<SpanLink> {
-        let text = self.editor_text();
-        let byte = text.char_indices().nth(self.editor.cursor_idx).map_or(text.len(), |(b, _)| b);
-        let note = cce_vault::parse::parse(text);
-        let link = note.links.iter().find(|l| l.span.start <= byte && byte < l.span.end)?;
-        let url = link.target.contains("://") || link.target.starts_with("mailto:");
-        Some(if url {
-            SpanLink::Url(link.target.clone())
-        } else {
-            SpanLink::Note { target: link.target.clone(), subpath: link.subpath.clone() }
-        })
-    }
-
     // ---- painting --------------------------------------------------------
 
     fn paint_band(&self, pc: &mut PaintCtx, m: &Metrics) {
@@ -1103,6 +1080,7 @@ impl NotesApp {
         if self.current.is_some() {
             let label = match self.mode {
                 Mode::Reading => "Reading",
+                Mode::Source if self.editor.preview => "Editing",
                 Mode::Source => "Source",
             };
             let c = m.mode_chip;
@@ -1137,8 +1115,11 @@ impl NotesApp {
         } else if let Some((msg, err)) = &self.status {
             (msg.clone(), if *err { CONFLICT } else { DIM })
         } else if let (Some(cur), Some(ix)) = (&self.current, &self.index) {
-            let text = if self.mode == Mode::Source { self.editor_text() } else { self.saved.as_str() };
-            let words = text.split_whitespace().count();
+            let words = if self.mode == Mode::Source {
+                self.editor.buf.lines().iter().map(|l| l.split_whitespace().count()).sum()
+            } else {
+                self.saved.split_whitespace().count()
+            };
             // Notes linking here, as the backlinks pane counts them.
             let back = ix.backlinks(cur).iter().filter(|b| b.source != cur).map(|b| b.source).collect::<std::collections::HashSet<_>>().len();
             (format!("{} · {}", plural(words, "word"), plural(back, "backlink")), DIM)
@@ -1251,26 +1232,40 @@ impl NotesApp {
             return;
         }
         match self.mode {
-            Mode::Source => match self.completion_rect(m) {
-                // Text draws over every plate, so the popup cannot simply
-                // cover the editor: paint the editor four times, clipped to
-                // the bands around the popup, and the popup into the hole.
-                Some(hole) => {
-                    let e = self.editor_rect(m);
-                    let (l, t, r, b) = (e.x - 4.0, e.y - 4.0, e.x + e.width + 4.0, e.y + e.height + 4.0);
-                    let bands = [
-                        Rect { x: l, y: t, width: r - l, height: hole.y - t },
-                        Rect { x: l, y: hole.y + hole.height, width: r - l, height: b - hole.y - hole.height },
-                        Rect { x: l, y: hole.y, width: hole.x - l, height: hole.height },
-                        Rect { x: hole.x + hole.width, y: hole.y, width: r - hole.x - hole.width, height: hole.height },
-                    ];
-                    for band in bands.into_iter().filter(|b| b.width > 0.0 && b.height > 0.0) {
-                        pc.clip(band, |pc| cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.editor, pc));
+            Mode::Source => {
+                let e = self.editor_rect(m);
+                let focused = self.editor_focused();
+                self.editor.prepare(e);
+                let hole = self.completion_rect(m);
+                let (index, cur) = (&self.index, self.current.as_deref());
+                let resolved = |t: &doc_editor::Target| match t {
+                    doc_editor::Target::Note { target, .. } => {
+                        target.is_empty() || index.as_ref().is_some_and(|ix| ix.resolve_text(cur, target).is_some())
                     }
-                    self.paint_completion(pc, hole);
+                    doc_editor::Target::Url(_) => true,
+                };
+                match hole {
+                    // Text draws over every plate, so the popup cannot simply
+                    // cover the editor: paint the editor four times, clipped
+                    // to the bands around the popup, and the popup into the
+                    // hole.
+                    Some(hole) => {
+                        let (l, t, r, b) = (e.x, e.y, e.x + e.width, e.y + e.height);
+                        let bands = [
+                            Rect { x: l, y: t, width: r - l, height: hole.y - t },
+                            Rect { x: l, y: hole.y + hole.height, width: r - l, height: b - hole.y - hole.height },
+                            Rect { x: l, y: hole.y, width: hole.x - l, height: hole.height },
+                            Rect { x: hole.x + hole.width, y: hole.y, width: r - hole.x - hole.width, height: hole.height },
+                        ];
+                        let ed = &mut self.editor;
+                        for band in bands.into_iter().filter(|b| b.width > 0.0 && b.height > 0.0) {
+                            pc.clip(band, |pc| ed.paint_prepared_with(pc, focused, &resolved));
+                        }
+                        self.paint_completion(pc, hole);
+                    }
+                    None => self.editor.paint_prepared_with(pc, focused, &resolved),
                 }
-                None => cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.editor, pc),
-            },
+            }
             Mode::Reading => {
                 let (ox, oy, width) = self.reading_frame(m);
                 self.ensure_layout(width);
@@ -1461,9 +1456,9 @@ impl Application for NotesApp {
         }
         let _ = sender.send(Message::Command(startup.command.clone()));
 
-        let mut editor = TextBox::new(String::new()).with_multiline(true).with_draw_bg_border(true).with_max_width(None);
-        editor.font_family = "monospace".to_string();
-        editor.font_size = 13.0;
+        let mut editor = DocEditor::new("", EditorTheme::new(READ_SIZE), true);
+        editor.max_width = READ_MAX_W;
+        editor.pad = READ_PAD;
         let switcher_input = TextBox::new(String::new()).with_placeholder("Find or create a note…");
         let search_input = TextBox::new(String::new()).with_placeholder("Search");
 
@@ -1489,8 +1484,9 @@ impl Application for NotesApp {
             read_motion: ScrollMotion::new(),
             pending_line: None,
             hover_hit: false,
+            saved_rev: editor.buf.revision,
+            edit_seen: editor.buf.revision,
             editor,
-            edit_seen: String::new(),
             edit_changed_at: Instant::now(),
             history: Vec::new(),
             hist_pos: 0,
@@ -1543,7 +1539,7 @@ impl Application for NotesApp {
     }
 
     fn idle_poll_interval(&self) -> Option<Duration> {
-        // Wakes the autosave check while source mode holds unsaved text.
+        // Wakes the autosave check while the editor holds unsaved text.
         self.dirty().then_some(Duration::from_millis(500))
     }
 
@@ -1567,10 +1563,13 @@ impl Application for NotesApp {
         if self.search_panel.tick(dt, m.tree_body) | self.side_panel.tick(dt, m.side_body) {
             *needs_rebuild = true;
         }
+        if self.mode == Mode::Source && self.editor.tick(dt) {
+            *needs_rebuild = true;
+        }
         if self.dirty() && !self.conflict {
             let now = Instant::now();
-            if self.editor_text() != self.edit_seen {
-                self.edit_seen = self.editor_text().to_string();
+            if self.editor.buf.revision != self.edit_seen {
+                self.edit_seen = self.editor.buf.revision;
                 self.edit_changed_at = now;
             } else if now.duration_since(self.edit_changed_at) >= AUTOSAVE_AFTER {
                 self.save();
@@ -1582,8 +1581,6 @@ impl Application for NotesApp {
     fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<DisplayList> {
         if !self.widgets_registered {
             self.widgets_registered = true;
-            let (id, ptr) = (self.editor.id(), self.editor.as_ptr_mut());
-            self.ui_context.register_widget(id, ptr);
             let (id, ptr) = (self.switcher_input.id(), self.switcher_input.as_ptr_mut());
             self.ui_context.register_widget(id, ptr);
             let (id, ptr) = (self.search_input.id(), self.search_input.as_ptr_mut());
@@ -1594,6 +1591,7 @@ impl Application for NotesApp {
         if size_changed {
             if self.scale != scale {
                 self.layout = None;
+                self.editor.invalidate();
             }
             self.width = size.width as u32;
             self.height = size.height as u32;
@@ -1602,8 +1600,6 @@ impl Application for NotesApp {
         }
         let m = self.metrics();
         if self.needs_rebuild || size_changed {
-            let e = self.editor_rect(&m);
-            self.editor.set_rect(e.x, e.y, e.width, e.height);
             let r = self.switcher_rect();
             self.switcher_input.set_rect(r.x + 12.0, r.y + 12.0, r.width - 24.0, 32.0);
             let sb = m.search_box;
@@ -1642,6 +1638,28 @@ impl Application for NotesApp {
         true
     }
 
+    /// Ctrl+Z reaches the editor through here: the runner routes the undo
+    /// chord to the focused widget first, and the editor is not one.
+    fn undo(&mut self, needs_rebuild: &mut bool) -> bool {
+        if !self.editor_focused() {
+            return false;
+        }
+        self.editor.undo();
+        self.update_completion();
+        *needs_rebuild = true;
+        true
+    }
+
+    fn redo(&mut self, needs_rebuild: &mut bool) -> bool {
+        if !self.editor_focused() {
+            return false;
+        }
+        self.editor.redo();
+        self.update_completion();
+        *needs_rebuild = true;
+        true
+    }
+
     /// The reading view needs bold and italic faces of the DE's sans
     /// (Noto Sans by default), which the toolkit's bundle does not carry:
     /// without system fonts, `**bold**` fell back to a serif face. The
@@ -1707,14 +1725,21 @@ impl Application for NotesApp {
         if self.left_tab == LeftTab::Search && self.ui_context.propagate_event(&ev, self.search_input.id()) {
             *needs_rebuild = true;
         }
-        let hit = self.reading_hit(x, y).is_some();
+        let mut hit = self.reading_hit(x, y).is_some();
+        if self.mode == Mode::Source {
+            if self.editor.dragging() {
+                if self.editor.drag(x, y) != doc_editor::Response::None {
+                    self.update_completion();
+                    *needs_rebuild = true;
+                }
+            } else if contains(m.note, x, y) {
+                hit = self.editor.link_at(x, y);
+            }
+        }
         if hit != self.hover_hit {
             self.hover_hit = hit;
             *needs_rebuild = true;
         }
-        if self.mode == Mode::Source && self.ui_context.propagate_event(&ev, self.editor.id()) {
-            *needs_rebuild = true;
-        }
     }
 
     fn handle_mouse_input(
@@ -1755,7 +1780,6 @@ impl Application for NotesApp {
         }
         if in_search {
             if pressed && !self.search_input.editing {
-                self.editor.unfocus();
                 self.ui_context.set_focused(&mut self.search_input);
                 WidgetHost::focus(&mut self.search_input);
             }
@@ -1833,16 +1857,24 @@ impl Application for NotesApp {
                 _ => {}
             }
         }
-        if self.mode == Mode::Source {
-            let ctrl_click = pressed && self.ui_context.ctrl_pressed;
-            self.ui_context.propagate_event(&ev, self.editor.id());
-            if ctrl_click && contains(self.editor_rect(&m), x, y) {
-                if let Some(link) = self.link_at_caret() {
-                    self.follow(link);
-                    return None;
+        if self.mode == Mode::Source && button == MouseButton::Left {
+            if state == ElementState::Released {
+                self.editor.release();
+            } else if contains(self.editor_rect(&m), x, y) {
+                let (shift, ctrl) = (self.ui_context.shift_pressed, self.ui_context.ctrl_pressed);
+                match self.editor.press(x, y, shift, ctrl) {
+                    doc_editor::Response::Follow(target) => {
+                        self.editor.release();
+                        self.hover_hit = false;
+                        self.follow(match target {
+                            doc_editor::Target::Note { target, subpath } => SpanLink::Note { target, subpath },
+                            doc_editor::Target::Url(url) => SpanLink::Url(url),
+                        });
+                        return None;
+                    }
+                    _ => self.update_completion(),
                 }
             }
-            self.update_completion();
         }
         None
     }
@@ -1888,8 +1920,7 @@ impl Application for NotesApp {
                 }
             }
             Mode::Source => {
-                let ev = Event::MouseWheel { delta: *delta, x, y, local_x: x, local_y: y };
-                if self.ui_context.propagate_event(&ev, self.editor.id()) {
+                if self.editor.wheel(delta) {
                     *needs_rebuild = true;
                 }
             }
@@ -1944,6 +1975,14 @@ impl Application for NotesApp {
                 self.set_mode(next);
                 return None;
             }
+            if k(&self.keys.toggle_source) && self.current.is_some() {
+                // Live preview <-> source, both editing; from reading it
+                // opens the editor in the mode it flips to.
+                let on = !self.editor.preview;
+                self.editor.set_preview(on);
+                self.set_mode(Mode::Source);
+                return None;
+            }
             if k(&self.keys.save) {
                 // In a conflict this is "keep mine": it writes over the disk copy.
                 self.save_now(self.conflict);
@@ -2037,10 +2076,13 @@ impl Application for NotesApp {
 
         match self.mode {
             Mode::Source => {
-                if pressed && self.completion_key(&event.logical_key) {
+                if !pressed {
+                    return None;
+                }
+                if self.completion_key(&event.logical_key) {
                     return None;
                 }
-                self.ui_context.propagate_event(&ev, self.editor.id());
+                self.editor.key(event);
                 self.update_completion();
             }
             Mode::Reading if pressed => {