Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
4.1 KiB
Crate: crates/editor (notescraft-editor, layer L5). First cut of the markdown EDITOR, source mode. SPEC sections 6 items 2-3, 5, 11 (editor notes), 14 (editor risk). Pragmatic design (decision recorded): wrap egui's TextEdit::multiline (which already provides IME, selection, clipboard, undo, bidi) with a custom markdown layouter for styling; keep a String buffer (no ropey until a benchmark demands it). Allowed dependencies (all { workspace = true }): egui, notescraft-markdown, thiserror, serde (and egui dev-feature default_fonts for tests). Do NOT depend on notescraft-ui-egui (same layer): colours come in through an EditorColors struct of egui::Color32. Another agent edits ui-egui at the same time: do not touch it.
Build in SMALL steps: one file per tool call (<=250 lines), build + clippy after each, every module declared in lib.rs, no unwrap/expect/panic outside tests, no indexing (use get()).
Implement:
colors.rs:EditorColors { text, muted, heading, link, link_unresolved, tag, code, code_background, highlight, accent, selection }plusEditorColors::fallback_dark()for tests.highlight.rs:layout_job(text: &str, colors: &EditorColors, font_size: f32, wrap_width: f32, resolver: &dyn Fn(&str) -> bool) -> egui::text::LayoutJobbuilt fromnotescraft_markdown::parse(text)items: headings larger and bold by level (32/24/20/16/16/16 relative to base 16), wikilinks and links coloured resolved/unresolved, tags,==highlight==with background, inline code and fences monospace oncode_background, bold/italic/strikethrough markers dimmed, callout header lines accent, blockquote>dimmed, task markers, comments muted, frontmatter muted. Sections never overlap and always cover the whole string on char boundaries (assert this in tests with random input); never panic on any text; texts over 2 MiB fall back to a single plain section.editor.rs:EditorState { text: String, cursor: Option<usize>, version: u64 },show_editor(ui, &mut EditorState, &EditorColors, resolver, opts: EditorOptions) -> EditorResponse { changed: bool, link_clicked: Option<String>, cursor_line_col: (usize, usize) }usingTextEdit::multiline(...).layouter(...), frame-less, desired width 100%, font size from options, tab = 4 spaces,Cmd/Ctrl+Enterignored, and these key behaviours implemented BEFORE the TextEdit sees the events (consume them fromui.input_mut): Enter continues lists (-,*,1.,- [ ],>) with numbering increment and an empty item ends the list; Tab/Shift+Tab indent and outdent selected lines or the list item;[[,(,[,"`**auto-close pairs and typing the closer skips over it; Cmd/Ctrl+B and +I wrap the selection in**/*; Cmd/Ctrl+K wraps in[[ ]](or[text]()? only[[ ]]). Implement these as PURE functions over(text, selection) -> Option<Edit>inkeys.rs(on_enter,on_tab,wrap_selection,auto_close) so they are unit tested without a UI, and applyEdit(range replace + new selection) through the TextEdit state.completion.rs: pure autocomplete:trigger_at(text, cursor) -> Option<Trigger::{Wikilink{query}, Heading{note, query}, Block{note, query}, Tag{query}, Callout{query}}>for[[,[[note#,[[note#^,#,> [!;apply(text, trigger, choice) -> Edit; and ashow_popup(ui, anchor, items, selected) -> Option<usize>widget (max 8 rows). The caller supplies the candidate list; do NOT depend on the index crate.stats.rs:line_col(text, cursor),word_count.
Tests (headless egui, 2 frames at 900x700, default_fonts): layout_job coverage invariant on 200 random adversarial strings (deterministic generator, adversarial alphabet [ ] ! # ^ = % $ > - * \ ``` and é), every highlight kind produces the expected colour on a fixture, 3 MiB text fallback, show_editor runs without panic and reports changes when text is modified throughRawInput events; keys.rs tables: list continuation (bullet, numbered increment, task, nested, empty item ends, ordinary line), Tab/Shift+Tab, wrap_selection, auto_close and skip-over with multi-byte chars; completion triggers for each form including negative cases ([[closed,#mid-word,# heading`, code fences).