Rendered Edit Session

May 22, 2026 · View on GitHub

Status: implemented (Phases 0–5; legacy rendered_focus removed in task 104)

Rendered (WYSIWYG) mode editing is coordinated by a single per-tab session instead of per-widget focus/defer hacks. One block (heading, paragraph, list item, formatted block, or table cell) is active at a time; switching blocks commits the previous buffer and opens the new target in one click.

PRD: prd-rendered-edit-session.md


Why the prior architecture failed

SymptomRoot cause
Double-click to switch blocksegui consumed the first click to defocus; commit → re-parse → id remap raced with new focus
Cursor flash / disappearui.push_id(content_hash, …) remapped all TextEdit ids on every keystroke commit
Stuck in raw formatted editformatted_exit_should_save deferred on blur but was never re-checked on later frames
Tables vs headings behaved differentlySeparate defer paths (TableGlobalFocus, rendered_focus, FormattedItemEditState) with no single owner

Fix: Stable widget ids (source_epoch scope) + explicit switch_to_ui at click boundaries + session-owned buffers.


Architecture

┌─────────────────────────────────────────────────────────────┐
│  Tab / MarkdownEditor                                        │
│  ┌─────────────────────┐    ┌──────────────────────────────┐ │
│  │ Tab::source_epoch   │    │ RenderedEditSession          │ │
│  │ (external invalid.) │    │  active: Option<BlockRef>    │ │
│  └──────────┬──────────┘    │  blocks: HashMap<BlockRef>   │ │
│             │               │  switch_to / close / commit  │ │
│             v               └──────────────┬───────────────┘ │
│  ui.push_id(editor_id)                     │                 │
│    ui.push_id(source_epoch)  ◄─────────────┘                 │
│      render blocks → session at click/focus boundaries       │
└─────────────────────────────────────────────────────────────┘
ModuleRole
src/markdown/rendered_session.rsTypes, state machine, tab-scoped egui storage
src/markdown/editor.rsRender paths, switch_to_ui, dismiss, table sync
src/markdown/widgets.rsEditableTable, force-commit signal, cell focus
src/markdown/rendered_commit_undo.rsOne undo step per commit boundary
src/state.rsTab::source_epoch(), bump helpers

source_epoch — widget identity scope

Rendered TextEdit ids live under ui.push_id(editor_id) + ui.push_id(source_epoch), not content_hash. Viewport culling still uses content hash independently.

See rendered-widget-identity.md and rendered-edit-session-phase0.md.

EventBumps source_epoch?
Rendered block commit (session close/switch)No
Raw FerriteEditor edit (same tab)Yes
Undo / redo / file reload / find-replaceYes
Split raw pane editYes (rendered pane reloads buffers on next frame)
Rendered keystrokes while block stays activeNo

On epoch mismatch, load_for_epoch calls invalidate_buffers() — session buffers reload from source on next interaction (RS-6).


BlockRef — stable block identity

Module: rendered_session::BlockRef

VariantIdentity fieldsWidget id suffix (under scope)
Heading { line, structural }1-indexed source line; structural*_sk keysheading_text[_sk] + line
Paragraph { line }1-indexed start linepara_text + line
ListItem { line, item }Start line + enumerate index within list nodelist_item_text + line (item is map key only)
FormattedParagraph { line, structural }Same as paragraphformatted_paragraph[_sk] + line + text_edit
FormattedListItem { line, item, structural }Line + item indexformatted_list_item[_sk] + line + item + text_edit
TableCell { table_line, row, col }Table start line (1-indexed) + cell coordstable + table_line + cell + row + col

Rules:

  • All line values are 1-indexed (consistent with EditState / AST).
  • List item matches the enumerate counter used when rendering (unique per sibling item).
  • structural: true selects structural-key code paths (render_*_with_structural_keys).

RenderedEditSession — data model & API

pub struct RenderedEditSession {
    pub active: Option<BlockRef>,
    pub blocks: HashMap<BlockRef, BlockEditState>,
}

pub struct BlockEditState {
    pub text: String,              // TextEdit buffer (raw markdown for formatted blocks)
    pub formatted_editing: bool,     // false = styled display, true = raw TextEdit
    pub dirty: bool,
    pub pending_activation: Option<PendingActivation>,
}
MethodBehavior
switch_to / switch_to_uiClose previous (SaveIfDirty), set active, queue PendingActivation; _ui variant surrenders previous egui focus
close_active / close_active_uiClear active; commit or discard per CommitPolicy
on_text_changedUpdate buffer, mark dirty — no source write
commit_activeForce-commit active buffer via callback
discard_activeReload buffer from source (Escape on formatted)
invalidate_buffersClear all state after epoch bump
load_for_epoch / save_for_epochTab-scoped temp memory + epoch tracking

Storage id: editor_id.with("rendered_edit_session"). Editor id: rendered_editor_id(tab.id) — shared by rendered-only and split preview (split view doc).


Commit policy (by block type)

