Files
Marcos Paulo 5e8020aa38
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: Obsidian basics, editor previews, graph rework, query blocks, stacked tabs, pop-out windows
Editor previews (colour swatches, arrows, embeds), the 18 obsidian-basics tasks (find and replace
with regex, back/forward, auto-pair, footnotes, folding, search operators, split view, PDF/audio/
video embeds, spelling, drag and drop, pinned tabs, settings) and round 2: ```query blocks, stacked
tabs, pop-out windows, tasks ticked inside embeds, text around an embed, embeds that follow edits,
block menu shortcut. New crates: notescraft-regex (linear-time engine) and notescraft-syntax.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 11:21:19 -03:00

12 KiB

Plan: Editor previews: colors, external links, fenced diagrams and trees

Approach

Pure scanners in a new editor/src/preview/ module (no egui, bounded, linear) detect colors and tree blocks; ui-egui only draws. Rendered note mode is the one path that can host previews. Neither text path can put a widget inside a text run, so a swatch or arrow reserves space with one pad character in the row's galley and is painted over the pad's measured rect (the existing char-based hit_rect). Source mode (the TextEdit buffer) gets no swatches or arrows: a pad char there would change the buffer. Mermaid already renders inline in rendered mode; this plan adds the Source/Diagram toggle with per-block memory and the fallback chips. Previews never produce a NoteEdit, so saved bytes are exactly what the user wrote.

Architecture

  • Already there (verified): inline Mermaid in rendered mode. show_contents (note_view.rs:~305) asks diagram_view::fence_ranges (diagram_view.rs:140) for mermaid fences and calls the fence closure, wired in shell/app/window/views.rs:50-66 (Views.inline: BTreeMap<(path, range.start), DiagramView>) and, for default callers, center.rs:149 and note_view.rs:192. show_diagram (diagram_view.rs:42) has a header with SVG copy and a Show/Hide source button that appends a read-only ui.code(source) below the diagram; parse errors show paint::show_error (message + read-only source); parse caps live in notescraft_diagrams::parse (MAX_INPUT_BYTES, MAX_NODES, MAX_EDGES, errors TooLarge, LimitExceeded, NoHeader, NotLayoutable). Source mode shows a diagram only for whole .mmd files (FileWidget::Diagram), not inline fences.
  • New for diagrams: (1) a two-state Mode { Diagram, Source } per block, default Diagram, replacing the offset key with a key of (path, hash of source, nth duplicate) so edits above a block do not mis-key it; held in Views for as long as the note is open. (2) The fence closure returns bool (rendered or not). In Source mode it draws only a small header row (label + Diagram/Source toggle) and returns false; show_contents then falls through to the ordinary code_block, so the original text is shown and editing uses the existing click-to-edit path (open_on_click, edit::request), no new edit code. Three closure call sites change (center.rs:149, note_view.rs:192, views.rs:50) plus the BodyHooks/Renderers signatures. (3) Fallback: on NoHeader/NotLayoutable the block is forced to Source with an "unsupported" chip; on other parse errors an "error" chip with the first message line; on TooLarge/LimitExceeded a size message. show_error loses its duplicate source dump (the code card shows it). No parse is attempted past the caps (the crate already refuses), and the parse result stays cached per source hash (update_cache).
  • Colors: spans_for_line (inlines.rs:53) asks editor::preview::detect_colors(text, limits) for plain-text and Span::Mono runs and splits the span, inserting Span::Swatch { rgb, alpha } (one pad char, U+2003). build_job/text_row_rect (painting.rs:112,159) record the pad's char index; after layout, hit_rect gives its rect and the painter draws a 12 pt rounded rect (radius 3, one border, 3:1 against the surface, alpha composited over the surface). Detection result is cached per line text hash in egui temp memory.
  • Pad character and selection: select.rs builds copy text and source alignment from galley.text() (select.rs:~249,295) and edit::CaretAt::Rendered { plain } gets the galley text; the pad must not reach any of them. One PAD constant in painting.rs; selection copy filters it, locate and the caret char index skip it. A test selects across a swatch and asserts clipboard text and caret byte offset equal the source. Fallback if this proves fragile: reserve space with extra_letter_spacing on the value's last char and paint the swatch after the value (changes where it sits, never the text).
  • Rows with inline math take the flow renderer (flow.rs, per-word galleys); swatches and arrows are not drawn there (documented gap; the text still reads correctly).
  • Plain-text hex vs tags: a candidate is skipped when it falls in a Tag item range (#abc, #123) or lacks a non-alphanumeric boundary on both sides. Named colors (16 basic CSS names, default off) are only detected inside inline code, whole word, per spec.
  • Fenced colors: code_card rows (code-blocks plan) call the same detector when the fence language is stylesheet, markup, JSON or settings (css scss less html xml svg json yaml toml ini); other languages get none.
  • Settings: EditorSettings (engine/src/prefs/app_settings.rs:130, which already holds blocks.{grip,toolbar,slash_preview}) gains previews { colors: true, named_colors: false }, persisted by the existing settings.set path. The shell publishes them each frame into egui temp memory exactly like block_menu::publish (widgets/block_menu.rs:144) and views read them with a previews::settings(ctx) getter, so no signature threading. Off skips detection entirely.
  • Context menu: swatch rect gets ui.interact(.., Sense::click()) and response.context_menu through widgets/context_menu.rs: color preview + hex, Copy color, Copy as rgb (ctx.copy_text), Color previews switch, Named colors switch (each calls settings.set). Menu never touches note text.
  • Links: Span::Link { text, dest } already carries dest; external = dest starts with http:// or https:// (computed, no new field). build_job appends one pad char inside the link's char range (so the arrow is clickable and uses the same link run), the painter draws a 16 pt up-right arrow in the link color over the pad rect (vector strokes, no font dependency). Wikilinks and vault-relative links get nothing; link_target/opener unchanged, so opening behavior is exactly as before.
  • Trees: editor::preview::detect_tree(label, body): label empty, text or tree; at least 2 lines, at least 2 of them with a tree glyph (│ ├ └ ─) followed by a name; box-drawing tables fail (no name column after the glyph run / closing corners). note_view/blocks/tree_card.rs draws the TREE label header, Copy (verbatim source via ctx.copy_text) and per row: prefix glyphs in monospace (already aligned), a folder or file icon from design/icons.rs (name ends in / or has children = folder), then the name. Non-qualifying fences stay ordinary code cards.
  • Caps: MAX_COLORS_PER_NOTE 2,000, lines longer than 4 KiB are not scanned, tree body 64 KiB / 2,000 lines; above the caps detection stops silently.

Files

  • Create
    • crates/editor/src/preview/{mod.rs,color.rs,named.rs,tree.rs} — Detected { range, rgb, alpha }, detect_colors, NAMED_COLORS, detect_tree; unit tests inline. (editor/src has 8 loose files, so a folder.)
    • crates/ui-egui/src/views/note_view/blocks/tree_card.rs (note_view/ is at the 12-loose-file cap; new code goes in blocks/).
    • crates/ui-egui/src/widgets/color_menu.rs; crates/ui-egui/src/widgets/previews.rs (settings getter/publisher and the arrow/swatch painters, kept out of painting.rs).
  • Modify
    • crates/editor/src/lib.rs:15-26 — pub mod preview;.
    • crates/engine/src/prefs/app_settings.rs:~130 — previews in EditorSettings; the settings validation for editor.previews.* keys.
    • crates/ui-egui/src/views/note_view/inlines.rs:24,53 — Span::Swatch, spans split; external flag helper.
    • crates/ui-egui/src/views/note_view/painting.rs:56,112,159 — pad char, swatch/arrow paint after layout, secondary-click hit rect.
    • crates/ui-egui/src/views/note_view/select.rs:~249,295 and edit.rs caret mapping — ignore PAD.
    • crates/ui-egui/src/views/note_view.rs:~305 and the Renderers/BodyHooks types (:~215-245) — fence closure returns bool; tree dispatch before code_block.
    • crates/ui-egui/src/views/center.rs:149, src/shell/app/window/views.rs:50-66 and :212 (whole-file diagram unchanged), views/diagram_view.rs:42 (Mode, toggle header, chips), views/diagram_view/paint.rs:9 (show_error without source dump).
    • crates/ui-egui/src/shell/app/editor/blocks.rs:~54 — publish preview settings next to block_menu::publish.
    • String tables: src/text/views/diagram_view.rs, src/text/views/note_view.rs (+ menu labels).
    • ROADMAP.md, docs/ARCHITECTURE.md.
  • Delete: none.

Data

  • No schema or disk change. Settings keys editor.previews.colors (default true) and editor.previews.named_colors (default false) in config.toml via the existing app-settings store (survives restart).
  • Per-block diagram mode: HashMap<(path, source_hash, nth), Mode> in Views, dropped when the note closes, never written.
  • Detection output Detected { range: Range<usize> (char boundaries), rgb: [u8;3], alpha: u8 }; per-line cache in egui temp memory keyed by line hash, bounded.
  • Fenced-language allow-list and named-color table are *.constants.rs-style consts in preview/.

Dependencies

  • None new (spec constraint). Parsing is hand-written (hex 3/4/6/8, rgb[a], hsl[a] with range-checked args); diagrams use the existing notescraft-diagrams; clipboard uses ctx.copy_text; link opening is untouched.

Risks

  • Pad char leaks into copy, selection or caret mapping (the largest risk): single PAD constant, filtered in the three places that read galley.text(), covered by a round-trip test; fallback described above.
  • Source mode has no swatches or arrows. Spec stories say "the editor"; owner confirms Visual (rendered) mode is enough for v1.
  • Byte/char mismatch with multibyte text: scanners use get and is_char_boundary, swatch positions are char counts (what hit_rect takes); fixtures with accents and emoji.
  • False positives: #123 and #abc tags/issue numbers (skipped via Tag items and boundary rule), prose "red" (default off, inline code only), CSS-like text in non-style fences (allow-list).
  • Hostile input: caps above; one linear scan per line, cached; 1,000-color note budget < 10 ms per frame checked by a timing test in release.
  • Right-click collides with the selection pointer handling in select::row: the menu attaches only inside a swatch rect; elsewhere the existing path is unchanged.
  • Fence closure signature change touches three callers; a compile-checked change, existing diagram tests (diagram_view.rs:~180) updated for the key change.
  • Tree detection false positives (box tables, ASCII art): require tree glyph + name on at least 2 lines; ambiguous means plain code card; test asserts Copy equals source bytes.
  • Contrast: swatch border 3:1 in both themes asserted by a theme test over all built-ins.

Out of Scope (Defer)

  • Color picker/editing; swatches and arrows in source mode and in math-containing rows; previews outside the editor.
  • Full CSS named-color set (basic 16 only, answers the open question); arrow on bare, non-link addresses (no arrow).
  • Persisting Source/Diagram across sessions; converting unsupported Mermaid kinds.
  • Language-labeled tree fences (only empty, text, tree labels qualify).

Test Strategy

  • Unit (editor/preview): hex 3/4/6/8 accept and reject; rgb/rgba/hsl/hsla ranges and alpha, out-of-range -> none; named colors whole-word and gated; detect_tree accepts (with and without label) and rejects tables/art; caps; boundary safety on multibyte fixtures; hostile 1 MB lines.
  • UI (kittest): spans include Swatch before detected values, none for #123 tags or when previews are off; fenced css gets swatches, rust does not; external link has the arrow, wikilink and in-vault link do not, click still returns the same clicked_link; select-across-swatch copy equals source; mermaid: valid -> Diagram, toggle -> original text editable and remembered across redraws and after an edit above the block, garbage -> Source + "error" chip, unknown kind -> "unsupported", over-limit -> message; tree card renders and Copy equals source; NoteViewResponse.edit is None for every preview interaction, and a save after previews is byte-identical.
  • Engine: settings round-trip and defaults for editor.previews.*.
  • Manual: snapshot example with a fixture note (colors, links, mermaid, tree) at 1440x900 and --scale 2 in Mocha and Latte; right-click menu; restart honors switches; 1,000-color note stays smooth in a release build; cargo xtask ci and cargo xtask i18n.

Owner decision (2026-10-18): swatches and link arrows in rendered mode only (pad character plus overlay); source mode and rows with inline math get none.