GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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
+ }
+}