git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit5a528578745f12d38afc78bd697fda37d4d187c8
parent25b3f72d3e
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-01 14:55
feat: DocEditor, a document editor with Markdown live preview (doc_editor feature)

widget::doc_editor: a line buffer with merged undo runs, per-line live
preview styling (markup hidden except on the caret's lines and the
fenced block it is in), wrapped layout with the x of every byte, and
incremental shaping (only drawn, changed lines). Host-driven: keys,
presses, the wheel and paint come from the app; link follows come back
as Response::Follow. Lists continue on Enter, Tab indents, a task's box
toggles as an undoable edit, unresolved links fade at paint.

Measure/ShapingMeasure move to widget::shaping (re-exported from
markdown); a width now includes trailing spaces, and offsets() gives
the x at every char boundary.

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

 CLAUDE.md                        |  39 ++
 Cargo.toml                       |   3 +
 src/widget/doc_editor/buffer.rs  | 469 +++++++++++++++++++
 src/widget/doc_editor/layout.rs  | 482 +++++++++++++++++++
 src/widget/doc_editor/mod.rs     | 982 +++++++++++++++++++++++++++++++++++++++
 src/widget/doc_editor/preview.rs | 635 +++++++++++++++++++++++++
 src/widget/markdown.rs           |  39 +-
 src/widget/mod.rs                |   3 +
 src/widget/shaping.rs            |  85 ++++
 9 files changed, 2699 insertions(+), 38 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index c7295ab..ec4ca8d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -802,6 +802,45 @@ cce-system-interface) to confirm behavior, not just the test suite.
   `detect_metric` — the display's logical px per mm from its `wl_output` geometry).
 - `units.rs` — lengths with units and the display metric; see the Units section below.
 
