docs(renderer): document rendering determinism

This commit is contained in:
Feror 2026-06-01 09:14:08 +02:00
parent fcfe65050f
commit df1e2ad8f4

View file

@ -1018,6 +1018,145 @@ The rendered element architecture can support images, wiki links, Mermaid, LaTeX
Future rich elements will still need feature-specific parsers, layout, accessibility, hit testing, and caching. Those are additions to the rendered element set and platform widget layer, not a replacement of the hybrid editor architecture. Future rich elements will still need feature-specific parsers, layout, accessibility, hit testing, and caching. Those are additions to the rendered element set and platform widget layer, not a replacement of the hybrid editor architecture.
## Finding #16 — Rendering Determinism
Milestone 3.3 separates the pure document render model from the editor presentation overlay. The previous lifecycle was explicit enough to decide source versus rendered lines, but it still lacked a testable model for the statement "same Markdown produces same rendered document." That made interaction-related regressions hard to isolate from real Markdown rendering changes.
Root causes found:
- Initial heading correctness depended on presentation passes being exercised through the editor path. There was no independent render model that could prove headings existed in the rendered document before focus, click, or selection changes.
- Rendered output and active-line presentation were easy to conflate. `DocumentPresentationState` intentionally depends on the active line because hybrid editing needs one source line, but the rendered document should not depend on active-line history.
- Render validation compared presentation behavior, not semantic render output. It could prove "line 2 is source" but not "this document deterministically contains heading, task, link, and code-block render nodes."
The corrected model is:
```mermaid
flowchart LR
Markdown["Markdown source"] --> Index["DocumentLineIndex"]
Index --> Model["DocumentRenderModel"]
Model --> Nodes["RenderNode values"]
Model --> Snapshot["RenderSnapshot"]
Index --> Presentation["DocumentPresentationState"]
Selection["Current editor selection"] --> Presentation
Presentation --> TextKit["Hybrid NSTextView presentation"]
```
`DocumentRenderModel` is the pure path. It receives a `DocumentLineIndex`, renders every selected line with no active line, and emits:
- `RenderedLine` values containing the source line, render plan, and semantic render nodes for that line.
- `RenderNode` values wrapping `RenderedDocumentElement` instances such as headings, tasks, links, inline code, blockquotes, list items, table rows, and code blocks.
- `RenderSnapshot`, a stable text signature of line kinds, spans, semantic nodes, source ranges, and whole-tree nodes.
`DocumentPresentationState` remains the editor path. It may depend on the current selection because active-line source mode is intentional. That interaction state is now outside the pure render model.
Determinism guarantees:
- Same Markdown source produces the same `DocumentRenderModel.snapshot`.
- Heading render nodes are produced on initial model construction without requiring focus, click, scroll, or selection changes.
- Snapshot changes are tied to Markdown changes, such as toggling `[ ]` to `[x]`.
- Dirty-line invalidation remains scoped to nearby affected lines; the pure render model does not add document-wide work to normal typing.
Validation added:
- `testDocumentRenderModelIsIndependentOfActiveLinePresentation`
- `testInitialRenderModelContainsHeadingsWithoutInteraction`
- `testRenderSnapshotChangesOnlyWhenMarkdownChanges`
- `testRenderedTaskToggleReplacementDoesNotMoveSourceRanges`
Release benchmark after Milestone 3.3:
| Scenario | Total | Dirty typing render | Dirty lines |
| --- | ---: | ---: | ---: |
| sample document | 11.207 ms | 0.268 ms | 3 |
| 2,100-line prototype | 426.829 ms | 0.357 ms | 3 |
| 5 MB benchmark | 3,154.725 ms | 0.888 ms | 3 |
The interaction path remains inside the Milestone 2.9/3.2 responsiveness envelope. The 5 MB benchmark still performs dirty typing render under 1 ms and styles only three lines.
Remaining nondeterminism boundary:
- Semantic rendering is deterministic and covered by snapshots.
- Hybrid presentation intentionally changes with the current selection because the active line must show Markdown source.
- Pixel-level TextKit layout and overlay geometry are not yet covered by automated screenshot comparison. They are constrained by source ranges and native layout APIs, but not proven by a visual snapshot test.
- iOS still uses the attributed checklist fallback; the interactive overlay is macOS-only for now.
Conclusion:
The renderer now has a deterministic semantic model. The editor presentation is a deterministic projection of document state plus the current active-line selection, which is the intended hybrid editing exception.
## Finding #17 — Interactive Widget Stability
Milestone 3.3 stabilizes checklist widgets so they behave as document interactions instead of editing interactions.
The observed checklist bug was:
```text
Before click:
☐ Move with arrow keys.
After click:
Move with arrow keys.
```
Root cause:
The checkbox toggle path replaced the Markdown checkbox and then moved the text selection to the checkbox range. That made the task line become the active source line after the click. Because active lines show Markdown source and inactive lines show rendered content, the click changed line state and layout geometry even though the user only intended to toggle task state.
The corrected interaction is:
```mermaid
sequenceDiagram
participant User
participant Checkbox as Checklist overlay
participant TextView as NSTextView
participant Source as Markdown source
participant Index as DocumentLineIndex
participant Renderer as Presentation renderer
User->>Checkbox: click
Checkbox->>TextView: preserve current selection and focus state
TextView->>Source: replace [ ] with [x] or [x] with [ ]
TextView->>Index: apply same-length DocumentLineIndexEdit
TextView->>Renderer: rerender affected task line
Renderer->>Checkbox: update checkbox state at same source range
TextView->>TextView: restore previous selection and focus
```
Checklist stability guarantees:
- Clicking a rendered checkbox updates only the Markdown checkbox marker.
- The replacement is same-length (`[ ]` and `[x]`), so source ranges and layout anchors remain stable.
- The previous selection is restored after the programmatic edit.
- The text view regains first responder only when it already had focus before the click.
- The native checkbox refuses first responder, preventing the control from stealing editor focus.
- `pendingEdit` is bypassed during the programmatic toggle so the line index receives exactly one explicit `DocumentLineIndexEdit`.
This preserves the active editing line. A user can edit one paragraph, click a checklist elsewhere, and continue editing from the same selection.
Paragraph stability:
The paragraph reflow symptom was caused by widget clicks changing the active line, not by different paragraph Markdown. Once checkbox clicks preserve selection and active line, unchanged paragraphs continue through the same rendered/source state and therefore keep the same wrapping, alignment, and geometry.
Tradeoffs:
- The widget remains an overlay rather than a replacement inside the text buffer. This preserves native source selection, undo integration, IME behavior, and dirty-line invalidation.
- The checkbox toggle still modifies Markdown source immediately; undo semantics should continue through the native text system, but richer widget undo behavior needs manual testing as more widgets are added.
- Visual stability is constrained by same-length source edits and range-based overlay placement. Pixel-level regression coverage remains future work.
Applicability to future widgets:
The same interaction contract should apply to images, wiki links, embeds, Mermaid, and LaTeX:
- Render from semantic nodes.
- Keep Markdown as source of truth.
- Route widget actions through explicit source edits.
- Preserve selection, active line, and focus unless the user explicitly begins editing that element.
- Rerender only affected source ranges.
This means future rendered widgets can be added without changing the fundamental editor architecture.
## AttributedString and NSAttributedString ## AttributedString and NSAttributedString
Swift `AttributedString` is useful for renderer-facing APIs and SwiftUI previews. Swift `AttributedString` is useful for renderer-facing APIs and SwiftUI previews.