Skip to content

feat(editing): Live/Source editing modes (PSE v1) - #203

Merged
aydiler merged 33 commits into
mainfrom
feature/editing
Sep 13, 2026
Merged

aydiler merged 33 commits into
mainfrom
feature/editing

Conversation

@aydiler

@aydiler aydiler commented Sep 13, 2026

Copy link
Copy Markdown
Owner

Summary

The flagship editing experience (docs/research/editing-approaches.md §3-B, state summary in docs/research/editing-state.md): three per-tab modes cycled with Ctrl+E —

Mode Behavior
Rendered Original viewer, untouched
Live (PSE) Every text block is a permanently-styled TextEdit laid out in document flow; images/math/mermaid/tables/code render normally between editors
Source Whole-file monospace editor

Highlights

  • Live mode fidelity: editors adopt renderer typography by construction (Major Third heading scale, tight heading line height, 1.5× body, weak quotes); structural markers hidden via equal-char-count glyph substitution so cursor/selection/IME math stays exact (egui#5885-safe); caret-line reveal shows raw markdown (Obsidian-style)
  • Session model: keystrokes mutate per-block temp buffers only; serialization happens on Ctrl+S / mode exit — never mid-typing, no reflow races
  • Phase 0 infrastructure: atomic background saves (temp+rename), dirty tracking, watcher self-write suppression via pure classify_disk_change(), external-change conflict banner, unsaved-changes guards
  • MCP test automation: mcp feature + scripts/mcp_e2e.py JSON-RPC driver; MDV_SIM_CLICK_Y, MDV_EDIT_DEBUG hooks

Merge 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/reload route through main's apply_loaded_content, extended with the PSE session/dirty resets
  • reload_changed_tabs combines editing's classify_disk_change (Skip/Resync/Conflict/Reload) with main's directory-scoped explorer refresh and io::Result reload error bars
  • Rendered mode keeps main's viewport-slice fast path; editing frames re-bootstrap (session feedback folds only blocks that actually paint, and record_block_layout frames must backfill all boundaries) — restored editing's force-bootstrap contract as an explicit editing_frame condition in show_scrollable
  • Outline clicks in Live/Source jump via caret/active block; Rendered uses main's source-stable header_position_key
  • Ctrl+S/Ctrl+E/Esc-to-deactivate added alongside main's multiplicative zoom

Testing

  • App: 86 passed (5 ignored) · backend: 28 · frontend lib: 92 · wrapping: 6
  • live_preview: 2/3 — live_preview_click_activates_and_types fails identically on the unmerged branch (pre-existing: the legacy edit_region single-editor path has no feedback producer; superseded by sessions — verified by running the test at the pre-merge commit 8526151)
  • clippy: 0 errors; warnings are pre-existing editing-branch WIP (styler.rs, unused legacy imports)

Known v1 limitations (from editing-state.md)

  1. Lists edit as one raw unit; 2. arrows don't cross block boundaries; 3. search/outline trail last-serialized content; 4. copying styled text yields galley text (egui#5885); 5. no formatting shortcuts yet; 6. IME/CJK untested; 7. conflict banner active-tab only; 8. >10k events repaint fully (fork limitation)

Follow-ups priority: CJK/IME pass → arrow-key block crossing → Ctrl+B/I/K shortcuts → per-item list editing.

aydiler added 30 commits August 23, 2026 22:32
…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.
@aydiler

aydiler commented Sep 13, 2026

Copy link
Copy Markdown
Owner Author

Follow-up commit e5b0199 (rebasing onto main surfaced it):

Real bug found & fixed — session feedback lost the changed signal. The session flush loop reset changed = false on every entry before stashing, so the app's if fb.changed buffer fold never fired: keystrokes lived in the egui temp buffers but never reached session_buffers, meaning Ctrl+S would have saved the seed text, not your edits. Entries are rebuilt from response.changed() every frame, so the reset was unnecessary — dropped it. My ported e2e (typing → feedback → buffer divergence) caught this; the old live_preview e2e couldn't, because it drove the vestigial edit_region API that never had a feedback producer at all (that test failed identically on the unmerged branch — see earlier note).

Also:

  • Ported the click→activate→type e2e to the session API (live_session_click_types_into_block): click resolves via recorded boundaries, keystroke changes exactly the focused block.
  • show_scrollable now re-bootstraps on record_block_layout frames so a Live frame after Rendered slice frames backfills all boundaries.

All suites green: 86 app + 28 backend + 92 frontend + 6 wrapping + 3/3 live_preview + 1 live_session. clippy 0 errors.

@aydiler
aydiler merged commit 68a38ab into main Sep 13, 2026
1 of 5 checks passed
@aydiler
aydiler deleted the feature/editing branch September 13, 2026 02:01
aydiler added a commit that referenced this pull request Sep 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant