Repository navigation
feat(editing): Live/Source editing modes (PSE v1) - #203
Conversation
…d-viewer Survey of reference architectures (Obsidian CM6, Typora, Slack Canvas, Notion, Zed/GPUI), codebase audit facts F1-F12, six candidate approaches (A-F) with comparison matrix, and a phased recommendation.
…mparison - Verified Obsidian/CM6 mechanism incl. CLS lesson; Slack Canvas stores plain markdown; Notion block-graph tradeoffs; Typora/Zed context - egui 0.33 TextEdit audit (undo, char-index cursors, IME bug #7975, monolithic galley re-layout epaint#4000), egui_code_editor 0.2.x compat - Prior art: no egui library exists; Ferrite app proves block-switching; GPUI aster/Cditor validate architecture; wry+eframe ruled out - Correct pulldown-cmark to workspace 0.13 pin; refresh matrix + sources
…iting Phase 0 (data safety): - Tab dirty tracking with synced/in-flight content hashes - Background save thread (clone+hash on UI thread; atomic tmp+rename write) - Watcher self-write suppression: classify_disk_change() decides Ignore/Resync/Conflict/Reload per tab; own saves no longer clobber buffers - Conflict banner (reload from disk vs keep my version) for external edits arriving while dirty - Unsaved-changes guard on tab close and quit (Save / Discard / Cancel), incl. deferred quit after all saves settle Phase 1 (source editing): - Ctrl+E toggles raw-markdown TextEdit pane (monospace, wraps, undo via egui) bound to tab.content; rendered view untouched elsewhere - Ctrl+S saves active tab; File menu Save + Open in External Editor - Dirty markers in tab bar and window title - Search jumps and outline clicks move the editor caret in source mode (byte→char index conversion); debounced derived-cache rebuilds (outline, links, matches) 250ms after typing stops Tests: hash/index helpers, disk-change policy matrix. Devlog + docs follow.
… 0/1 - devlog: design decisions, egui TextEdit quirks (char-index cursors, enclosing-ScrollArea scrolling), watcher policy rationale, sandbox testing limitation, remaining limitations - KEYBOARD_SHORTCUTS: Ctrl+S / Ctrl+E + unsaved-changes behavior - ARCHITECTURE: Editing & Saving section
… editing
Fork (additive, opt-in; default behavior untouched):
- CommonMarkOptions.edit_region: skip-paint events inside a top-level
block byte range and paint an inline TextEdit instead (egui-temp buffer,
so the caller never double-borrows the source string)
- CommonMarkOptions.record_block_layout + ScrollableCache.boundaries:
record safe-boundary y positions for click hit-testing
- top_level_block_spans(): depth-0 block ranges from cached events, tiled
over inter-block whitespace; exposed on CommonMarkCache together with
take/stash edit feedback and block_span_at_content_y hit-testing
App:
- EditMode {Rendered, Source, Live} replaces source_mode bool;
Ctrl+E toggles Rendered<->Live, View menu offers all three
- Click-to-edit: pointer click mapped through last frame's content origin
+ scroll offset to the containing block's byte range
- Per-frame splice-back of edited text (anchor_after_splice keeps caret
position stable across length changes); Esc deactivates; re-seeding is
keyed by (content_version, block start) so typing is never clobbered
- Search jumps / outline clicks activate the containing block in Live mode
Tests: 4 backend segmentation tests (tiling, fences, atomic quotes/tables)
+ anchor-splice math. All green: app 56, backend 18+1.
- Ctrl+E now cycles Rendered -> Live -> Source -> Rendered (every press visibly changes the surface; Source was previously unreachable by key) - Hint bar in Live mode while no block is active, so entering the mode isn't visually indistinguishable from Rendered - First Live-frame clicks no longer wrongly clear state: distinguish 'layout not recorded yet' (ignore) from 'clicked outside blocks' (deactivate) via CommonMarkCache::has_block_layout - log::info telemetry at mode switches, activations, seeds and splices for RUST_LOG=info diagnosis
Headless end-to-end test (Context::run, no window system) reproduced a real click-to-edit bug: block_span_at_content_y built ranges from raw boundary next_start offsets, which land in inter-block whitespace — the app then anchored on whitespace, contains() failed against every real block, and the stale-anchor fallback silently activated block 0. Clicks never reliably reached the intended paragraph. Now the segment between boundary cuts is mapped back onto the first tiled top_level_block_spans range overlapping it, so callers always get a real block span. New tests/live_preview.rs drives the full pipeline headlessly: paint+record -> simulated click y -> editor paints in place of the block -> synthetic keystroke lands in its buffer with changed=true. (The macros crate trybuild failure is pre-existing on main.)
show_scrollable wraps its source id as Id::new(source_id) for scroll-cache
keying; the new block-layout accessors keyed by the raw id instead, so in
the app (arbitrary PathBuf-derived Tab::id) they read a different,
always-empty entry. Clicks logged 'block layout not recorded yet' forever.
Headless tests passed only because Id::new("test-doc") coincided there.
Fix: accessors apply the identical wrapping; regression test passes an
arbitrary pre-built Id (PathBuf-derived, like Tab::id) and asserts the
accessors see show_scrollable's boundaries.
Rendered-mode frames fill split_points with record=false; on switching to Live, every split point already existed, and boundary recording was nested inside that dedup check — so boundaries stayed empty forever and every click logged 'block layout not recorded yet' (16/16 clicks in the telemetry session). Headless tests missed it because they recorded from frame one. - boundaries dedupe independently via new BlockBoundary.event_index (split-point existence no longer gates recording) - hit-test maps clicks above the first boundary to the first block instead of returning None - regression test: two Rendered frames then a Live frame must backfill all boundaries and resolve clicks immediately
next_widget_position() inside the renderer-owned ScrollArea is screen space: it shifts with scrolling and includes panel chrome. Boundaries recorded that way went stale the moment the document scrolled, so click-to-edit resolved to blocks far above the actual click. Boundaries are now stored relative to the content origin (screen pos minus the content ui's max_rect.min.y), making them scroll-invariant. App-side click mapping already worked in content space.
Compares six candidate architectures (v1 block-swap, layouter-decorated swap, Ferrite-style persistent styled editors, custom decoration engine, webview+tiptap, block-graph) against verified evidence: product research, Ferrite source dive, egui 0.33 layouter/cursor contract (char-indexed, password-mask precedent, #5885 clipboard constraint), Zed's open WYSIWYG request, and an ecosystem sweep confirming no maintained egui component does inline syntax-hiding editing. Recommendation: Persistent Styled Editors as Live-mode target (C), with styled-layouter swap (B+) as first shipping step; both compose. Includes PSE-1..4 migration plan from the current prototype.
markdown_block_job() renders the active block inside the swap-in TextEdit: - structural markers (# , - , > , 12. ) blanked via equal-char-count substitution -> cursor/copy math intact (password-mask precedent) - inline runs styled per-section: strong family, italics, mono+bg for code, link color+underline; delimiters and link urls hidden - headings scale via heading_scale multipliers on the strong face - caret line reveals raw syntax (Obsidian signature); caret char stored under editor id temp, one-frame lag - EditRegionConfig gains kind: EditBlockKind; app derives it from the block's first line (block_kind_of) - code blocks stay raw monospace (that is their rendered form) Known tradeoff (documented): copying selected text yields galley text, so hidden markers copy as blanks (egui#5885 constraint). Tests: 7 styler unit tests incl unicode length preservation, reveal-line, sections coverage; app block_kind tests; e2e typing still green through the new layouter path.
One-shot request_focus at activation was lost (egui applies focus at pass start, before the swapped-in widget exists) — telemetry showed focused=false for hundreds of frames, keystrokes dead, reveal_line frozen at 0. Now every Live frame re-requests focus until the editor owns the keyboard.
Boundary ys were converted at record time using ui.max_rect().min.y, whose frame inside the renderer-owned ScrollArea is not reliably the content origin — recorded values stayed in a screen-like frame, so clicks resolved to blocks far above the click point (the long-standing 'editor opens way above' bug), and my above-first-boundary fallback masked it into 'first block'. Now boundaries record RAW screen y every bootstrap frame (refreshed in place by event_index), and show_scrollable converts them with that same frame's ScrollAreaOutput (inner_rect.min.y + state.offset.y) — both sides of the subtraction measured in one frame, correct under any scroll/chrome. Hit-testing reads only converted top_y.
Absolute boundary ys still carried a constant frame bias (max_rect semantics inside the renderer-owned ScrollArea are not stable across layout churn), so clicks resolved to blocks above the click point even after the coordinate refactor. New model: the painted editor rect is ground truth for the active block. Each click shifts the whole boundary table by (recorded_top(active) - true_content_top(editor)) before hit-testing — any constant bias cancels; per-block deltas come from a single recording pass so they stay consistent. Fork stashes the editor rect per document id (stash_editor_rect/editor_rect); app exposes block_bounds() snapshot and does shifted hit-testing inline. Also: calibration only applies when an editor is open (first click in Live falls back to raw mapping).
Two defects found via deterministic synthetic clicks (MDV_SIM_CLICK_Y): - cut selection used 'last boundary above-or-at click', mapping every click inside a block to the PREVIOUS block; now counts cuts at/above the click (segment index semantics) - containment predicate had a .max(mid+1) clamp that matched the first span unconditionally; removed (midpoint rule + overlap fallback) Synthetic sweep now resolves monotonically across all nine probed offsets (H1 -> paragraphs -> heading -> list -> table -> code -> tail), with past-end clicks deactivating as designed.
expect() on last_content_origin fired when Live mode was active on a tab that had not painted yet (fresh tab + early click). Graceful skip; also tidies block-closer formatting.
Rapid typing inside an edited block resizes content before the debounced derived refresh; spans computed from old events could exceed the buffer and panic on slicing. Resolution now filters invalid spans (editor skips one frame until caches catch up) and the splice site validates the range before touching feedback.
Live mode now paints EVERY text block as its own styled TextEdit (markdown_block_job layouter: headings scaled, inline runs formatted, structural markers hidden, caret-line reveal) while non-text blocks (images/math/mermaid/tables/code fences) keep rendering normally. No swap, no jump: the whole document is continuously editable. - fork: EditSessionConfig/SessionBlock + edit_session builder; paint loop seeds per-block temp buffers from source on first sight and routes per-block feedback (index/text/changed) via stash - app: Tab.session_buffers/kinds/dirty; session_seed() on Live entry; deferred session_serialize() folds buffers back with marker re-materialization on Ctrl+S and mode exit; flash-free fallback to last known blocks across cache refreshes - retired single-editor click machinery for PSE (native TextEdit focus) Headless e2e live_session.rs proves: four editors painted, keystroke routed to focused block only, others untouched. Suites: app 57, backend 24+7 styler, frontend 37, e2e 3+1.
External-reload (user picks 'Reload from disk' after conflict) and open-file must drop per-block buffers so Live re-seeds from fresh content; otherwise stale buffers painted over new document.
View menu Live/Source/Rendered and File→Save are now visible to the egui-mcp bridge, enabling full UI automation of the editing flow. scripts/mcp_e2e.py drives: Ctrl+Live via menu click, editor count, focus+type into block 1, get_value round-trip, Ctrl+S serialize, disk content check.
[show] summary line per frame (session blocks count, region flag); per-block painted probe under MDV_EDIT_DEBUG+MDV_PROBE.
Root cause of 'vertical text fields': the markdown paint layout is inline-flow (left-to-right + wrap). Session editors painted inside it cascaded SIDEWAYS — each block to the right of the previous, ever narrowing (453px → 195 → 44...), producing vertical slivers of text. Each session editor now paints in its own top-down-justified row at full width. Also removes the obsolete single-editor edit_region branch (superseded by the session path) that a bad splice had corrupted.
…cing, exact heading scale) Editors previously used default row heights and frame margins only, so Live-mode blocks sat at different y positions than the rendered view. Now: - MarkdownEditStyle carries line_height (body*1.5), paragraph gap and per-level heading above/below scales matching the renderer's Major Third heading sizes (H3 1.25, H4 1.125 corrected) - markdown_block_job applies line_height to all text sections - session paint adds heading above/below + paragraph spacing via ui.add_space using options.typography - galley tests switched to structural row-count assertions (headless env reports zero font advances; width asserts unreliable)
The markdown paint layout is inline-flow (LTR + wrap). Unconstrained multiline TextEdits painted into it grabbed 'remaining width x entire available height' — narrow tall slivers marching rightward across the screen (the vertical-fields bug). Session editors now: pre-measure their styled galley, allocate exactly full-width x content-height (+margins) via allocate_ui_with_layout, constrain min_size so the widget can't stretch, and end the row after each block.
- headings: tight line height (1.3x equiv), Major Third scale, strong family; above/below typography spacing after paint - body/em/code/link: line_height 1.5x; code drops fake background - quotes: weak color like renderer - paragraph gap after paragraph blocks Closes the visual mismatch between Live editors and rendered view.
# Conflicts: # crates/egui_commonmark/egui_commonmark/src/lib.rs # crates/egui_commonmark/egui_commonmark/src/parsers/pulldown.rs # crates/egui_commonmark/egui_commonmark_backend/src/misc.rs # docs/KEYBOARD_SHORTCUTS.md # src/main.rs
… fold The session flush reset changed=false on every entry before stashing, so the app's 'if fb.changed' buffer fold never fired: keystrokes landed in the egui temp buffers but never reached session_buffers, and Ctrl+S would have saved the seed text. Entries are rebuilt from response.changed() every frame, so the reset was unnecessary — drop it and keep the edit-frame signal intact. Port live_preview's click-activate-type e2e from the vestigial edit_region API (which never had a feedback producer and always failed) to the session API: click resolves via recorded boundaries, session editors seed unchanged, keystroke changes exactly the focused block. Also re-bootstrap on record_block_layout frames so a Live frame after Rendered slice frames backfills all boundaries.
|
Follow-up commit Real bug found & fixed — session feedback lost the changed signal. The session flush loop reset Also:
All suites green: 86 app + 28 backend + 92 frontend + 6 wrapping + 3/3 live_preview + 1 live_session. clippy 0 errors. |
Summary
The flagship editing experience (
docs/research/editing-approaches.md§3-B, state summary indocs/research/editing-state.md): three per-tab modes cycled with Ctrl+E —Highlights
classify_disk_change(), external-change conflict banner, unsaved-changes guardsmcpfeature +scripts/mcp_e2e.pyJSON-RPC driver;MDV_SIM_CLICK_Y,MDV_EDIT_DEBUGhooksMerge with main
Branch rebased its integration onto current
main(includes #171 frontmatter, #201 sidebar/pane-clip, #202 fonts). Merge resolutions worth review:Tab::new/load_file/reloadroute through main'sapply_loaded_content, extended with the PSE session/dirty resetsreload_changed_tabscombines editing'sclassify_disk_change(Skip/Resync/Conflict/Reload) with main's directory-scoped explorer refresh andio::Resultreload error barsrecord_block_layoutframes must backfill all boundaries) — restored editing's force-bootstrap contract as an explicitediting_framecondition inshow_scrollableheader_position_keyTesting
live_preview: 2/3 —live_preview_click_activates_and_typesfails identically on the unmerged branch (pre-existing: the legacyedit_regionsingle-editor path has no feedback producer; superseded by sessions — verified by running the test at the pre-merge commit8526151)Known v1 limitations (from editing-state.md)
Follow-ups priority: CJK/IME pass → arrow-key block crossing → Ctrl+B/I/K shortcuts → per-item list editing.