+## Markdown: `MarkdownView` and `DocEditor` (features, 2026-10-01)
+
+Two opt-in features for the clients that show notes (Obsidian-on-cce):
+
+- **`markdown`** — `widget::markdown`, the reading view: `cce_vault`'s
+  blocks laid out at a width into draw items and click targets
+  (`layout`, `Layout::paint` / `paint_scaled`). cce-notes' reading mode
+  and cce-grid's note cards draw through it. Brings in `cce-vault`.
+- **`doc_editor`** — `widget::doc_editor::DocEditor`, the editor with
+  Markdown **live preview**: markup is hidden except on the caret's lines
+  (the selection's, or the whole fenced block the caret is in), where it
+  shows dimmed; `preview = false` is source mode. No extra dependencies.
+  - `buffer` — lines, caret/selection as (line, byte), edits with merged
+    typing/deleting undo runs, and a log of `Change`s for the layout.
+  - `preview` — styles ONE line: block kind (heading, list, task, quote,
+    rule, code, fence, frontmatter, table) plus inline segments that map
+    1:1 onto source bytes. Markup is never replaced, only hidden, so
+    caret maths never translates between screen and source.
+  - `layout` — one styled line wrapped into runs, with the x of every
+    byte (`ShapingMeasure::offsets`), so drawing, caret and clicks agree.
+  - **Incremental:** a line is shaped only when it is drawn and has
+    changed; undrawn lines keep an estimated height. A 5000-line note
+    shapes one screen (`a_long_document_shapes_only_what_shows`).
+  - **Host-driven, not a registered widget:** the app forwards keys,
+    presses, motion and the wheel and paints it (`prepare` then
+    `paint_prepared_with`, which takes a link resolver so unresolved
+    links fade without a relayout). Answers come back as `Response`
+    (`Follow(Target)` for a rendered-link click or a Ctrl+click). Undo
+    and redo are the host's `Application::undo` / `redo` hooks calling
+    `DocEditor::undo` / `redo` — the runner routes the chord there
+    because the editor is not a focused widget.
+  - Measure with the app's own font set: `DocEditor::new(.., system_fonts)`
+    must match `Application::load_system_fonts`, or widths are not drawn
+    widths. `widget::shaping` holds `Measure` / `ShapingMeasure`, shared
+    with the reading view; a width includes trailing spaces (max of glyph
+    x + w), which is what a run placed after it needs.
+  - The caret does not blink (a blink is a frame every half second for
+    as long as the window is open).
+
 ## A graph's wires are strokes in a style (since 2026-09-30)
 
 `Graph` draws its wires in one of four `WireStyle`s: **orthogonal** (down,
diff --git a/Cargo.toml b/Cargo.toml
index 664cdd2..9cbbe40 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -12,6 +12,9 @@ path = "src/lib.rs"
 # and note cards. Brings in cce-vault and its Markdown parser, so only the
 # clients that show notes turn it on.
 markdown = ["dep:cce-vault"]
+# `widget::doc_editor` (DocEditor): the line-buffer document editor with
+# Markdown live preview (cce-notes' editing mode). No extra dependencies.
+doc_editor = []
 
 [dependencies]
 cce-vault = { git = "https://github.com/lsgalante/cce-vault.git", rev = "11a26a5f8e1ff1ac55a951d88afc751ab68a5aba", optional = true }
diff --git a/src/widget/doc_editor/buffer.rs b/src/widget/doc_editor/buffer.rs
new file mode 100644
index 0000000..520d723
--- /dev/null
+++ b/src/widget/doc_editor/buffer.rs
@@ -0,0 +1,469 @@
+//! The editor's text: a vector of lines (no `\n` stored), a caret and a
+//! selection anchor, and edits that can be undone.
+//!
+//! Lines rather than a rope: every edit is O(length of the lines it
+//! touches) plus a splice of line pointers, line access is O(1), and the
+//! layout caches per line — so a change reports exactly which lines it
+//! replaced ([`Change`]) and only those are shaped again. A 100k-line file
+//! moves 2.4 MB of pointers on a line insert, well under a frame.
+//!
+//! Positions are (line, byte column) and always sit on a char boundary.
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Default, Hash)]
+pub struct Pos {
+    pub line: usize,
+    /// Byte offset into the line, on a char boundary.
+    pub col: usize,
+}
+
+impl Pos {
+    pub fn new(line: usize, col: usize) -> Pos {
+        Pos { line, col }
+    }
+}
+
+/// Lines `first .. first + removed` were replaced by `inserted` lines.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct Change {
+    pub first: usize,
+    pub removed: usize,
+    pub inserted: usize,
+}
+
+/// How an edit groups for undo: a run of typing (or of deleting) undoes
+/// as one step; anything else is a step of its own.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum EditKind {
+    Typing,
+    Deleting,
+    Other,
+}
+
+#[derive(Clone, Debug)]
+struct Edit {
+    start: Pos,
+    removed: String,
+    inserted: String,
+    caret_before: (Pos, Option<Pos>),
+    caret_after: (Pos, Option<Pos>),
+    kind: EditKind,
+}
+
+/// Where `text` inserted at `start` ends.
+fn end_of(start: Pos, text: &str) -> Pos {
+    match text.rfind('\n') {
+        Some(i) => Pos::new(start.line + text.matches('\n').count(), text.len() - i - 1),
+        None => Pos::new(start.line, start.col + text.len()),
+    }
+}
+
+pub struct Buffer {
+    lines: Vec<String>,
+    pub caret: Pos,
+    /// The other end of the selection, when there is one.
+    pub anchor: Option<Pos>,
+    undo: Vec<Edit>,
+    redo: Vec<Edit>,
+    changes: Vec<Change>,
+    /// Bumped on every change to the text.
+    pub revision: u64,
+}
+
+impl Buffer {
+    pub fn new(text: &str) -> Buffer {
+        let mut b = Buffer { lines: Vec::new(), caret: Pos::default(), anchor: None, undo: Vec::new(), redo: Vec::new(), changes: Vec::new(), revision: 0 };
+        b.set_text(text);
+        b
+    }
+
+    /// Replace everything: caret to the start, history and changes cleared
+    /// (the layout is rebuilt whole).
+    pub fn set_text(&mut self, text: &str) {
+        let text = text.replace("\r\n", "\n");
+        self.lines = text.split('\n').map(String::from).collect();
+        self.caret = Pos::default();
+        self.anchor = None;
+        self.undo.clear();
+        self.redo.clear();
+        self.changes.clear();
+        self.changes.push(Change { first: 0, removed: usize::MAX, inserted: self.lines.len() });
+        self.revision += 1;
+    }
+
+    pub fn text(&self) -> String {
+        self.lines.join("\n")
+    }
+
+    pub fn line_count(&self) -> usize {
+        self.lines.len()
+    }
+
+    pub fn line(&self, i: usize) -> &str {
+        self.lines.get(i).map(String::as_str).unwrap_or("")
+    }
+
+    pub fn lines(&self) -> &[String] {
+        &self.lines
+    }
+
+    /// What changed since the last call, in order. A `removed` of
+    /// `usize::MAX` means everything.
+    pub fn take_changes(&mut self) -> Vec<Change> {
+        std::mem::take(&mut self.changes)
+    }
+
+    pub fn clamp(&self, p: Pos) -> Pos {
+        let line = p.line.min(self.lines.len().saturating_sub(1));
+        let s = self.line(line);
+        let mut col = p.col.min(s.len());
+        while !s.is_char_boundary(col) {
+            col -= 1;
+        }
+        Pos::new(line, col)
+    }
+
+    pub fn end(&self) -> Pos {
+        let last = self.lines.len() - 1;
+        Pos::new(last, self.lines[last].len())
+    }
+
+    /// The selection, ordered, when it is not empty.
+    pub fn selection(&self) -> Option<(Pos, Pos)> {
+        let a = self.anchor?;
+        if a == self.caret {
+            return None;
+        }
+        Some((a.min(self.caret), a.max(self.caret)))
+    }
+
+    pub fn text_range(&self, a: Pos, b: Pos) -> String {
+        let (a, b) = (a.min(b), a.max(b));
+        if a.line == b.line {
+            return self.line(a.line)[a.col..b.col].to_string();
+        }
+        let mut out = self.line(a.line)[a.col..].to_string();
+        for l in a.line + 1..b.line {
+            out.push('\n');
+            out.push_str(self.line(l));
+        }
+        out.push('\n');
+        out.push_str(&self.line(b.line)[..b.col]);
+        out
+    }
+
+    pub fn selected_text(&self) -> Option<String> {
+        self.selection().map(|(a, b)| self.text_range(a, b))
+    }
+
+    /// Replace `a..b` with `text`, no history. Returns where the inserted
+    /// text ends.
+    fn raw_replace(&mut self, a: Pos, b: Pos, text: &str) -> Pos {
+        let (a, b) = (a.min(b), a.max(b));
+        let head = self.lines[a.line][..a.col].to_string();
+        let tail = self.lines[b.line][b.col..].to_string();
+        let mut new: Vec<String> = text.split('\n').map(String::from).collect();
+        let n = new.len();
+        let end = if n == 1 { Pos::new(a.line, a.col + new[0].len()) } else { Pos::new(a.line + n - 1, new[n - 1].len()) };
+        new[0].insert_str(0, &head);
+        new[n - 1].push_str(&tail);
+        self.lines.splice(a.line..=b.line, new);
+        self.changes.push(Change { first: a.line, removed: b.line - a.line + 1, inserted: n });
+        self.revision += 1;
+        end
+    }
+
+    /// Replace `a..b` with `text` as one undoable edit; the caret lands at
+    /// the end of the inserted text, and the selection goes.
+    pub fn replace(&mut self, a: Pos, b: Pos, text: &str, kind: EditKind) {
+        let (a, b) = (self.clamp(a.min(b)), self.clamp(a.max(b)));
+        let removed = self.text_range(a, b);
+        if removed.is_empty() && text.is_empty() {
+            return;
+        }
+        let before = (self.caret, self.anchor);
+        let end = self.raw_replace(a, b, text);
+        self.caret = end;
+        self.anchor = None;
+        self.redo.clear();
+        let edit = Edit { start: a, removed, inserted: text.to_string(), caret_before: before, caret_after: (end, None), kind };
+        // A run of typing (or deleting) is one undo step.
+        if let Some(last) = self.undo.last_mut() {
+            let joins = match kind {
+                EditKind::Typing => {
+                    last.kind == EditKind::Typing
+                        && edit.removed.is_empty()
+                        && !text.contains('\n')
+                        && end_of(last.start, &last.inserted) == a
+                }
+                EditKind::Deleting => last.kind == EditKind::Deleting && edit.inserted.is_empty() && (last.start == b || last.start == a),
+                EditKind::Other => false,
+            };
+            if joins {
+                match kind {
+                    EditKind::Typing => last.inserted.push_str(text),
+                    _ if last.start == b => {
+                        // Backspacing: the new text removed sits before.
+                        last.removed.insert_str(0, &edit.removed);
+                        last.start = a;
+                    }
+                    _ => last.removed.push_str(&edit.removed),
+                }
+                last.caret_after = edit.caret_after;
+                return;
+            }
+        }
+        self.undo.push(edit);
+    }
+
+    /// Type `text` over the selection, or at the caret.
+    pub fn insert(&mut self, text: &str, kind: EditKind) {
+        let (a, b) = self.selection().unwrap_or((self.caret, self.caret));
+        self.replace(a, b, text, kind);
+    }
+
+    pub fn delete_selection(&mut self) -> bool {
+        match self.selection() {
+            Some((a, b)) => {
+                self.replace(a, b, "", EditKind::Other);
+                true
+            }
+            None => false,
+        }
+    }
+
+    pub fn backspace(&mut self, word: bool) {
+        if self.delete_selection() {
+            return;
+        }
+        let to = if word { self.word_left(self.caret) } else { self.prev(self.caret) };
+        if to != self.caret {
+            self.replace(to, self.caret, "", EditKind::Deleting);
+        }
+    }
+
+    pub fn delete_forward(&mut self, word: bool) {
+        if self.delete_selection() {
+            return;
+        }
+        let to = if word { self.word_right(self.caret) } else { self.next(self.caret) };
+        if to != self.caret {
+            let at = self.caret;
+            self.replace(at, to, "", EditKind::Deleting);
+            self.caret = at;
+        }
+    }
+
+    pub fn undo(&mut self) -> bool {
+        let Some(e) = self.undo.pop() else { return false };
+        let end = end_of(e.start, &e.inserted);
+        self.raw_replace(e.start, end, &e.removed);
+        (self.caret, self.anchor) = e.caret_before;
+        self.redo.push(e);
+        true
+    }
+
+    pub fn redo(&mut self) -> bool {
+        let Some(e) = self.redo.pop() else { return false };
+        let end = end_of(e.start, &e.removed);
+        self.raw_replace(e.start, end, &e.inserted);
+        (self.caret, self.anchor) = e.caret_after;
+        self.undo.push(e);
+        true
+    }
+
+    /// Break the current typing run, so the next keystroke starts a new
+    /// undo step (a caret move between them, a pause).
+    pub fn seal(&mut self) {
+        if let Some(last) = self.undo.last_mut() {
+            last.kind = EditKind::Other;
+        }
+    }
+
+    /// Move the caret, extending the selection when `select`.
+    pub fn set_caret(&mut self, p: Pos, select: bool) {
+        let p = self.clamp(p);
+        if select {
+            if self.anchor.is_none() {
+                self.anchor = Some(self.caret);
+            }
+        } else {
+            self.anchor = None;
+        }
+        self.caret = p;
+        self.seal();
+    }
+
+    pub fn select_all(&mut self) {
+        self.anchor = Some(Pos::default());
+        self.caret = self.end();
+    }
+
+    // ---- boundaries ----------------------------------------------------
+
+    pub fn prev(&self, p: Pos) -> Pos {
+        if p.col == 0 {
+            return if p.line == 0 { p } else { Pos::new(p.line - 1, self.line(p.line - 1).len()) };
+        }
+        let s = self.line(p.line);
+        let col = s[..p.col].char_indices().next_back().map(|(i, _)| i).unwrap_or(0);
+        Pos::new(p.line, col)
+    }
+
+    pub fn next(&self, p: Pos) -> Pos {
+        let s = self.line(p.line);
+        if p.col >= s.len() {
+            return if p.line + 1 >= self.lines.len() { p } else { Pos::new(p.line + 1, 0) };
+        }
+        let c = s[p.col..].chars().next().map(char::len_utf8).unwrap_or(1);
+        Pos::new(p.line, p.col + c)
+    }
+
+    /// The start of the word before `p` (skipping spaces first), or the
+    /// end of the previous line at a line start.
+    pub fn word_left(&self, p: Pos) -> Pos {
+        if p.col == 0 {
+            return self.prev(p);
+        }
+        let s = &self.line(p.line)[..p.col];
+        let chars: Vec<(usize, char)> = s.char_indices().collect();
+        let mut i = chars.len();
+        while i > 0 && chars[i - 1].1.is_whitespace() {
+            i -= 1;
+        }
+        let word = |c: char| c.is_alphanumeric() || c == '_';
+        if i > 0 {
+            let in_word = word(chars[i - 1].1);
+            while i > 0 && !chars[i - 1].1.is_whitespace() && word(chars[i - 1].1) == in_word {
+                i -= 1;
+            }
+        }
+        Pos::new(p.line, chars.get(i).map(|(b, _)| *b).unwrap_or(0))
+    }
+
+    pub fn word_right(&self, p: Pos) -> Pos {
+        let s = self.line(p.line);
+        if p.col >= s.len() {
+            return self.next(p);
+        }
+        let rest: Vec<(usize, char)> = s[p.col..].char_indices().collect();
+        let mut i = 0;
+        while i < rest.len() && rest[i].1.is_whitespace() {
+            i += 1;
+        }
+        let word = |c: char| c.is_alphanumeric() || c == '_';
+        if i < rest.len() {
+            let in_word = word(rest[i].1);
+            while i < rest.len() && !rest[i].1.is_whitespace() && word(rest[i].1) == in_word {
+                i += 1;
+            }
+        }
+        Pos::new(p.line, p.col + rest.get(i).map(|(b, _)| *b).unwrap_or(s.len() - p.col))
+    }
+
+    /// The word around `p` (a double-click's selection).
+    pub fn word_at(&self, p: Pos) -> (Pos, Pos) {
+        let s = self.line(p.line);
+        let word = |c: char| c.is_alphanumeric() || c == '_';
+        let mut a = p.col;
+        while a > 0 {
+            let prev = s[..a].chars().next_back().unwrap();
+            if !word(prev) {
+                break;
+            }
+            a -= prev.len_utf8();
+        }
+        let mut b = p.col;
+        while let Some(c) = s[b..].chars().next() {
+            if !word(c) {
+                break;
+            }
+            b += c.len_utf8();
+        }
+        (Pos::new(p.line, a), Pos::new(p.line, b))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn insert_newlines_and_changes() {
+        let mut b = Buffer::new("ab\ncd");
+        b.take_changes();
+        b.caret = Pos::new(0, 1);
+        b.insert("X\nY", EditKind::Other);
+        assert_eq!(b.text(), "aX\nYb\ncd");
+        assert_eq!(b.caret, Pos::new(1, 1));
+        assert_eq!(b.take_changes(), [Change { first: 0, removed: 1, inserted: 2 }]);
+        b.set_caret(Pos::new(0, 1), false);
+        b.set_caret(Pos::new(2, 1), true);
+        assert_eq!(b.selected_text().as_deref(), Some("X\nYb\nc"));
+        b.insert("", EditKind::Other);
+        assert_eq!(b.text(), "ad");
+    }
+
+    #[test]
+    fn typing_undoes_as_one_step_and_redoes() {
+        let mut b = Buffer::new("");
+        for c in ["h", "e", "y"] {
+            b.insert(c, EditKind::Typing);
+        }
+        b.insert("\n", EditKind::Other);
+        b.insert("x", EditKind::Typing);
+        assert_eq!(b.text(), "hey\nx");
+        assert!(b.undo());
+        assert_eq!(b.text(), "hey\n");
+        assert!(b.undo());
+        assert!(b.undo());
+        assert_eq!(b.text(), "");
+        assert!(!b.undo());
+        assert!(b.redo());
+        assert_eq!(b.text(), "hey");
+        assert_eq!(b.caret, Pos::new(0, 3));
+    }
+
+    #[test]
+    fn backspace_runs_join_and_restore() {
+        let mut b = Buffer::new("héllo wörld");
+        b.caret = b.end();
+        for _ in 0..3 {
+            b.backspace(false);
+        }
+        assert_eq!(b.text(), "héllo wö");
+        b.backspace(true);
+        assert_eq!(b.text(), "héllo ");
+        assert!(b.undo());
+        assert_eq!(b.text(), "héllo wörld");
+        // At a line start, backspace joins lines.
+        let mut b = Buffer::new("a\nb");
+        b.caret = Pos::new(1, 0);
+        b.backspace(false);
+        assert_eq!(b.text(), "ab");
+        assert_eq!(b.caret, Pos::new(0, 1));
+    }
+
+    #[test]
+    fn words_and_boundaries() {
+        let b = Buffer::new("foo bar_baz  qux.");
+        assert_eq!(b.word_right(Pos::new(0, 0)), Pos::new(0, 3));
+        assert_eq!(b.word_right(Pos::new(0, 3)), Pos::new(0, 11));
+        assert_eq!(b.word_left(Pos::new(0, 13)), Pos::new(0, 4));
+        assert_eq!(b.word_at(Pos::new(0, 6)), (Pos::new(0, 4), Pos::new(0, 11)));
+        let b = Buffer::new("é");
+        assert_eq!(b.next(Pos::new(0, 0)), Pos::new(0, 2));
+        assert_eq!(b.clamp(Pos::new(0, 1)), Pos::new(0, 0));
+    }
+
+    #[test]
+    fn delete_forward_keeps_the_caret() {
+        let mut b = Buffer::new("abc");
+        b.caret = Pos::new(0, 1);
+        b.delete_forward(false);
+        b.delete_forward(false);
+        assert_eq!(b.text(), "a");
+        assert_eq!(b.caret, Pos::new(0, 1));
+        assert!(b.undo());
+        assert_eq!(b.text(), "abc");
+    }
+}
diff --git a/src/widget/doc_editor/layout.rs b/src/widget/doc_editor/layout.rs
new file mode 100644
index 0000000..d9f65b1
--- /dev/null
+++ b/src/widget/doc_editor/layout.rs
@@ -0,0 +1,482 @@
+//! One line of the document, styled ([`preview::Line`]), laid out at a
+//! width: wrapped rows of runs, decorations, and the x of every byte the
+//! runs show — what the caret is drawn at and what a click maps back to.
+//!
+//! Wrapping is decided on measured word widths (a word running across
+//! styles wraps whole); then each run — consecutive bytes of one look on
+//! one row — is shaped once and its char boundaries recorded, and runs are
+//! placed at their shaped widths, so drawn text, caret and clicks agree.
+//! Spaces belong to the run before them, so a caret between words maps
+//! inside a run. A tab is shaped as one space.
+
+use std::ops::Range;
+
+use super::preview::{self, Kind, Look, Marker, Target};
+use crate::scene::layout::Rect;
+use crate::scene::paint::TextAttrs;
+use crate::widget::shaping::{Measure, ShapingMeasure};
+
+/// Colours and fonts; colours are linear (what prims take).
+#[derive(Clone, Debug, PartialEq)]
+pub struct EditorTheme {
+    pub body_font: String,
+    pub mono_font: String,
+    pub size: f32,
+    /// Line height as a multiple of the font size.
+    pub spacing: f32,
+    pub fg: [f32; 4],
+    pub dim: [f32; 4],
+    pub link: [f32; 4],
+    /// A note link with no target (the host says which, at paint).
+    pub link_unresolved: [f32; 4],
+    pub code_bg: [f32; 4],
+    pub highlight_bg: [f32; 4],
+    pub tag_bg: [f32; 4],
+    pub accent: [f32; 4],
+    pub rule: [f32; 4],
+    pub caret: [f32; 4],
+    pub selection: [f32; 4],
+}
+
+impl EditorTheme {
+    /// Obsidian-ish dark defaults at `size`, in the DE's sans and mono.
+    pub fn new(size: f32) -> EditorTheme {
+        let lin = crate::colors::to_linear;
+        EditorTheme {
+            body_font: "sans-serif".into(),
+            mono_font: "monospace".into(),
+            size,
+            spacing: 1.55,
+            fg: crate::colors::TEXT_FG,
+            dim: crate::colors::TEXT_DIM,
+            link: lin([0.66, 0.55, 0.98, 1.0]),
+            link_unresolved: lin([0.50, 0.44, 0.70, 1.0]),
+            code_bg: [1.0, 1.0, 1.0, 0.06],
+            highlight_bg: lin([1.0, 0.82, 0.0, 0.40]),
+            tag_bg: lin([0.66, 0.55, 0.98, 0.15]),
+            accent: lin([0.66, 0.55, 0.98, 1.0]),
+            rule: [1.0, 1.0, 1.0, 0.14],
+            caret: lin([0.85, 0.85, 0.92, 1.0]),
+            selection: lin([0.40, 0.45, 0.75, 0.45]),
+        }
+    }
+}
+
+/// Indent per list level, and the gutter a list marker sits in.
+fn indent(th: &EditorTheme) -> f32 {
+    (th.size * 1.6).round()
+}
+fn gutter(th: &EditorTheme) -> f32 {
+    (th.size * 1.6).round()
+}
+const QUOTE_STEP: f32 = 18.0;
+
+#[derive(Clone, Debug)]
+pub struct Run {
+    pub row: usize,
+    pub x: f32,
+    pub w: f32,
+    /// Source bytes of the line this run shows.
+    pub src: Range<usize>,
+    pub text: String,
+    pub size: f32,
+    pub font: String,
+    pub attrs: TextAttrs,
+    pub color: [f32; 4],
+    pub bg: Option<[f32; 4]>,
+    pub strike: bool,
+    pub link: Option<usize>,
+    pub look: Look,
+    /// (line byte, x relative to `x`) at every char boundary of `src`.
+    pub xs: Vec<(usize, f32)>,
+}
+
+#[derive(Clone, Debug)]
+pub enum Deco {
+    /// A filled rect (code backgrounds, rules, quote bars).
+    Quad(Rect, [f32; 4]),
+    Dot { cx: f32, cy: f32, r: f32, color: [f32; 4] },
+    Check { cx: f32, cy: f32, r: f32, checked: bool },
+    /// A list number, drawn in the gutter.
+    Text { text: String, x: f32, y: f32, size: f32, color: [f32; 4], font: String },
+}
+
+#[derive(Clone, Debug)]
+pub struct LineLayout {
+    pub height: f32,
+    pub row_h: f32,
+    pub rows: usize,
+    pub runs: Vec<Run>,
+    pub decos: Vec<Deco>,
+    pub links: Vec<Target>,
+    /// A task's checkbox hit rect, and the byte of its status char.
+    pub task: Option<(Rect, usize)>,
+    /// Where an empty line's (or a hidden prefix's) caret sits.
+    pub content_x: f32,
+    pub content_start: usize,
+    pub text_size: f32,
+}
+
+fn size_for(kind: &Kind, base: f32) -> f32 {
+    match kind {
+        Kind::Heading(1) => (base * 1.6).round(),
+        Kind::Heading(2) => (base * 1.4).round(),
+        Kind::Heading(3) => (base * 1.25).round(),
+        Kind::Heading(4) => (base * 1.1).round(),
+        Kind::Code | Kind::Fence | Kind::Frontmatter | Kind::Table => (base * 0.92).round(),
+        _ => base,
+    }
+}
+
+struct Piece {
+    src: Range<usize>,
+    seg: usize,
+    row: usize,
+}
+
+/// Lay out `text` (one line) styled as `line`, wrapping at `width`.
+/// `active` is whether the caret is on it (its markers show).
+pub fn layout_line(text: &str, line: &preview::Line, active: bool, width: f32, th: &EditorTheme, m: &mut ShapingMeasure) -> LineLayout {
+    let base = size_for(&line.kind, th.size);
+    let row_h = (base * th.spacing).ceil();
+    let heading = matches!(line.kind, Kind::Heading(_));
+    let look_font = |look: &Look| -> (String, f32, TextAttrs) {
+        let mono = look.mono || look.code;
+        let size = if look.code && !look.mono { (base * 0.92).round() } else { base };
+        let font = if mono { th.mono_font.clone() } else { th.body_font.clone() };
+        (font, size, TextAttrs { italic: look.italic, weight: (look.bold || heading).then_some(700) })
+    };
+
+    // Where content starts, and the decorations of a hidden prefix.
+    let mut decos: Vec<Deco> = Vec::new();
+    let mut task = None;
+    let mut content_x = 0.0f32;
+    match &line.kind {
+        Kind::List { level, marker } => {
+            let x0 = *level as f32 * indent(th);
+            // A task's gutter holds its raw `- [ ] ` too, so the text stays
+            // put when the caret arrives and the marker shows.
+            let g = match marker {
+                Marker::Task(..) => gutter(th).max(m.width("- [ ] ", base, &th.body_font, TextAttrs::default()).ceil()),
+                _ => gutter(th),
+            };
+            content_x = x0 + g;
+            if !active {
+                let cy = row_h / 2.0;
+                let cx = x0 + g / 2.0;
+                match marker {
+                    Marker::Bullet => decos.push(Deco::Dot { cx, cy, r: (th.size * 0.17).max(2.0), color: th.dim }),
+                    Marker::Number(n) => {
+                        let w = m.width(n, base, &th.body_font, TextAttrs::default());
+                        decos.push(Deco::Text {
+                            text: n.clone(),
+                            x: content_x - w - th.size * 0.35,
+                            y: (row_h - base) / 2.0,
+                            size: base,
+                            color: th.dim,
+                            font: th.body_font.clone(),
+                        })
+                    }
+                    Marker::Task(c, at) => {
+                        let r = (th.size * 0.42).round();
+                        decos.push(Deco::Check { cx, cy, r, checked: *c != ' ' });
+                        task = Some((Rect { x: cx - r - 3.0, y: cy - r - 3.0, width: 2.0 * r + 6.0, height: 2.0 * r + 6.0 }, *at));
+                    }
+                }
+            } else if let Marker::Task(_, at) = marker {
+                // The raw `- [ ] ` shows, but the box stays clickable.
+                task = Some((Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 }, *at));
+            }
+        }
+        Kind::Quote(d) => content_x = *d as f32 * QUOTE_STEP,
+        _ => {}
+    }
+
+    // Pass 1: break into rows on measured widths.
+    let segs = &line.segs;
+    let looks: Vec<(String, f32, TextAttrs)> = segs.iter().map(|s| look_font(&s.look)).collect();
+    let mut pieces: Vec<Piece> = Vec::new();
+    // On the active line the hidden prefix shows; its markers end where
+    // the content begins, so text does not jump when the caret arrives.
+    let prefix_w: f32 = if active && line.content_start > 0 {
+        segs.iter()
+            .enumerate()
+            .filter(|(_, s)| s.range.end <= line.content_start && s.look.marker)
+            .map(|(i, s)| m.width(&text[s.range.clone()].replace('\t', " "), looks[i].1, &looks[i].0, looks[i].2))
+            .sum()
+    } else {
+        0.0
+    };
+    let start_x = (content_x - prefix_w).max(0.0);
+    let right = width.max(content_x + 40.0);
+    let mut cx = start_x;
+    let mut row = 0usize;
+    let mut row_start = true;
+    for (si, seg) in segs.iter().enumerate() {
+        let (font, size, attrs) = &looks[si];
+        let t = &text[seg.range.clone()];
+        let mut i = 0;
+        let bytes = t.as_bytes();
+        while i < bytes.len() {
+            let ws = bytes[i] == b' ' || bytes[i] == b'\t';
+            let mut j = i;
+            while j < bytes.len() && (bytes[j] == b' ' || bytes[j] == b'\t') == ws {
+                j += 1;
+            }
+            while j < t.len() && !t.is_char_boundary(j) {
+                j += 1;
+            }
+            let src = seg.range.start + i..seg.range.start + j;
+            if ws {
+                // Spaces join the piece before them (or lead a row).
+                if row_start && cx <= content_x && !active {
+                    // Leading whitespace of a row shows as nothing.
+                    pieces.push(Piece { src, seg: si, row });
+                } else {
+                    let w = m.width(&t[i..j].replace('\t', " "), *size, font, *attrs);
+                    pieces.push(Piece { src, seg: si, row });
+                    cx += w;
+                }
+            } else {
+                let w = m.width(&t[i..j], *size, font, *attrs);
+                // A word glued to the previous piece (no space between,
+                // other style) moves with it.
+                let glued = pieces.last().is_some_and(|p| p.src.end == src.start && !text[p.src.clone()].ends_with([' ', '\t']));
+                if cx + w > right && !row_start {
+                    if glued {
+                        // Carry the glued run down with this word.
+                        let mut k = pieces.len();
+                        while k > 0 && pieces[k - 1].row == row && !text[pieces[k - 1].src.clone()].ends_with([' ', '\t']) && (k == pieces.len() || pieces[k - 1].src.end == pieces[k].src.start) {
+                            k -= 1;
+                        }
+                        if k > 0 && pieces[k - 1].row == row {
+                            row += 1;
+                            for p in &mut pieces[k..] {
+                                p.row = row;
+                            }
+                            cx = content_x;
+                            for p in &pieces[k..] {
+                                let (f, s, a) = &looks[p.seg];
+                                cx += m.width(&text[p.src.clone()], *s, f, *a);
+                            }
+                        }
+                    } else {
+                        row += 1;
+                        cx = content_x;
+                    }
+                }
+                pieces.push(Piece { src, seg: si, row });
+                cx += w;
+                row_start = false;
+            }
+            i = j;
+        }
+    }
+
+    // Obsidian dims a finished task's text (the reading view does too).
+    let done = matches!(&line.kind, Kind::List { marker: Marker::Task(c, _), .. } if *c != ' ');
+
+    // Pass 2: merge pieces into runs, shape each, place left to right.
+    let mut runs: Vec<Run> = Vec::new();
+    let mut row_x: Vec<f32> = Vec::new();
+    for p in pieces {
+        let seg = &segs[p.seg];
+        let joins = runs.last().is_some_and(|r: &Run| {
+            r.row == p.row && r.src.end == p.src.start && r.look == seg.look && r.link == seg.link && !text[p.src.clone()].contains('\t') && !text[r.src.clone()].contains('\t')
+        });
+        if joins {
+            let r = runs.last_mut().unwrap();
+            r.src.end = p.src.end;
+        } else {
+            let (font, size, attrs) = looks[p.seg].clone();
+            let color = if done && seg.link.is_none() { th.dim } else { color_for(&seg.look, th) };
+            let bg = if seg.look.code && !seg.look.mono {
+                Some(th.code_bg)
+            } else if seg.look.highlight {
+                Some(th.highlight_bg)
+            } else if seg.look.tag {
+                Some(th.tag_bg)
+            } else {
+                None
+            };
+            runs.push(Run {
+                row: p.row,
+                x: 0.0,
+                w: 0.0,
+                src: p.src,
+                text: String::new(),
+                size,
+                font,
+                attrs,
+                color,
+                bg,
+                strike: seg.look.strike,
+                link: seg.link,
+                look: seg.look,
+                xs: Vec::new(),
+            });
+        }
+    }
+    for r in &mut runs {
+        while row_x.len() <= r.row {
+            row_x.push(if row_x.is_empty() { start_x } else { content_x });
+        }
+        let shown = text[r.src.clone()].replace('\t', " ");
+        let offs = m.offsets(&shown, r.size, &r.font, r.attrs);
+        r.w = offs.last().map(|o| o.1).unwrap_or(0.0);
+        r.xs = offs.into_iter().map(|(b, x)| (r.src.start + b, x)).collect();
+        r.text = shown;
+        r.x = row_x[r.row];
+        row_x[r.row] += r.w;
+    }
+    let rows = runs.last().map(|r| r.row + 1).unwrap_or(1);
+    let height = rows as f32 * row_h;
+
+    // Block decorations that span the line.
+    match &line.kind {
+        Kind::Quote(d) => {
+            for k in 0..*d {
+                decos.push(Deco::Quad(Rect { x: k as f32 * QUOTE_STEP + 2.0, y: 0.0, width: 3.0, height }, th.accent));
+            }
+        }
+        Kind::Rule if !active => decos.push(Deco::Quad(Rect { x: 0.0, y: (height / 2.0).round(), width, height: 1.0 }, th.rule)),
+        Kind::Code | Kind::Fence => decos.insert(0, Deco::Quad(Rect { x: -8.0, y: 0.0, width: width + 16.0, height }, th.code_bg)),
+        _ => {}
+    }
+    LineLayout { height, row_h, rows, runs, decos, links: line.links.clone(), task, content_x, content_start: line.content_start, text_size: base }
+}
+
+fn color_for(look: &Look, th: &EditorTheme) -> [f32; 4] {
+    if look.marker || look.comment || look.dim {
+        th.dim
+    } else if look.link || look.tag {
+        th.link
+    } else {
+        th.fg
+    }
+}
+
+impl LineLayout {
+    /// Where the caret before byte `col` is drawn: (x, row).
+    pub fn caret_xy(&self, col: usize) -> (f32, usize) {
+        let mut best: Option<(f32, usize)> = None;
+        for r in &self.runs {
+            if col < r.src.start {
+                break;
+            }
+            if col <= r.src.end {
+                let x = r.xs.iter().rev().find(|(b, _)| *b <= col).map(|(_, x)| *x).unwrap_or(0.0);
+                best = Some((r.x + x, r.row));
+                if col < r.src.end {
+                    break;
+                }
+            } else {
+                best = Some((r.x + r.w, r.row));
+            }
+        }
+        best.unwrap_or_else(|| match self.runs.first() {
+            Some(r) => (r.x, r.row),
+            None => (self.content_x, 0),
+        })
+    }
+
+    /// The byte nearest a point (x, row) of the line.
+    pub fn col_at(&self, x: f32, row: usize) -> usize {
+        let in_row: Vec<&Run> = self.runs.iter().filter(|r| r.row == row).collect();
+        if in_row.is_empty() {
+            return match self.runs.iter().rev().find(|r| r.row < row) {
+                Some(r) => r.src.end,
+                None => self.content_start,
+            };
+        }
+        let mut best = (f32::INFINITY, in_row[0].src.start);
+        for r in in_row {
+            for (b, bx) in &r.xs {
+                let d = (r.x + bx - x).abs();
+                if d < best.0 {
+                    best = (d, *b);
+                }
+            }
+        }
+        best.1
+    }
+
+    /// The link under a point of the line, by index into `links`.
+    pub fn link_at(&self, x: f32, y: f32) -> Option<usize> {
+        let row = (y / self.row_h).floor().max(0.0) as usize;
+        self.runs.iter().find(|r| r.row == row && r.link.is_some() && x >= r.x && x <= r.x + r.w).and_then(|r| r.link)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::widget::doc_editor::preview::{style_line, Context};
+
+    fn lay(text: &str, active: bool, width: f32) -> LineLayout {
+        let th = EditorTheme::new(14.0);
+        let mut m = ShapingMeasure::new(false);
+        let line = style_line(text, Context::Normal, active);
+        layout_line(text, &line, active, width, &th, &mut m)
+    }
+
+    #[test]
+    fn every_shown_byte_has_an_x_and_clicks_come_back() {
+        let t = "plain **bold** and [[Link|shown]] end";
+        for active in [false, true] {
+            let l = lay(t, active, 2000.0);
+            assert_eq!(l.rows, 1);
+            let mut last = -1.0;
+            for r in &l.runs {
+                for (b, x) in &r.xs {
+                    assert!(t.is_char_boundary(*b));
+                    let ax = r.x + x;
+                    assert!(ax >= last - 0.01, "x runs backwards at {b}: {ax} < {last}");
+                    last = ax;
+                }
+            }
+            // Every visible boundary maps to a caret x that maps back to it.
+            for r in &l.runs {
+                for (b, _) in &r.xs {
+                    let (x, row) = l.caret_xy(*b);
+                    let back = l.col_at(x, row);
+                    let (x2, _) = l.caret_xy(back);
+                    assert!((x - x2).abs() < 0.5, "{b} -> {x} -> {back}");
+                }
+            }
+        }
+    }
+
+    #[test]
+    fn long_lines_wrap_with_a_hanging_list_indent() {
+        let t = "- one two three four five six seven eight nine ten eleven twelve";
+        let l = lay(t, false, 160.0);
+        assert!(l.rows > 2, "rows {}", l.rows);
+        let first_x: Vec<f32> = (0..l.rows).map(|row| l.runs.iter().find(|r| r.row == row).unwrap().x).collect();
+        assert!(first_x.iter().all(|x| (*x - l.content_x).abs() < 0.5), "{first_x:?} vs {}", l.content_x);
+        assert!(matches!(l.decos[0], Deco::Dot { .. }));
+    }
+
+    #[test]
+    fn the_active_lines_marker_ends_where_the_content_starts() {
+        let t = "- [ ] task text";
+        let off = lay(t, false, 1000.0);
+        let on = lay(t, true, 1000.0);
+        let text_x = |l: &LineLayout| l.caret_xy(6).0;
+        assert!((text_x(&off) - text_x(&on)).abs() < 1.5, "{} vs {}", text_x(&off), text_x(&on));
+        assert!(off.task.is_some() && matches!(off.decos[0], Deco::Check { checked: false, .. }));
+        // Hidden prefix: a click left of the text lands at its start.
+        assert_eq!(off.col_at(0.0, 0), 6);
+    }
+
+    #[test]
+    fn empty_and_link_lines() {
+        let l = lay("", false, 500.0);
+        assert_eq!((l.rows, l.caret_xy(0)), (1, (0.0, 0)));
+        let t = "see [[Target]]";
+        let l = lay(t, false, 500.0);
+        let r = l.runs.iter().find(|r| r.link.is_some()).unwrap();
+        assert_eq!(l.link_at(r.x + 2.0, 2.0), Some(0));
+        assert_eq!(l.link_at(1.0, 2.0), None);
+    }
+}
diff --git a/src/widget/doc_editor/mod.rs b/src/widget/doc_editor/mod.rs
new file mode 100644
index 0000000..d1cc680
--- /dev/null
+++ b/src/widget/doc_editor/mod.rs
@@ -0,0 +1,982 @@
+//! `DocEditor`: a document editor with Markdown live preview — cce-notes'
+//! editing mode (Obsidian-on-cce milestone 6), and a fast plain editor with
+//! preview off.
+//!
+//! - [`buffer`]: the text as lines, caret and selection, undoable edits.
+//! - [`preview`]: how a line shows — markup hidden except on the caret's
+//!   line, where it shows dimmed (Obsidian's live preview).
+//! - [`layout`]: one styled line wrapped into runs, with the x of every
+//!   byte, so drawing, caret and clicks agree.
+//!
+//! **Incremental.** A line is laid out (shaped) only when it is drawn and
+//! has changed: an edit invalidates the lines it replaced
+//! ([`buffer::Change`]), a moved caret the lines it left and entered (their
+//! markers show or hide), a fence the block contexts after it. Lines never
+//! drawn keep an estimated height, so opening a long file shapes one
+//! screen of it.
+//!
+//! **Driven by its host, not a registered widget.** The app forwards keys,
+//! pointer events and the wheel, and paints it into its display list
+//! ([`DocEditor::paint`]); answers come back as [`Response`] — a link to
+//! follow is the host's to resolve. The caret does not blink: a blinking
+//! caret is a frame every half second for as long as the window is open.
+
+pub mod buffer;
+pub mod layout;
+pub mod preview;
+
+use std::time::{Duration, Instant};
+
+pub use buffer::{Buffer, EditKind, Pos};
+pub use layout::EditorTheme;
+pub use preview::Target;
+
+use crate::scene::layout::Rect;
+use crate::scene::paint::{Cap, PaintCtx};
+use crate::widget::shaping::ShapingMeasure;
+use crate::widget::{Bounds, Key, KeyEvent, MouseScrollDelta, NamedKey, ScrollMotion};
+use layout::{Deco, LineLayout};
+use preview::{Context, Kind, Marker};
+
+/// What a key or click did, for the host.
+#[derive(Clone, Debug, PartialEq)]
+pub enum Response {
+    None,
+    /// The caret or selection moved; the text did not change.
+    Moved,
+    /// The text changed.
+    Changed,
+    /// A link was clicked (rendered) or Ctrl+clicked (raw).
+    Follow(Target),
+}
+
+const DOUBLE_CLICK: Duration = Duration::from_millis(400);
+
+pub struct DocEditor {
+    pub buf: Buffer,
+    /// Live preview: markup hidden except on the caret's lines. Off: every
+    /// line shows as written (with its syntax still dimmed).
+    pub preview: bool,
+    pub theme: EditorTheme,
+    /// The widest the text column grows (Obsidian's readable line length);
+    /// 0 lets it fill the rect.
+    pub max_width: f32,
+    pub pad: f32,
+    measure: ShapingMeasure,
+    ctx: Vec<Context>,
+    layouts: Vec<Option<LineLayout>>,
+    heights: Vec<f32>,
+    tops: Vec<f32>,
+    tops_dirty: bool,
+    /// Lines laid out as active (raw), so a caret move relays out the lines
+    /// it leaves and the ones it enters.
+    shown_active: (usize, usize),
+    width: f32,
+    pub scroll: f32,
+    motion: ScrollMotion,
+    /// The x a vertical move keeps to.
+    want_x: Option<f32>,
+    dragging: bool,
+    clicks: Option<(Instant, Pos, u8)>,
+    /// Where the text column was last painted: its origin and the rect.
+    origin: (f32, f32),
+    viewport: Rect,
+    follow_caret: bool,
+}
+
+impl DocEditor {
+    /// An editor over `text`. `system_fonts` must match the app's
+    /// `Application::load_system_fonts`, so measured widths are drawn ones.
+    pub fn new(text: &str, theme: EditorTheme, system_fonts: bool) -> DocEditor {
+        let mut e = DocEditor {
+            buf: Buffer::new(text),
+            preview: true,
+            theme,
+            max_width: 0.0,
+            pad: 24.0,
+            measure: ShapingMeasure::new(system_fonts),
+            ctx: Vec::new(),
+            layouts: Vec::new(),
+            heights: Vec::new(),
+            tops: Vec::new(),
+            tops_dirty: true,
+            shown_active: (usize::MAX, usize::MAX),
+            width: 0.0,
+            scroll: 0.0,
+            motion: ScrollMotion::new(),
+            want_x: None,
+            dragging: false,
+            clicks: None,
+            origin: (0.0, 0.0),
+            viewport: Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 },
+            follow_caret: false,
+        };
+        e.sync();
+        e
+    }
+
+    pub fn set_text(&mut self, text: &str) {
+        self.buf.set_text(text);
+        self.scroll = 0.0;
+        self.motion = ScrollMotion::new();
+        self.want_x = None;
+        self.sync();
+    }
+
+    pub fn text(&self) -> String {
+        self.buf.text()
+    }
+
+    /// Drop every layout (fonts or the theme changed).
+    pub fn invalidate(&mut self) {
+        for l in &mut self.layouts {
+            *l = None;
+        }
+        self.tops_dirty = true;
+    }
+
+    pub fn set_preview(&mut self, on: bool) {
+        if self.preview != on {
+            self.preview = on;
+            self.invalidate();
+        }
+    }
+
+    // ---- incremental bookkeeping ----------------------------------------
+
+    /// The lines shown raw: the caret's (or the selection's), widened to
+    /// a fenced code block the caret is in, so its fences show.
+    fn active_range(&self) -> (usize, usize) {
+        let (mut a, mut b) = match self.buf.selection() {
+            Some((a, b)) => (a.line, b.line),
+            None => (self.buf.caret.line, self.buf.caret.line),
+        };
+        if let Some((x, _)) = preview::fenced_block(&self.ctx, a) {
+            a = x;
+        }
+        if let Some((_, y)) = preview::fenced_block(&self.ctx, b) {
+            b = y;
+        }
+        (a, b)
+    }
+
+    fn is_active(&self, i: usize, act: (usize, usize)) -> bool {
+        !self.preview || (i >= act.0 && i <= act.1)
+    }
+
+    fn estimate(&self, i: usize) -> f32 {
+        let row = (self.theme.size * self.theme.spacing).ceil();
+        let chars = self.buf.line(i).chars().count() as f32;
+        let per_row = (self.width / (self.theme.size * 0.5)).max(10.0);
+        row * (chars / per_row).ceil().max(1.0)
+    }
+
+    /// Bring layouts, contexts and heights in line with the buffer.
+    fn sync(&mut self) {
+        for c in self.buf.take_changes() {
+            if c.removed == usize::MAX {
+                self.layouts = vec![None; self.buf.line_count()];
+                self.heights = vec![0.0; self.buf.line_count()];
+                self.ctx.clear();
+                continue;
+            }
+            let end = (c.first + c.removed).min(self.layouts.len());
+            self.layouts.splice(c.first..end, std::iter::repeat_with(|| None).take(c.inserted));
+            self.heights.splice(c.first..end, std::iter::repeat_n(0.0, c.inserted));
+            self.tops_dirty = true;
+        }
+        let n = self.buf.line_count();
+        self.layouts.resize_with(n, || None);
+        self.heights.resize(n, 0.0);
+        let ctx = preview::contexts(self.buf.lines());
+        if ctx.len() == self.ctx.len() {
+            for i in 0..n {
+                if ctx[i] != self.ctx[i] {
+                    self.layouts[i] = None;
+                }
+            }
+        } else {
+            for l in &mut self.layouts {
+                *l = None;
+            }
+        }
+        self.ctx = ctx;
+        let act = self.active_range();
+        if act != self.shown_active {
+            let (a, b) = self.shown_active;
+            for i in [a, b, act.0, act.1] {
+                if i < n {
+                    self.layouts[i] = None;
+                }
+            }
+            // A selection's middle lines too.
+            for i in act.0.min(n)..=act.1.min(n.saturating_sub(1)) {
+                self.layouts[i] = None;
+            }
+            if a != usize::MAX {
+                for i in a.min(n)..=b.min(n.saturating_sub(1)) {
+                    self.layouts[i] = None;
+                }
+            }
+            self.shown_active = act;
+        }
+        for i in 0..n {
+            let h = match &self.layouts[i] {
+                Some(l) => l.height,
+                None if self.heights[i] > 0.0 => self.heights[i],
+                None => self.estimate(i),
+            };
+            if (h - self.heights[i]).abs() > 0.01 {
+                self.heights[i] = h;
+                self.tops_dirty = true;
+            }
+        }
+        self.retop();
+    }
+
+    fn retop(&mut self) {
+        if !self.tops_dirty && self.tops.len() == self.heights.len() + 1 {
+            return;
+        }
+        self.tops.clear();
+        self.tops.reserve(self.heights.len() + 1);
+        let mut y = 0.0;
+        for h in &self.heights {
+            self.tops.push(y);
+            y += h;
+        }
+        self.tops.push(y);
+        self.tops_dirty = false;
+    }
+
+    fn ensure(&mut self, i: usize) {
+        if i >= self.layouts.len() || self.layouts[i].is_some() {
+            return;
+        }
+        let act = self.shown_active;
+        let active = self.is_active(i, act);
+        let text = self.buf.line(i);
+        let line = preview::style_line(text, self.ctx[i], active);
+        let l = layout::layout_line(text, &line, active, self.width, &self.theme, &mut self.measure);
+        if (l.height - self.heights[i]).abs() > 0.01 {
+            self.heights[i] = l.height;
+            self.tops_dirty = true;
+        }
+        self.layouts[i] = Some(l);
+    }
+
+    fn line_at_y(&self, y: f32) -> usize {
+        let n = self.heights.len();
+        match self.tops[..n].binary_search_by(|t| t.partial_cmp(&y).unwrap_or(std::cmp::Ordering::Less)) {
+            Ok(i) => i,
+            Err(0) => 0,
+            Err(i) => (i - 1).min(n - 1),
+        }
+    }
+
+    pub fn content_height(&self) -> f32 {
+        self.tops.last().copied().unwrap_or(0.0) + 2.0 * self.pad
+    }
+
+    fn max_scroll(&self) -> f32 {
+        (self.content_height() - self.viewport.height).max(0.0)
+    }
+
+    // ---- caret geometry -----------------------------------------------------
+
+    /// The caret's (x, y) in content space and its row height.
+    fn caret_point(&mut self, p: Pos) -> (f32, f32, f32) {
+        self.ensure(p.line);
+        self.retop();
+        let l = self.layouts[p.line].as_ref().expect("laid out");
+        let (x, row) = l.caret_xy(p.col);
+        (x, self.tops[p.line] + row as f32 * l.row_h, l.row_h)
+    }
+
+    /// The caret's rect on screen as last painted — for a popup at it.
+    pub fn caret_rect(&mut self) -> Rect {
+        let (x, y, h) = self.caret_point(self.buf.caret);
+        Rect { x: self.origin.0 + x, y: self.origin.1 + y - self.scroll, width: 2.0, height: h }
+    }
+
+    /// The position under a screen point.
+    fn pos_at(&mut self, sx: f32, sy: f32) -> Pos {
+        let (x, y) = (sx - self.origin.0, sy - self.origin.1 + self.scroll);
+        if y < 0.0 {
+            return Pos::default();
+        }
+        let i = self.line_at_y(y);
+        self.ensure(i);
+        self.retop();
+        let l = self.layouts[i].as_ref().unwrap();
+        let row = (((y - self.tops[i]) / l.row_h).floor().max(0.0) as usize).min(l.rows - 1);
+        Pos::new(i, l.col_at(x, row))
+    }
+
+    fn scroll_to_caret(&mut self) {
+        let (_, y, h) = self.caret_point(self.buf.caret);
+        let view = self.viewport.height - 2.0 * self.pad;
+        if view <= 0.0 {
+            return;
+        }
+        if y < self.scroll {
+            self.scroll = y;
+        } else if y + h > self.scroll + view {
+            self.scroll = y + h - view;
+        }
+        self.scroll = self.scroll.clamp(0.0, self.max_scroll());
+        self.motion.y.jump_to(self.scroll);
+    }
+
+    /// Move the caret one visual row (or `rows`) up or down, keeping `want_x`.
+    fn vertical(&mut self, rows: i32, select: bool) {
+        let p = self.buf.caret;
+        self.ensure(p.line);
+        let (cx, row) = self.layouts[p.line].as_ref().unwrap().caret_xy(p.col);
+        let x = *self.want_x.get_or_insert(cx);
+        let (mut line, mut row) = (p.line as i64, row as i64);
+        let mut left = rows.unsigned_abs();
+        while left > 0 {
+            row += rows.signum() as i64;
+            let rows_here = self.layouts[line as usize].as_ref().map_or(1, |l| l.rows) as i64;
+            if row < 0 {
+                if line == 0 {
+                    row = 0;
+                    break;
+                }
+                line -= 1;
+                self.ensure(line as usize);
+                row = self.layouts[line as usize].as_ref().unwrap().rows as i64 - 1;
+            } else if row >= rows_here {
+                if line as usize + 1 >= self.buf.line_count() {
+                    row = rows_here - 1;
+                    break;
+                }
+                line += 1;
+                self.ensure(line as usize);
+                row = 0;
+            }
+            left -= 1;
+        }
+        let l = self.layouts[line as usize].as_ref().unwrap();
+        let col = l.col_at(x, row as usize);
+        let keep = self.want_x;
+        self.buf.set_caret(Pos::new(line as usize, col), select);
+        self.want_x = keep;
+    }
+
+    // ---- input --------------------------------------------------------------
+
+    /// A key press. Undo/redo chords are the host's to route (the runner
+    /// sends them to `Application::undo`); call [`DocEditor::undo`] there.
+    pub fn key(&mut self, ev: &KeyEvent) -> Response {
+        self.sync();
+        let rev = self.buf.revision;
+        let caret = (self.buf.caret, self.buf.anchor);
+        let (ctrl, shift) = (ev.ctrl, ev.shift);
+        let mut keep_x = false;
+        match &ev.logical_key {
+            Key::Named(NamedKey::ArrowLeft) => {
+                let to = match (self.buf.selection(), shift, ctrl) {
+                    (Some((a, _)), false, false) => a,
+                    (_, _, true) => self.buf.word_left(self.buf.caret),
+                    _ => self.buf.prev(self.buf.caret),
+                };
+                self.buf.set_caret(to, shift);
+            }
+            Key::Named(NamedKey::ArrowRight) => {
+                let to = match (self.buf.selection(), shift, ctrl) {
+                    (Some((_, b)), false, false) => b,
+                    (_, _, true) => self.buf.word_right(self.buf.caret),
+                    _ => self.buf.next(self.buf.caret),
+                };
+                self.buf.set_caret(to, shift);
+            }
+            Key::Named(NamedKey::ArrowUp) => {
+                self.vertical(-1, shift);
+                keep_x = true;
+            }
+            Key::Named(NamedKey::ArrowDown) => {
+                self.vertical(1, shift);
+                keep_x = true;
+            }
+            Key::Named(NamedKey::PageUp) | Key::Named(NamedKey::PageDown) => {
+                let row = (self.theme.size * self.theme.spacing).max(1.0);
+                let rows = ((self.viewport.height - 2.0 * self.pad) / row).floor().max(1.0) as i32;
+                let dir = if matches!(ev.logical_key, Key::Named(NamedKey::PageUp)) { -1 } else { 1 };
+                self.vertical(dir * rows, shift);
+                keep_x = true;
+            }
+            Key::Named(NamedKey::Home) => {
+                let to = if ctrl {
+                    Pos::default()
+                } else {
+                    // Smart home: the content start first, then column 0.
+                    let p = self.buf.caret;
+                    self.ensure(p.line);
+                    let start = self.content_start(p.line);
+                    Pos::new(p.line, if p.col > start { start } else { 0 })
+                };
+                self.buf.set_caret(to, shift);
+            }
+            Key::Named(NamedKey::End) => {
+                let to = if ctrl { self.buf.end() } else { Pos::new(self.buf.caret.line, self.buf.line(self.buf.caret.line).len()) };
+                self.buf.set_caret(to, shift);
+            }
+            Key::Named(NamedKey::Backspace) => self.buf.backspace(ctrl),
+            Key::Named(NamedKey::Delete) => self.buf.delete_forward(ctrl),
+            Key::Named(NamedKey::Enter) => self.enter(),
+            Key::Named(NamedKey::Tab) => self.tab(shift),
+            Key::Named(NamedKey::Escape) => {
+                if self.buf.anchor.is_some() {
+                    self.buf.anchor = None;
+                } else {
+                    return Response::None;
+                }
+            }
+            Key::Character(c) if ctrl => match c.to_lowercase().as_str() {
+                "a" => self.buf.select_all(),
+                "c" => {
+                    if let Some(t) = self.buf.selected_text() {
+                        crate::widget::clipboard::copy_to_clipboard(&t);
+                    }
+                }
+                "x" => {
+                    if let Some(t) = self.buf.selected_text() {
+                        crate::widget::clipboard::copy_to_clipboard(&t);
+                        self.buf.delete_selection();
+                    }
+                }
+                "v" => {
+                    if let Some(t) = crate::widget::clipboard::read_from_clipboard() {
+                        self.buf.insert(&t.replace("\r\n", "\n"), EditKind::Other);
+                    }
+                }
+                "b" => self.wrap("**"),
+                "i" => self.wrap("*"),
+                _ => return Response::None,
+            },
+            _ => {
+                let Some(text) = ev.text.as_deref() else { return Response::None };
+                if ctrl || ev.alt || text.is_empty() || text.chars().any(|c| c.is_control()) {
+                    return Response::None;
+                }
+                let kind = if text.chars().all(char::is_whitespace) { EditKind::Other } else { EditKind::Typing };
+                self.buf.insert(text, kind);
+            }
+        }
+        if !keep_x {
+            self.want_x = None;
+        }
+        self.after_edit(rev, caret)
+    }
+
+    fn after_edit(&mut self, rev: u64, caret: (Pos, Option<Pos>)) -> Response {
+        if self.buf.revision != rev {
+            self.sync();
+            self.follow_caret = true;
+            Response::Changed
+        } else if (self.buf.caret, self.buf.anchor) != caret {
+            self.sync();
+            self.follow_caret = true;
+            Response::Moved
+        } else {
+            Response::None
+        }
+    }
+
+    fn content_start(&self, line: usize) -> usize {
+        preview::style_line(self.buf.line(line), self.ctx.get(line).copied().unwrap_or(Context::Normal), true).content_start
+    }
+
+    /// Enter continues a list item (`- `, `1. ` counting on, `- [ ] `),
+    /// and on an empty item ends the list instead, as Obsidian does.
+    fn enter(&mut self) {
+        let p = self.buf.caret;
+        let text = self.buf.line(p.line).to_string();
+        let ctx = self.ctx.get(p.line).copied().unwrap_or(Context::Normal);
+        let line = preview::style_line(&text, ctx, true);
+        if self.buf.selection().is_none() {
+            if let Kind::List { marker, .. } = &line.kind {
+                let body = &text[line.content_start..];
+                if body.trim().is_empty() && p.col >= line.content_start {
+                    // An empty item: drop its marker, leave the list.
+                    self.buf.replace(Pos::new(p.line, 0), Pos::new(p.line, text.len()), "", EditKind::Other);
+                    return;
+                }
+                let indent: String = text.chars().take_while(|c| *c == ' ' || *c == '\t').collect();
+                let next = match marker {
+                    Marker::Bullet => format!("{indent}{} ", text.trim_start().chars().next().unwrap_or('-')),
+                    Marker::Number(n) => {
+                        let digits: String = n.chars().filter(char::is_ascii_digit).collect();
+                        let delim = n.chars().last().unwrap_or('.');
+                        format!("{indent}{}{delim} ", digits.parse::<u64>().unwrap_or(0) + 1)
+                    }
+                    Marker::Task(..) => {
+                        let bullet = text.trim_start().chars().next().unwrap_or('-');
+                        format!("{indent}{bullet} [ ] ")
+                    }
+                };
+                if p.col >= line.content_start {
+                    self.buf.insert(&format!("\n{next}"), EditKind::Other);
+                    return;
+                }
+            }
+        }
+        self.buf.insert("\n", EditKind::Other);
+    }
+
+    /// Tab indents a list item (or the selected lines) by a tab; Shift+Tab
+    /// takes one level off. Anywhere else Tab inserts a tab.
+    fn tab(&mut self, outdent: bool) {
+        let (a, b) = self.buf.selection().unwrap_or((self.buf.caret, self.buf.caret));
+        let is_list = |s: &Self, i: usize| matches!(preview::style_line(s.buf.line(i), s.ctx.get(i).copied().unwrap_or(Context::Normal), true).kind, Kind::List { .. });
+        if a.line == b.line && !is_list(self, a.line) && !outdent {
+            self.buf.insert("\t", EditKind::Other);
+            return;
+        }
+        let (caret, anchor) = (self.buf.caret, self.buf.anchor);
+        let mut caret_delta = 0isize;
+        let mut anchor_delta = 0isize;
+        for i in a.line..=b.line {
+            let text = self.buf.line(i).to_string();
+            let d: isize = if outdent {
+                let n = if text.starts_with('\t') {
+                    1
+                } else {
+                    text.chars().take(4).take_while(|c| *c == ' ').count()
+                };
+                if n == 0 {
+                    continue;
+                }
+                self.buf.replace(Pos::new(i, 0), Pos::new(i, n), "", EditKind::Other);
+                -(n as isize)
+            } else {
+                self.buf.replace(Pos::new(i, 0), Pos::new(i, 0), "\t", EditKind::Other);
+                1
+            };
+            if i == caret.line {
+                caret_delta = d;
+            }
+            if anchor.is_some_and(|x| x.line == i) {
+                anchor_delta = d;
+            }
+        }
+        let fix = |p: Pos, d: isize| Pos::new(p.line, (p.col as isize + d).max(0) as usize);
+        self.buf.caret = self.buf.clamp(fix(caret, caret_delta));
+        self.buf.anchor = anchor.map(|x| self.buf.clamp(fix(x, anchor_delta)));
+    }
+
+    /// Wrap the selection in `mark` (Ctrl+B, Ctrl+I), or insert a pair
+    /// with the caret between.
+    fn wrap(&mut self, mark: &str) {
+        match self.buf.selection() {
+            Some((a, b)) if a.line == b.line => {
+                let inner = self.buf.text_range(a, b);
+                self.buf.replace(a, b, &format!("{mark}{inner}{mark}"), EditKind::Other);
+                self.buf.anchor = Some(Pos::new(a.line, a.col + mark.len()));
+                self.buf.caret = Pos::new(a.line, a.col + mark.len() + inner.len());
+            }
+            Some(_) => {}
+            None => {
+                let at = self.buf.caret;
+                self.buf.insert(&format!("{mark}{mark}"), EditKind::Other);
+                self.buf.caret = Pos::new(at.line, at.col + mark.len());
+            }
+        }
+    }
+
+    /// Replace `a..b` with `text` as one undo step and put the caret at
+    /// `caret` (a host's splice: a completion, a template).
+    pub fn edit(&mut self, a: Pos, b: Pos, text: &str, caret: Pos) {
+        self.buf.replace(a, b, text, EditKind::Other);
+        self.buf.anchor = None;
+        self.buf.caret = self.buf.clamp(caret);
+        self.want_x = None;
+        self.sync();
+        self.follow_caret = true;
+    }
+
+    pub fn undo(&mut self) -> bool {
+        let done = self.buf.undo();
+        if done {
+            self.sync();
+            self.follow_caret = true;
+        }
+        done
+    }
+
+    pub fn redo(&mut self) -> bool {
+        let done = self.buf.redo();
+        if done {
+            self.sync();
+            self.follow_caret = true;
+        }
+        done
+    }
+
+    /// A left press at a screen point. A rendered link follows; a task's
+    /// box toggles; Ctrl+click follows a raw link; otherwise the caret
+    /// moves (Shift extends; a double press selects a word, a triple the
+    /// line).
+    pub fn press(&mut self, sx: f32, sy: f32, shift: bool, ctrl: bool) -> Response {
+        self.sync();
+        let rev = self.buf.revision;
+        let before = (self.buf.caret, self.buf.anchor);
+        let (x, y) = (sx - self.origin.0, sy - self.origin.1 + self.scroll);
+        if y >= 0.0 && !shift {
+            let i = self.line_at_y(y);
+            self.ensure(i);
+            self.retop();
+            let active = self.is_active(i, self.shown_active);
+            let l = self.layouts[i].as_ref().unwrap();
+            let ly = y - self.tops[i];
+            if let Some((r, at)) = l.task {
+                if r.width > 0.0 && x >= r.x && x <= r.x + r.width && ly >= r.y && ly <= r.y + r.height {
+                    let status = self.buf.line(i)[at..].chars().next().unwrap_or(' ');
+                    let next = if status == ' ' { "x" } else { " " };
+                    let end = at + status.len_utf8();
+                    let keep = (self.buf.caret, self.buf.anchor);
+                    self.buf.replace(Pos::new(i, at), Pos::new(i, end), next, EditKind::Other);
+                    (self.buf.caret, self.buf.anchor) = keep;
+                    return self.after_edit(rev, before);
+                }
+            }
+            if !active || ctrl {
+                if let Some(k) = l.link_at(x, ly) {
+                    return Response::Follow(l.links[k].clone());
+                }
+            }
+        }
+        let p = self.pos_at(sx, sy);
+        let now = Instant::now();
+        let count = match self.clicks {
+            Some((t, at, n)) if now.duration_since(t) < DOUBLE_CLICK && at == p => n % 3 + 1,
+            _ => 1,
+        };
+        self.clicks = Some((now, p, count));
+        match count {
+            2 => {
+                let (a, b) = self.buf.word_at(p);
+                self.buf.set_caret(a, false);
+                self.buf.set_caret(b, true);
+            }
+            3 => {
+                self.buf.set_caret(Pos::new(p.line, 0), false);
+                self.buf.set_caret(Pos::new(p.line, self.buf.line(p.line).len()), true);
+            }
+            _ => self.buf.set_caret(p, shift),
+        }
+        self.dragging = true;
+        self.want_x = None;
+        self.after_edit(rev, before)
+    }
+
+    /// Pointer motion; extends the selection while a press is held.
+    pub fn drag(&mut self, sx: f32, sy: f32) -> Response {
+        if !self.dragging {
+            return Response::None;
+        }
+        let before = (self.buf.caret, self.buf.anchor);
+        let p = self.pos_at(sx, sy);
+        if p != self.buf.caret {
+            self.buf.set_caret(p, true);
+        }
+        let r = self.after_edit(self.buf.revision, before);
+        // Selecting by drag does not drag the view to the caret: only an
+        // edge does, and the wheel.
+        self.follow_caret = sy < self.viewport.y || sy > self.viewport.y + self.viewport.height;
+        r
+    }
+
+    pub fn release(&mut self) {
+        self.dragging = false;
+    }
+
+    /// Whether a press is being dragged (the host keeps routing motion).
+    pub fn dragging(&self) -> bool {
+        self.dragging
+    }
+
+    /// A link under a screen point, for a hover cursor.
+    pub fn link_at(&mut self, sx: f32, sy: f32) -> bool {
+        let (x, y) = (sx - self.origin.0, sy - self.origin.1 + self.scroll);
+        if y < 0.0 || self.layouts.is_empty() {
+            return false;
+        }
+        let i = self.line_at_y(y);
+        if self.is_active(i, self.shown_active) {
+            return false;
+        }
+        self.ensure(i);
+        self.retop();
+        let l = self.layouts[i].as_ref().unwrap();
+        l.link_at(x, y - self.tops[i]).is_some() || l.task.is_some_and(|(r, _)| r.width > 0.0 && x >= r.x && x <= r.x + r.width && y - self.tops[i] <= r.y + r.height)
+    }
+
+    pub fn wheel(&mut self, delta: &MouseScrollDelta) -> bool {
+        let line = (self.theme.size * self.theme.spacing).max(1.0);
+        self.motion.reconcile(0.0, self.scroll);
+        let moved = self.motion.apply(delta, (line, line * 2.0), Bounds::max(0.0), Bounds::max(self.max_scroll()));
+        self.scroll = self.motion.y.pos();
+        moved
+    }
+
+    /// Advance a wheel glide; true while it moves.
+    pub fn tick(&mut self, dt: f32) -> bool {
+        if !self.motion.is_animating() {
+            return false;
+        }
+        self.motion.tick(dt, Bounds::max(0.0), Bounds::max(self.max_scroll()));
+        self.scroll = self.motion.y.pos();
+        true
+    }
+
+    /// Bring a line to the top of the view (search hits, outline clicks).
+    pub fn reveal_line(&mut self, line: usize) {
+        self.sync();
+        let line = line.min(self.buf.line_count() - 1);
+        self.retop();
+        self.scroll = self.tops[line].clamp(0.0, self.max_scroll());
+        self.motion.y.jump_to(self.scroll);
+        self.buf.set_caret(Pos::new(line, 0), false);
+        self.sync();
+    }
+
+    // ---- painting -------------------------------------------------------------
+
+    /// Paint into `rect`: lay out what shows, then selection, text, caret.
+    /// `focused` draws the caret.
+    pub fn paint(&mut self, pc: &mut PaintCtx, rect: Rect, focused: bool) {
+        self.prepare(rect);
+        self.paint_prepared(pc, focused);
+    }
+
+    /// Lay out what shows in `rect` and settle the scroll, without
+    /// painting: after it, [`DocEditor::caret_rect`] is where the caret
+    /// will be drawn (a popup placed before the paint needs that).
+    pub fn prepare(&mut self, rect: Rect) {
+        let mut width = (rect.width - 2.0 * self.pad).max(40.0);
+        if self.max_width > 0.0 {
+            width = width.min(self.max_width);
+        }
+        if (width - self.width).abs() > 0.5 {
+            self.width = width;
+            self.invalidate();
+            for i in 0..self.heights.len() {
+                self.heights[i] = 0.0;
+            }
+        }
+        self.viewport = rect;
+        self.origin = (rect.x + (rect.width - width) / 2.0, rect.y + self.pad);
+        self.sync();
+        if std::mem::take(&mut self.follow_caret) {
+            self.scroll_to_caret();
+        }
+        // Lay out what shows (heights settle), then paint it.
+        let view_h = rect.height;
+        for _ in 0..2 {
+            let mut i = self.line_at_y(self.scroll - self.pad);
+            while i < self.buf.line_count() && self.tops[i] < self.scroll + view_h {
+                self.ensure(i);
+                i += 1;
+            }
+            self.retop();
+        }
+        self.scroll = self.scroll.clamp(0.0, self.max_scroll());
+    }
+
+    /// Paint what the last [`DocEditor::prepare`] laid out.
+    pub fn paint_prepared(&mut self, pc: &mut PaintCtx, focused: bool) {
+        self.paint_prepared_with(pc, focused, &|_| true);
+    }
+
+    /// [`DocEditor::paint_prepared`], with `resolved` saying whether a
+    /// link has a target: one that does not draws faded, as in Obsidian.
+    /// Asked at paint, so a vault change needs no relayout.
+    pub fn paint_prepared_with(&mut self, pc: &mut PaintCtx, focused: bool, resolved: &dyn Fn(&Target) -> bool) {
+        let rect = self.viewport;
+        let view_h = rect.height;
+        let (ox, oy) = (self.origin.0, self.origin.1 - self.scroll);
+        let sel = self.buf.selection();
+        let caret = self.buf.caret;
+        let th = self.theme.clone();
+        let first = self.line_at_y(self.scroll - self.pad);
+        pc.clip(rect, |pc| {
+            let mut i = first;
+            while i < self.buf.line_count() && self.tops[i] < self.scroll + view_h {
+                let Some(l) = self.layouts[i].as_ref() else {
+                    i += 1;
+                    continue;
+                };
+                let top = oy + self.tops[i];
+                // Selection, behind everything on the line.
+                if let Some((a, b)) = sel {
+                    if i >= a.line && i <= b.line {
+                        let from = if i == a.line { l.caret_xy(a.col) } else { (l.runs.first().map_or(l.content_x, |r| r.x), 0) };
+                        let to = if i == b.line { l.caret_xy(b.col) } else { (l.runs.iter().map(|r| r.x + r.w).fold(l.content_x, f32::max) + 6.0, l.rows - 1) };
+                        for row in from.1..=to.1 {
+                            let x0 = if row == from.1 { from.0 } else { l.runs.iter().filter(|r| r.row == row).map(|r| r.x).fold(f32::INFINITY, f32::min).min(l.content_x) };
+                            let x1 = if row == to.1 { to.0 } else { l.runs.iter().filter(|r| r.row == row).map(|r| r.x + r.w).fold(x0, f32::max) };
+                            if x1 > x0 {
+                                pc.quad(Rect { x: ox + x0, y: top + row as f32 * l.row_h, width: x1 - x0, height: l.row_h }, th.selection);
+                            }
+                        }
+                    }
+                }
+                for d in &l.decos {
+                    match d {
+                        Deco::Quad(r, c) => pc.quad(Rect { x: ox + r.x, y: top + r.y, width: r.width, height: r.height }, *c),
+                        Deco::Dot { cx, cy, r, color } => pc.circle(ox + cx, top + cy, *r, *color),
+                        Deco::Check { cx, cy, r, checked } => crate::widget::Checkbox::paint_round_mark(pc, ox + cx, top + cy, *r, *checked),
+                        Deco::Text { text, x, y, size, color, font } => {
+                            pc.text_with(text.clone(), ox + x, top + y, *size, srgb_u8(*color), Some(font.clone()), None)
+                        }
+                    }
+                }
+                for r in &l.runs {
+                    let row_y = top + r.row as f32 * l.row_h;
+                    if let Some(bg) = r.bg {
+                        pc.rounded_rect(Rect { x: ox + r.x - 2.0, y: row_y + 2.0, width: r.w + 4.0, height: l.row_h - 4.0 }, 3.0, (true, true, true, true), bg);
+                    }
+                    let ty = row_y + (l.row_h - r.size) / 2.0;
+                    let color = match r.link {
+                        Some(k) if !resolved(&l.links[k]) => th.link_unresolved,
+                        _ => r.color,
+                    };
+                    pc.text_attrs(r.text.clone(), ox + r.x, ty, r.size, srgb_u8(color), Some(r.font.clone()), None, r.attrs);
+                    if r.strike {
+                        let sy = ty + r.size * 0.55;
+                        pc.vector(ox + r.x, sy, ox + r.x + r.w, sy, 1.0, r.color, Cap::Flat);
+                    }
+                }
+                if focused && i == caret.line {
+                    let (x, row) = l.caret_xy(caret.col);
+                    let h = l.row_h * 0.8;
+                    let y = top + row as f32 * l.row_h + (l.row_h - h) / 2.0;
+                    pc.quad(Rect { x: ox + x - 0.5, y, width: 2.0, height: h }, th.caret);
+                }
+                i += 1;
+            }
+        });
+    }
+}
+
+fn srgb_u8(linear: [f32; 4]) -> [u8; 3] {
+    let s = crate::colors::to_srgb(linear);
+    [(s[0] * 255.0) as u8, (s[1] * 255.0) as u8, (s[2] * 255.0) as u8]
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::widget::ElementState;
+
+    fn key(k: Key, text: Option<&str>, ctrl: bool, shift: bool) -> KeyEvent {
+        KeyEvent { state: ElementState::Pressed, logical_key: k, text: text.map(String::from), repeat: false, ctrl, shift, alt: false }
+    }
+
+    fn typed(c: &str) -> KeyEvent {
+        key(Key::Character(c.into()), Some(c), false, false)
+    }
+
+    fn named(n: NamedKey) -> KeyEvent {
+        key(Key::Named(n), None, false, false)
+    }
+
+    fn editor(text: &str) -> DocEditor {
+        let mut e = DocEditor::new(text, EditorTheme::new(14.0), false);
+        let mut pc = PaintCtx::new();
+        e.paint(&mut pc, Rect { x: 0.0, y: 0.0, width: 600.0, height: 400.0 }, true);
+        e
+    }
+
+    #[test]
+    fn typing_enter_and_list_continuation() {
+        let mut e = editor("");
+        for c in ["-", " ", "[", " ", "]", " ", "a"] {
+            e.key(&typed(c));
+        }
+        assert_eq!(e.key(&named(NamedKey::Enter)), Response::Changed);
+        e.key(&typed("b"));
+        assert_eq!(e.text(), "- [ ] a\n- [ ] b");
+        e.key(&named(NamedKey::Enter));
+        // Enter on an empty item ends the list.
+        e.key(&named(NamedKey::Enter));
+        assert_eq!(e.text(), "- [ ] a\n- [ ] b\n");
+        let mut e = editor("3. three");
+        e.buf.caret = Pos::new(0, 8);
+        e.key(&named(NamedKey::Enter));
+        assert_eq!(e.text(), "3. three\n4. ");
+    }
+
+    #[test]
+    fn tab_indents_list_items_and_undo_works() {
+        let mut e = editor("- a\n- b");
+        e.buf.caret = Pos::new(1, 3);
+        e.key(&named(NamedKey::Tab));
+        assert_eq!(e.text(), "- a\n\t- b");
+        assert_eq!(e.buf.caret, Pos::new(1, 4));
+        e.key(&key(Key::Named(NamedKey::Tab), None, false, true));
+        assert_eq!(e.text(), "- a\n- b");
+        assert!(e.undo());
+        assert_eq!(e.text(), "- a\n\t- b");
+    }
+
+    #[test]
+    fn arrows_words_home_end_and_vertical() {
+        let mut e = editor("- [ ] first item\nsecond");
+        e.buf.caret = Pos::new(0, 10);
+        e.key(&named(NamedKey::Home));
+        assert_eq!(e.buf.caret, Pos::new(0, 6), "smart home: the content start");
+        e.key(&named(NamedKey::Home));
+        assert_eq!(e.buf.caret, Pos::new(0, 0));
+        e.key(&named(NamedKey::End));
+        assert_eq!(e.buf.caret, Pos::new(0, 16));
+        e.key(&named(NamedKey::ArrowDown));
+        assert_eq!(e.buf.caret.line, 1);
+        e.key(&key(Key::Named(NamedKey::ArrowLeft), None, true, true));
+        assert_eq!(e.buf.selected_text().as_deref(), Some("second"));
+    }
+
+    #[test]
+    fn clicks_place_the_caret_and_toggle_tasks() {
+        let mut e = editor("- [ ] todo\nplain line");
+        // Line 0 is not active (the caret is on it? it starts at 0,0 — move it).
+        e.buf.caret = Pos::new(1, 0);
+        let mut pc = PaintCtx::new();
+        e.paint(&mut pc, Rect { x: 0.0, y: 0.0, width: 600.0, height: 400.0 }, true);
+        let l = e.layouts[0].as_ref().unwrap();
+        let (r, _) = l.task.unwrap();
+        let (sx, sy) = (e.origin.0 + r.x + r.width / 2.0, e.origin.1 + r.y + r.height / 2.0);
+        assert_eq!(e.press(sx, sy, false, false), Response::Changed);
+        assert_eq!(e.text(), "- [x] todo\nplain line");
+        assert_eq!(e.buf.caret, Pos::new(1, 0), "ticking does not move the caret");
+        // A click at the far left of the second line puts the caret there.
+        let y1 = e.origin.1 + e.tops[1] + 3.0;
+        e.release();
+        e.press(e.origin.0 + 1.0, y1, false, false);
+        assert_eq!(e.buf.caret, Pos::new(1, 0));
+    }
+
+    #[test]
+    fn rendered_links_follow() {
+        let mut e = editor("see [[Target|it]] now\nsecond");
+        e.buf.caret = Pos::new(1, 0);
+        let mut pc = PaintCtx::new();
+        e.paint(&mut pc, Rect { x: 0.0, y: 0.0, width: 600.0, height: 400.0 }, true);
+        let l = e.layouts[0].as_ref().unwrap();
+        let run = l.runs.iter().find(|r| r.link.is_some()).unwrap();
+        let (sx, sy) = (e.origin.0 + run.x + 2.0, e.origin.1 + 4.0);
+        assert_eq!(e.press(sx, sy, false, false), Response::Follow(Target::Note { target: "Target".into(), subpath: None }));
+    }
+
+    #[test]
+    fn a_long_document_shapes_only_what_shows() {
+        let text: String = (0..5000).map(|i| format!("line {i} with **some** text\n")).collect();
+        let e = editor(&text);
+        let laid = e.layouts.iter().filter(|l| l.is_some()).count();
+        assert!(laid < 60, "{laid} lines shaped");
+        assert!(e.content_height() > 5000.0 * 14.0);
+    }
+}
diff --git a/src/widget/doc_editor/preview.rs b/src/widget/doc_editor/preview.rs
new file mode 100644
index 0000000..e7a2b67
--- /dev/null
+++ b/src/widget/doc_editor/preview.rs
@@ -0,0 +1,635 @@
+//! Live preview: how one line of Markdown shows. Pure: text in, styled
+//! segments out.
+//!
+//! Every segment covers a byte range of the line and shows exactly those
+//! bytes (a tab shows as one space), so caret and click positions map 1:1
+//! between screen and source. Markup is never replaced, only hidden: on a
+//! line the caret is NOT on, markers (`**`, `## `, `[[` and a link's target
+//! part, a list's `- [ ] `…) have no segment and are drawn as nothing — or
+//! as a decoration the line carries (a bullet, a checkbox, a quote bar, a
+//! rule). On the caret's line ("active") the markers come back, dimmed,
+//! as Obsidian's live preview does.
+//!
+//! Block context (fenced code, frontmatter) spans lines, so it is computed
+//! for the whole document first ([`contexts`]); everything else is per line.
+
+use std::ops::Range;
+
+/// What a line is inside, from the lines before it.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Context {
+    Normal,
+    /// Inside a fenced code block (not the fences themselves).
+    Code,
+    /// A ``` or ~~~ fence line, opening or closing.
+    Fence,
+    /// Inside the frontmatter, or one of its `---` lines.
+    Frontmatter,
+}
+
+/// Block context per line: fences toggle code; a `---` first line opens
+/// frontmatter, closed by the next `---`.
+pub fn contexts(lines: &[String]) -> Vec<Context> {
+    let mut out = Vec::with_capacity(lines.len());
+    let mut fence: Option<(char, usize)> = None;
+    let mut front = lines.first().is_some_and(|l| l.trim_end() == "---");
+    for (i, line) in lines.iter().enumerate() {
+        if front {
+            out.push(Context::Frontmatter);
+            if i > 0 && line.trim_end() == "---" {
+                front = false;
+            }
+            continue;
+        }
+        let t = line.trim_start();
+        let run = |c: char| t.chars().take_while(|&x| x == c).count();
+        match fence {
+            Some((c, n)) => {
+                if run(c) >= n && t.trim_start_matches(c).trim().is_empty() {
+                    fence = None;
+                    out.push(Context::Fence);
+                } else {
+                    out.push(Context::Code);
+                }
+            }
+            None => {
+                let (bt, tl) = (run('`'), run('~'));
+                if bt >= 3 {
+                    fence = Some(('`', bt));
+                    out.push(Context::Fence);
+                } else if tl >= 3 {
+                    fence = Some(('~', tl));
+                    out.push(Context::Fence);
+                } else {
+                    out.push(Context::Normal);
+                }
+            }
+        }
+    }
+    out
+}
+
+/// A line's block shape.
+#[derive(Clone, Debug, PartialEq)]
+pub enum Kind {
+    Plain,
+    Heading(u8),
+    /// `> ` nesting depth.
+    Quote(u8),
+    /// A list item: indentation level, and its marker.
+    List { level: u8, marker: Marker },
+    Rule,
+    Code,
+    Fence,
+    Frontmatter,
+    Table,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum Marker {
+    Bullet,
+    /// The number as written, with its `.` or `)`.
+    Number(String),
+    /// A task's status char, and the byte of that char in the line.
+    Task(char, usize),
+}
+
+/// A segment's look. `marker` is markup shown on the active line.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
+pub struct Look {
+    pub bold: bool,
+    pub italic: bool,
+    pub code: bool,
+    pub strike: bool,
+    pub highlight: bool,
+    pub link: bool,
+    pub tag: bool,
+    pub marker: bool,
+    pub comment: bool,
+    /// Code blocks, fences, frontmatter and tables: monospace.
+    pub mono: bool,
+    /// The dim text of a done task or a fence line.
+    pub dim: bool,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum Target {
+    /// `[[target#sub|…]]` or a relative `[…](path)`.
+    Note { target: String, subpath: Option<String> },
+    Url(String),
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct Seg {
+    pub range: Range<usize>,
+    pub look: Look,
+    /// The link this segment shows, by index into [`Line::links`].
+    pub link: Option<usize>,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct Line {
+    pub kind: Kind,
+    pub segs: Vec<Seg>,
+    pub links: Vec<Target>,
+    /// Where content starts after the hidden prefix (`## `, `> `, a list
+    /// marker), for a click left of the text and an empty item's caret.
+    pub content_start: usize,
+}
+
+const BOLD: u16 = 1;
+const ITALIC: u16 = 2;
+const CODE: u16 = 4;
+const STRIKE: u16 = 8;
+const HIGHLIGHT: u16 = 16;
+const LINK: u16 = 32;
+const TAG: u16 = 64;
+const MARKER: u16 = 128;
+const COMMENT: u16 = 256;
+const DIM: u16 = 512;
+
+/// The fenced code block holding `line` — its opening and closing fence
+/// lines (the last line when it never closes) — or `None` outside one.
+pub fn fenced_block(ctx: &[Context], line: usize) -> Option<(usize, usize)> {
+    if !matches!(ctx.get(line), Some(Context::Code | Context::Fence)) {
+        return None;
+    }
+    let mut open: Option<usize> = None;
+    for (i, c) in ctx.iter().enumerate() {
+        match (c, open) {
+            (Context::Fence, None) => open = Some(i),
+            (Context::Fence, Some(a)) => {
+                if line >= a && line <= i {
+                    return Some((a, i));
+                }
+                open = None;
+            }
+            _ => {}
+        }
+        if i > line && open.is_none() {
+            break;
+        }
+    }
+    open.map(|a| (a, ctx.len() - 1))
+}
+
+/// Style one line. `active` shows its markers; `ctx` is its block context.
+pub fn style_line(text: &str, ctx: Context, active: bool) -> Line {
+    let n = text.len();
+    let whole = |look: Look| Line { kind: Kind::Plain, segs: vec![Seg { range: 0..n, look, link: None }], links: Vec::new(), content_start: 0 };
+    match ctx {
+        Context::Code => return Line { kind: Kind::Code, ..whole(Look { mono: true, ..Look::default() }) },
+        // A fence hides while the caret is outside its block (the editor
+        // makes the whole block active), as Obsidian's live preview does.
+        Context::Fence if !active => return Line { kind: Kind::Fence, segs: Vec::new(), links: Vec::new(), content_start: 0 },
+        Context::Fence => return Line { kind: Kind::Fence, ..whole(Look { mono: true, dim: true, ..Look::default() }) },
+        Context::Frontmatter => return Line { kind: Kind::Frontmatter, ..whole(Look { mono: true, dim: true, ..Look::default() }) },
+        Context::Normal => {}
+    }
+    let mut flags = vec![0u16; n];
+    let mut link_of: Vec<Option<usize>> = vec![None; n];
+    let mut links: Vec<Target> = Vec::new();
+    let mut kind = Kind::Plain;
+    let mut prefix = 0usize;
+
+    let trimmed = text.trim_start();
+    let indent_bytes = n - trimmed.len();
+    // A thematic break: three or more of - * _ and nothing else.
+    let compact: String = trimmed.chars().filter(|c| !c.is_whitespace()).collect();
+    if compact.len() >= 3 && (compact.chars().all(|c| c == '-') || compact.chars().all(|c| c == '*') || compact.chars().all(|c| c == '_')) && indent_bytes < 4 {
+        let look = Look { marker: true, ..Look::default() };
+        return Line { kind: Kind::Rule, segs: if active { vec![Seg { range: 0..n, look, link: None }] } else { Vec::new() }, links, content_start: n };
+    }
+    if trimmed.starts_with('|') {
+        return Line { kind: Kind::Table, ..whole(Look { mono: true, ..Look::default() }) };
+    }
+    // Headings.
+    let hashes = trimmed.chars().take_while(|&c| c == '#').count();
+    if (1..=6).contains(&hashes) && indent_bytes < 4 && (trimmed.len() == hashes || trimmed[hashes..].starts_with(' ')) {
+        kind = Kind::Heading(hashes as u8);
+        prefix = (indent_bytes + hashes + 1).min(n);
+    } else {
+        // Quotes, possibly nested, then a list marker inside them.
+        let mut at = 0usize;
+        let mut depth = 0u8;
+        loop {
+            let rest = &text[at..];
+            let sp = rest.len() - rest.trim_start().len();
+            if rest[sp..].starts_with('>') {
+                depth += 1;
+                at += sp + 1;
+                if text[at..].starts_with(' ') {
+                    at += 1;
+                }
+            } else {
+                break;
+            }
+        }
+        if depth > 0 {
+            kind = Kind::Quote(depth);
+            prefix = at;
+        }
+        if let Some((level, marker, end)) = list_marker(&text[at..]) {
+            let marker = match marker {
+                Marker::Task(c, b) => Marker::Task(c, b + at),
+                m => m,
+            };
+            if depth == 0 {
+                kind = Kind::List { level, marker };
+            }
+            prefix = at + end;
+        }
+    }
+    for f in flags.iter_mut().take(prefix) {
+        *f |= MARKER;
+    }
+    let done = matches!(kind, Kind::List { marker: Marker::Task(c, _), .. } if c != ' ');
+    inline(text, prefix, &mut flags, &mut link_of, &mut links);
+    if done {
+        for f in flags.iter_mut().skip(prefix) {
+            *f |= DIM;
+        }
+    }
+    // Merge bytes into segments at char boundaries.
+    let mut segs: Vec<Seg> = Vec::new();
+    for (i, _) in text.char_indices() {
+        let f = flags[i];
+        let hidden = !active && f & MARKER != 0;
+        if hidden {
+            continue;
+        }
+        let end = i + text[i..].chars().next().map(char::len_utf8).unwrap_or(1);
+        let look = Look {
+            bold: f & BOLD != 0,
+            italic: f & ITALIC != 0,
+            code: f & CODE != 0,
+            strike: f & STRIKE != 0,
+            highlight: f & HIGHLIGHT != 0,
+            link: f & LINK != 0,
+            tag: f & TAG != 0,
+            marker: f & MARKER != 0,
+            comment: f & COMMENT != 0,
+            mono: false,
+            dim: f & DIM != 0,
+        };
+        let link = link_of[i];
+        match segs.last_mut() {
+            Some(s) if s.range.end == i && s.look == look && s.link == link => s.range.end = end,
+            _ => segs.push(Seg { range: i..end, look, link }),
+        }
+    }
+    Line { kind, segs, links, content_start: prefix }
+}
+
+/// A list marker at the start of `s` (after indentation): its level, the
+/// marker, and the byte where the content starts.
+fn list_marker(s: &str) -> Option<(u8, Marker, usize)> {
+    let body = s.trim_start_matches([' ', '\t']);
+    let ind = &s[..s.len() - body.len()];
+    // A tab is one level (Obsidian indents with tabs); two spaces are one.
+    let cols: usize = ind.chars().map(|c| if c == '\t' { 2 } else { 1 }).sum();
+    let level = (cols / 2).min(12) as u8;
+    let (marker, mlen) = if body.starts_with("- ") || body.starts_with("* ") || body.starts_with("+ ") {
+        (Marker::Bullet, 2)
+    } else if matches!(body, "-" | "*" | "+") {
+        (Marker::Bullet, 1)
+    } else {
+        let digits = body.chars().take_while(char::is_ascii_digit).count();
+        if digits == 0 || digits > 9 {
+            return None;
+        }
+        let after = &body[digits..];
+        if after.starts_with(". ") || after.starts_with(") ") {
+            (Marker::Number(body[..digits + 1].to_string()), digits + 2)
+        } else {
+            return None;
+        }
+    };
+    let mut end = ind.len() + mlen;
+    let rest = &s[end..];
+    let mut chars = rest.chars();
+    if let (Some('['), Some(c), Some(']')) = (chars.next(), chars.next(), chars.next()) {
+        let after = &rest[2 + c.len_utf8()..];
+        if after.is_empty() || after.starts_with(' ') {
+            let status_at = end + 1;
+            end += 2 + c.len_utf8() + usize::from(after.starts_with(' '));
+            return Some((level, Marker::Task(c, status_at), end));
+        }
+    }
+    Some((level, marker, end))
+}
+
+/// Mark the inline syntax of `text[from..]` in `flags`.
+fn inline(text: &str, from: usize, flags: &mut [u16], link_of: &mut [Option<usize>], links: &mut Vec<Target>) {
+    let b = text.as_bytes();
+    let n = b.len();
+    let mut taken = vec![false; n];
+    let mark = |flags: &mut [u16], r: Range<usize>, f: u16| {
+        for x in &mut flags[r] {
+            *x |= f;
+        }
+    };
+    // Code spans first: nothing inside them is syntax.
+    let mut i = from;
+    while i < n {
+        if b[i] == b'`' {
+            let run = b[i..].iter().take_while(|&&c| c == b'`').count();
+            let close = find_run(b, i + run, b'`', run);
+            if let Some(c) = close {
+                mark(flags, i..i + run, MARKER | CODE);
+                mark(flags, i + run..c, CODE);
+                mark(flags, c..c + run, MARKER | CODE);
+                for t in &mut taken[i..c + run] {
+                    *t = true;
+                }
+                i = c + run;
+                continue;
+            }
+            i += run;
+            continue;
+        }
+        i += 1;
+    }
+    // Comments.
+    let mut i = from;
+    while let Some(o) = find_free(text, i, "%%", &taken) {
+        let Some(c) = find_free(text, o + 2, "%%", &taken) else { break };
+        mark(flags, o..c + 2, COMMENT);
+        mark(flags, o..o + 2, MARKER);
+        mark(flags, c..c + 2, MARKER);
+        for t in &mut taken[o..c + 2] {
+            *t = true;
+        }
+        i = c + 2;
+    }
+    // Wikilinks and embeds: [[target#sub|alias]].
+    let mut i = from;
+    while let Some(o) = find_free(text, i, "[[", &taken) {
+        let Some(c) = find_free(text, o + 2, "]]", &taken) else { break };
+        let inner = &text[o + 2..c];
+        let start = if o > 0 && b[o - 1] == b'!' && !taken[o - 1] { o - 1 } else { o };
+        let (target, alias_at) = match inner.find('|') {
+            Some(p) => (&inner[..p], Some(o + 2 + p + 1)),
+            None => (inner, None),
+        };
+        let (t, sub) = match target.split_once('#') {
+            Some((t, s)) => (t.to_string(), Some(s.to_string())),
+            None => (target.to_string(), None),
+        };
+        let idx = links.len();
+        links.push(Target::Note { target: t.trim().to_string(), subpath: sub.filter(|s| !s.is_empty()) });
+        let shown = alias_at.unwrap_or(o + 2)..c;
+        mark(flags, start..shown.start, MARKER);
+        mark(flags, shown.clone(), LINK);
+        mark(flags, c..c + 2, MARKER);
+        for x in &mut link_of[shown] {
+            *x = Some(idx);
+        }
+        for t in &mut taken[start..c + 2] {
+            *t = true;
+        }
+        i = c + 2;
+    }
+    // Markdown links: [text](dest).
+    let mut i = from;
+    while let Some(o) = find_free(text, i, "[", &taken) {
+        let Some(c) = find_free(text, o + 1, "](", &taken) else { break };
+        let Some(e) = find_free(text, c + 2, ")", &taken) else { break };
+        let dest = text[c + 2..e].trim();
+        let start = if o > 0 && b[o - 1] == b'!' && !taken[o - 1] { o - 1 } else { o };
+        let idx = links.len();
+        let decoded = dest.replace("%20", " ");
+        links.push(if dest.contains("://") || dest.starts_with("mailto:") {
+            Target::Url(dest.to_string())
+        } else {
+            let (t, s) = match decoded.split_once('#') {
+                Some((t, s)) => (t.to_string(), Some(s.to_string())),
+                None => (decoded.clone(), None),
+            };
+            Target::Note { target: t, subpath: s }
+        });
+        mark(flags, start..o + 1, MARKER);
+        mark(flags, o + 1..c, LINK);
+        mark(flags, c..e + 1, MARKER);
+        for x in &mut link_of[o + 1..c] {
+            *x = Some(idx);
+        }
+        for t in &mut taken[start..e + 1] {
+            *t = true;
+        }
+        i = e + 1;
+    }
+    // Emphasis: paired delimiters, longest first.
+    for (delim, flag) in [("**", BOLD), ("__", BOLD), ("~~", STRIKE), ("==", HIGHLIGHT), ("*", ITALIC), ("_", ITALIC)] {
+        let mut i = from;
+        while let Some(o) = find_free(text, i, delim, &taken) {
+            let inner = o + delim.len();
+            // Opening: not followed by a space; `_` must not sit mid-word.
+            let next = text[inner..].chars().next();
+            let prev = text[..o].chars().next_back();
+            let mid_word = delim.starts_with('_') && prev.is_some_and(char::is_alphanumeric);
+            if next.is_none_or(char::is_whitespace) || mid_word {
+                i = inner;
+                continue;
+            }
+            let mut search = inner;
+            let close = loop {
+                let Some(c) = find_free(text, search, delim, &taken) else { break None };
+                let before = text[..c].chars().next_back();
+                let after = text[c + delim.len()..].chars().next();
+                let bad_word = delim.starts_with('_') && after.is_some_and(char::is_alphanumeric);
+                if c > inner && before.is_some_and(|ch| !ch.is_whitespace()) && !bad_word {
+                    break Some(c);
+                }
+                search = c + delim.len();
+            };
+            let Some(c) = close else {
+                i = inner;
+                continue;
+            };
+            mark(flags, o..inner, MARKER);
+            mark(flags, inner..c, flag);
+            mark(flags, c..c + delim.len(), MARKER);
+            for t in &mut taken[o..inner] {
+                *t = true;
+            }
+            for t in &mut taken[c..c + delim.len()] {
+                *t = true;
+            }
+            i = c + delim.len();
+        }
+    }
+    // Tags: #name at a word start, not all digits.
+    let mut i = from;
+    while i < n {
+        if b[i] == b'#' && !taken[i] && (i == 0 || text[..i].chars().next_back().is_some_and(char::is_whitespace)) {
+            let rest = &text[i + 1..];
+            let len: usize = rest.chars().take_while(|&c| c.is_alphanumeric() || "_-/".contains(c)).map(char::len_utf8).sum();
+            if len > 0 && !rest[..len].chars().all(|c| c.is_ascii_digit() || c == '/') {
+                mark(flags, i..i + 1 + len, TAG);
+                i += 1 + len;
+                continue;
+            }
+        }
+        i += 1;
+    }
+}
+
+/// The next `pat` at or after `from` that starts on a byte nothing else
+/// has taken.
+fn find_free(text: &str, from: usize, pat: &str, taken: &[bool]) -> Option<usize> {
+    let mut at = from;
+    while at <= text.len() {
+        let p = at + text.get(at..)?.find(pat)?;
+        if !taken[p..p + pat.len()].iter().any(|&t| t) {
+            return Some(p);
+        }
+        at = p + 1;
+        while at < text.len() && !text.is_char_boundary(at) {
+            at += 1;
+        }
+    }
+    None
+}
+
+/// The next run of exactly `len` `c` bytes at or after `from`.
+fn find_run(b: &[u8], from: usize, c: u8, len: usize) -> Option<usize> {
+    let mut i = from;
+    while i < b.len() {
+        if b[i] == c {
+            let run = b[i..].iter().take_while(|&&x| x == c).count();
+            if run == len {
+                return Some(i);
+            }
+            i += run;
+        } else {
+            i += 1;
+        }
+    }
+    None
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// The shown text of each segment, `[marker]` for dimmed markup.
+    fn shown(text: &str, line: &Line) -> Vec<String> {
+        line.segs
+            .iter()
+            .map(|s| {
+                let t = &text[s.range.clone()];
+                if s.look.marker { format!("[{t}]") } else { t.to_string() }
+            })
+            .collect()
+    }
+
+    #[test]
+    fn inactive_lines_hide_markup() {
+        let t = "## A **bold** and *it* with `code`";
+        let l = style_line(t, Context::Normal, false);
+        assert_eq!(l.kind, Kind::Heading(2));
+        assert_eq!(shown(t, &l), ["A ", "bold", " and ", "it", " with ", "code"]);
+        assert!(l.segs[1].look.bold && l.segs[3].look.italic && l.segs[5].look.code);
+        assert_eq!(l.content_start, 3);
+    }
+
+    #[test]
+    fn the_active_line_shows_markup_dimmed() {
+        let t = "## A **bold**";
+        let l = style_line(t, Context::Normal, true);
+        assert_eq!(shown(t, &l), ["[## ]", "A ", "[**]", "bold", "[**]"]);
+        assert!(l.segs[2].look.marker && l.segs[3].look.bold && !l.segs[3].look.marker);
+        // Every byte is covered, in order: 1:1 with the source.
+        let mut at = 0;
+        for s in &l.segs {
+            assert_eq!(s.range.start, at);
+            at = s.range.end;
+        }
+        assert_eq!(at, t.len());
+    }
+
+    #[test]
+    fn links_show_their_alias_and_carry_a_target() {
+        let t = "see [[Note#Part|the part]] and [web](https://a.b) or [[Plain]]";
+        let l = style_line(t, Context::Normal, false);
+        assert_eq!(shown(t, &l), ["see ", "the part", " and ", "web", " or ", "Plain"]);
+        assert_eq!(l.links[0], Target::Note { target: "Note".into(), subpath: Some("Part".into()) });
+        assert_eq!(l.links[1], Target::Note { target: "Plain".into(), subpath: None });
+        assert_eq!(l.links[2], Target::Url("https://a.b".into()));
+        assert_eq!(l.segs[1].link, Some(0));
+        assert_eq!(l.segs[3].link, Some(2));
+    }
+
+    #[test]
+    fn fenced_blocks_are_found_and_fences_hide() {
+        let lines: Vec<String> = ["a", "```", "x", "```", "```", "y"].iter().map(|s| s.to_string()).collect();
+        let ctx = contexts(&lines);
+        assert_eq!(fenced_block(&ctx, 0), None);
+        assert_eq!(fenced_block(&ctx, 2), Some((1, 3)));
+        assert_eq!(fenced_block(&ctx, 3), Some((1, 3)));
+        assert_eq!(fenced_block(&ctx, 4), Some((4, 5)));
+        assert!(style_line("```rust", Context::Fence, false).segs.is_empty());
+        assert_eq!(style_line("```rust", Context::Fence, true).segs.len(), 1);
+    }
+
+    #[test]
+    fn lists_tasks_quotes_and_rules() {
+        let t = "\t- [x] done *thing*";
+        let l = style_line(t, Context::Normal, false);
+        assert_eq!(l.kind, Kind::List { level: 1, marker: Marker::Task('x', 4) });
+        assert_eq!(shown(t, &l), ["done ", "thing"]);
+        assert!(l.segs[0].look.dim);
+        let l = style_line("1. first", Context::Normal, false);
+        assert_eq!(l.kind, Kind::List { level: 0, marker: Marker::Number("1.".into()) });
+        let t = "> > quoted";
+        let l = style_line(t, Context::Normal, false);
+        assert_eq!((l.kind.clone(), shown(t, &l)), (Kind::Quote(2), vec!["quoted".to_string()]));
+        assert_eq!(style_line("---", Context::Normal, false).kind, Kind::Rule);
+        assert!(style_line("---", Context::Normal, false).segs.is_empty());
+        assert_eq!(style_line("- ", Context::Normal, false).kind, Kind::List { level: 0, marker: Marker::Bullet });
+    }
+
+    #[test]
+    fn emphasis_needs_real_pairs() {
+        let t = "a * b * c snake_case_word 2*3*4 ==hi== ~~no~~";
+        let l = style_line(t, Context::Normal, false);
+        let styled: Vec<(String, bool, bool, bool)> = l
+            .segs
+            .iter()
+            .map(|s| (t[s.range.clone()].to_string(), s.look.italic, s.look.highlight, s.look.strike))
+            .collect();
+        assert!(styled.iter().any(|(s, _, h, _)| s == "hi" && *h));
+        assert!(styled.iter().any(|(s, _, _, k)| s == "no" && *k));
+        assert!(styled.iter().all(|(s, i, _, _)| !(s.contains("snake") && *i)), "{styled:?}");
+        assert!(!styled.iter().any(|(s, i, _, _)| s.contains(" b ") && *i), "spaced * is not emphasis");
+    }
+
+    #[test]
+    fn tags_and_code_protect() {
+        let t = "#tag and `#not **x**` #2026 #a/b";
+        let l = style_line(t, Context::Normal, false);
+        let tags: Vec<&str> = l.segs.iter().filter(|s| s.look.tag).map(|s| &t[s.range.clone()]).collect();
+        assert_eq!(tags, ["#tag", "#a/b"]);
+        assert!(l.segs.iter().any(|s| s.look.code && &t[s.range.clone()] == "#not **x**"));
+    }
+
+    #[test]
+    fn block_contexts() {
+        let lines: Vec<String> = ["---", "a: 1", "---", "text", "```rust", "let x;", "```", "after"].iter().map(|s| s.to_string()).collect();
+        assert_eq!(
+            contexts(&lines),
+            [
+                Context::Frontmatter,
+                Context::Frontmatter,
+                Context::Frontmatter,
+                Context::Normal,
+                Context::Fence,
+                Context::Code,
+                Context::Fence,
+                Context::Normal
+            ]
+        );
+        assert_eq!(style_line("let x;", Context::Code, false).segs[0].look.mono, true);
+    }
+}
diff --git a/src/widget/markdown.rs b/src/widget/markdown.rs
index 7e69c33..1946a4e 100644
--- a/src/widget/markdown.rs
+++ b/src/widget/markdown.rs
@@ -17,48 +17,11 @@
 //! the squircle corner shape draws a few-px rounded rect as a square.
 //! Moved here from cce-notes (its `reading.rs`) on 2026-10-01.
 
-use std::collections::HashMap;
-
 use crate::scene::layout::Rect;
 use crate::scene::paint::{Cap, PaintCtx, TextAttrs};
 pub use cce_vault::markdown::{blocks, Block, Callout, ListItem, Span, SpanLink, Style};
 
-/// Text widths in logical px, for one run in one style.
-pub trait Measure {
-    fn width(&mut self, text: &str, size: f32, font: &str, attrs: TextAttrs) -> f32;
-}
-
-/// [`Measure`] through the renderer's own shaping entry
-/// (`get_text_buffer_attrs`), so laid-out widths are the drawn widths. It
-/// owns its FontSystem: create it with the same font set the app's renderer
-/// loads — `system_fonts` mirroring the app's `load_system_fonts` — or
-/// face ids and widths will not match. Widths are logical px at the
-/// toolkit's current scale factor, cached per run.
-pub struct ShapingMeasure {
-    fs: cosmic_text::FontSystem,
-    cache: HashMap<(String, u32, u32, String, TextAttrs), f32>,
-}
-
-impl ShapingMeasure {
-    pub fn new(system_fonts: bool) -> ShapingMeasure {
-        let fs = if system_fonts { crate::create_font_system_with_system_fonts() } else { crate::create_font_system() };
-        ShapingMeasure { fs, cache: HashMap::new() }
-    }
-}
-
-impl Measure for ShapingMeasure {
-    fn width(&mut self, text: &str, size: f32, font: &str, attrs: TextAttrs) -> f32 {
-        let scale = crate::scale::scale_factor().max(0.01);
-        let key = (text.to_string(), (size * 100.0) as u32, (scale * 1000.0) as u32, font.to_string(), attrs);
-        if let Some(w) = self.cache.get(&key) {
-            return *w;
-        }
-        let buf = crate::backend::window_runner::get_text_buffer_attrs(&mut self.fs, text, size, Some(font), attrs);
-        let w = buf.layout_runs().map(|r| r.line_w).fold(0.0, f32::max) / scale;
-        self.cache.insert(key, w);
-        w
-    }
-}
+pub use crate::widget::shaping::{Measure, ShapingMeasure};
 
 /// Fonts and the body size the layout works from.
 #[derive(Clone, Debug, PartialEq)]
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 7a47e5d..4828e7b 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -590,8 +590,11 @@ pub mod container;
 pub mod display;
 pub mod editor;
 pub mod layout_helper;
+pub mod shaping;
 #[cfg(feature = "markdown")]
 pub mod markdown;
+#[cfg(feature = "doc_editor")]
+pub mod doc_editor;
 pub mod model;
 pub mod scroll_region;
 pub mod scroll_motion;
diff --git a/src/widget/shaping.rs b/src/widget/shaping.rs
new file mode 100644
index 0000000..8b8860d
--- /dev/null
+++ b/src/widget/shaping.rs
@@ -0,0 +1,85 @@
+//! Measuring text the way the renderer will draw it: widths of runs, and
+//! the x of every character boundary in a run (a caret's or a click's
+//! position). Shared by `widget::markdown` and `widget::doc_editor`.
+
+use std::collections::HashMap;
+
+use crate::scene::paint::TextAttrs;
+
+/// Text widths in logical px, for one run in one style.
+pub trait Measure {
+    fn width(&mut self, text: &str, size: f32, font: &str, attrs: TextAttrs) -> f32;
+}
+
+/// [`Measure`] through the renderer's own shaping entry
+/// (`get_text_buffer_attrs`), so laid-out widths are the drawn widths. It
+/// owns its FontSystem: create it with the same font set the app's renderer
+/// loads — `system_fonts` mirroring the app's `load_system_fonts` — or
+/// face ids and widths will not match. Widths are logical px at the
+/// toolkit's current scale factor, cached per run.
+pub struct ShapingMeasure {
+    fs: cosmic_text::FontSystem,
+    cache: HashMap<(String, u32, u32, String, TextAttrs), f32>,
+}
+
+impl ShapingMeasure {
+    pub fn new(system_fonts: bool) -> ShapingMeasure {
+        let fs = if system_fonts { crate::create_font_system_with_system_fonts() } else { crate::create_font_system() };
+        ShapingMeasure { fs, cache: HashMap::new() }
+    }
+}
+
+impl Measure for ShapingMeasure {
+    fn width(&mut self, text: &str, size: f32, font: &str, attrs: TextAttrs) -> f32 {
+        let scale = crate::scale::scale_factor().max(0.01);
+        let key = (text.to_string(), (size * 100.0) as u32, (scale * 1000.0) as u32, font.to_string(), attrs);
+        if let Some(w) = self.cache.get(&key) {
+            return *w;
+        }
+        let buf = crate::backend::window_runner::get_text_buffer_attrs(&mut self.fs, text, size, Some(font), attrs);
+        // The glyphs' extent, trailing spaces included — the same measure
+        // `offsets` ends on. (A layout run's `line_w` leaves trailing
+        // whitespace out, so a width taken from it disagreed with where
+        // the next run was placed by a space.)
+        let w = crate::backend::window_runner::normalized_glyph_starts(&buf, text)
+            .into_iter()
+            .map(|(_, x, w)| x + w)
+            .fold(0.0, f32::max)
+            / scale;
+        self.cache.insert(key, w);
+        w
+    }
+}
+
+impl ShapingMeasure {
+    /// The x (logical px) of every char boundary in `text` shaped as one
+    /// run: `(byte offset, x)`, starting at `(0, 0.0)` and ending at
+    /// `(text.len(), width)`. A boundary inside a cluster (a ligature, a
+    /// combining mark) takes the cluster's start.
+    pub fn offsets(&mut self, text: &str, size: f32, font: &str, attrs: TextAttrs) -> Vec<(usize, f32)> {
+        let scale = crate::scale::scale_factor().max(0.01);
+        let buf = crate::backend::window_runner::get_text_buffer_attrs(&mut self.fs, text, size, Some(font), attrs);
+        let glyphs = crate::backend::window_runner::normalized_glyph_starts(&buf, text);
+        let mut starts: Vec<(usize, f32)> = Vec::with_capacity(glyphs.len() + 1);
+        let mut width = 0.0f32;
+        for (start, x, w) in glyphs {
+            if starts.last().map_or(true, |&(b, _)| b != start) {
+                starts.push((start, x / scale));
+            }
+            width = width.max((x + w) / scale);
+        }
+        starts.sort_by_key(|&(b, _)| b);
+        let mut out = Vec::with_capacity(text.len() + 1);
+        let mut gi = 0;
+        let mut last_x = 0.0;
+        for (b, _) in text.char_indices() {
+            while gi < starts.len() && starts[gi].0 <= b {
+                last_x = starts[gi].1;
+                gi += 1;
+            }
+            out.push((b, last_x));
+        }
+        out.push((text.len(), width));
+        out
+    }
+}