Block typeWhile editingOn close / switch
HeadingBuffer in sessionupdate_source_line via commit_session_block
Plain paragraph / list itemBuffer in sessionupdate_source_range
Formatted paragraph / list itemRaw in buffer; display when formatted_editing == falseSave raw → source; set formatted_editing = false
Table cellText in TableData (widget), not session buffersignal_table_force_commit when session leaves table; widget flushes on next frame
Code blocks / mermaidNot session-backedExisting widget-local commit paths unchanged

Close triggers: switch_to another block; click-outside dismiss (session_dismiss_if_clicked_outside); Enter/Escape on formatted blocks; tab close / mode switch to raw.

Undo: One logical undo step per commit boundary — see rendered-edit-session-undo.md.


Focus switch flow (old vs new)

Prior architecture (removed)

User clicks B while A focused
  → egui defocuses A (first click)
  → A: lost_focus → defer commit → content_hash changes
  → all widget ids remap
  → B: request_focus / restore_switch_focus fights egui
  → cursor lost; formatted: editing flag stuck

Current (session)

User clicks B while A active
  → session.switch_to_ui(B, PendingActivation { cursor, request_focus })
      → close A: save buffer if dirty, surrender A's egui focus (no epoch bump)
      → open B: set pending_activation
  → next frame: B TextEdit applies focus + cursor
  → stable ids throughout (source_epoch unchanged)

Tables: cross-block exit runs commit_fnsignal_table_force_commit(table_line); intra-table Tab uses direct session.active assign (no commit). See rendered-edit-session-tables.md.

Formatted blocks: display click → galley cursor → PendingActivation. See click-to-edit-formatting.md and galley-cursor-positioning.md.


Block-type documentation

Block typeDetail doc
Core types & APIrendered-edit-session-core.md
Headingsrendered-edit-session-headings.md
Paragraphs & listsrendered-edit-session-paragraphs-lists.md
Formatted blocksrendered-edit-session-formatted.md
Tablesrendered-edit-session-tables.md
Split view / RS-6rendered-edit-session-split-view.md
Undorendered-edit-session-undo.md
Widget idsrendered-widget-identity.md
Phase 0 historyrendered-edit-session-phase0.md

Resolved design decisions (PRD open questions)

QuestionDecision
Click outside documentSave and close active block (session_dismiss_if_clicked_outside at end of rendered frame). Uses session_active_clicked flag — active block must receive response.clicked() or dismiss fires with SaveIfDirty.
Buffer evictionFull invalidate_buffers() on source_epoch mismatch; no LRU cache of closed blocks. Re-open cold-loads from source via ensure_formatted_block_initialized / render paths.
Code blocks / mermaidOut of scope for session in v0.3.x — separate widget-local edit state.
Split conflictRaw pane wins on content: raw edit bumps epoch → rendered session invalidated. No simultaneous edit merge; rendered buffers reload from updated source. User should not expect live dual-pane co-editing of the same block without epoch refresh.

Regression matrix — rendered editing (RS-1…RS-7)

Manual acceptance tests for the session model. Full execution log: v0.3.0-regression-matrix.md §3.12.

IDStepsExpected result
RS-1Doc with # Alpha and # Beta. Edit Alpha text. Single-click Beta.Beta focused at click; Alpha text persisted in source; no second click required.
RS-2Click between characters in a heading. Type continuously for 3+ seconds.Caret stays visible and stable; no flash/disappear each keystroke.
RS-3Bullet item with **bold**. Click → edit raw → click empty space outside any block.Returns to styled display; not stuck showing **bold** TextEdit.
RS-4Two formatted list items. Edit first; single-click second.Second enters edit; first saved; one click after typing.
RS-5Doc with table + heading. Edit cell; single-click heading.Table committed to source; heading focused; one click.
RS-6Split view: edit raw left pane; observe rendered right.Rendered reflects change after epoch bump (may need one frame / re-focus). Session buffers cleared — re-click block loads new source.
RS-7Enable trace logging. Edit heading, switch away (commit).source_epoch unchanged across rendered edit + commit; only external/raw edits bump.

Table scenarios (TBLE-1…TBLE-3)

Under session model, table intra-grid behaviour is unchanged; cross-block exit goes through session + force-commit signal. See §3.11 in the regression matrix.

IDStepsExpected result
TBLE-1Rendered/split: Add column; click each new empty cell; type; click outside table.Each empty cell accepts click; edits commit when leaving table; source shows new | columns.
TBLE-2Add row; click empties; type; blur table.New | lines in source.
TBLE-3From populated cell, Tab across several empty cells.Each cell opens edit with empty buffer; Tab does not commit table mid-navigation; text only in active cell.

Automated tests

cargo test rendered_session::

Covers switch/commit/discard, widget id suffixes, table cross-block/intra-table, epoch invalidation, force-commit signal (see widgets.rs tests).


DocUpdate
wysiwyg-editor.mdRendered mode overview + session pointer
click-to-edit-formatting.mdSession owns formatted buffers
table-editing-focus.mdSession triggers commit on leave
table-cell-focus-navigation.mdTab/empty cells + session sync