docs(renderer): document rendered lifecycle

This commit is contained in:
Feror 2026-05-31 23:02:20 +02:00
parent 0dd8351847
commit f4948c4089

View file

@ -852,6 +852,172 @@ Conclusion:
Milestone 3.1 moves Sapling's inactive-line display closer to rendered Markdown while preserving the validated hybrid editing contract. The renderer now hides the most distracting source syntax for headings, inline markup, and fenced code blocks, and it gives task lists visible state. The remaining limitations are deliberate source-preserving tradeoffs rather than missed parser features. Milestone 3.1 moves Sapling's inactive-line display closer to rendered Markdown while preserving the validated hybrid editing contract. The renderer now hides the most distracting source syntax for headings, inline markup, and fenced code blocks, and it gives task lists visible state. The remaining limitations are deliberate source-preserving tradeoffs rather than missed parser features.
## Finding #14 — Rendered Document Lifecycle
Milestone 3.2 makes rendered state explicit instead of leaving it implicit inside styling code. Before this change, the native adapter decided whether a line was source or rendered inside `MarkdownTextStyler.apply(...)` by comparing each line index to the active line. That worked, but it left presentation state spread across delegate timing, invalidation caches, and styler branches.
The new lifecycle model is:
- `RenderedLineState.source`: exactly one active line derived from the current native selection and `DocumentLineIndex`.
- `RenderedLineState.rendered`: every other visible line.
- `DocumentPresentationState`: deterministic projection from `(DocumentLineIndex, activeLineIndex, optional dirty line indexes)` to presentation lines and semantic rendered elements.
- `DocumentPresentationLine`: one source line plus its line state, render plan, and semantic rendered elements.
Every line in a presentation pass is now in exactly one state. The state is data-driven by current document text and current selection, not by previous focus history or click history.
```mermaid
stateDiagram-v2
[*] --> Rendered: line.index != activeLineIndex
[*] --> Source: line.index == activeLineIndex
Rendered --> Source: selection enters line
Source --> Rendered: selection leaves line
Rendered --> Rendered: source edit elsewhere
Source --> Source: source edit on active line
```
Render pipeline after Milestone 3.2:
```mermaid
flowchart TD
NativeSelection["Native text selection"] --> Active["DocumentLineIndex.lineIndex(containing:)"]
Source["Markdown source"] --> LineIndex["DocumentLineIndex"]
LineIndex --> Presentation["DocumentPresentationState"]
Active --> Presentation
Dirty["Dirty line indexes"] --> Presentation
Presentation --> Lines["DocumentPresentationLine"]
Lines --> Styler["MarkdownTextStyler"]
Lines --> Elements["RenderedDocumentElement"]
Elements --> Widgets["Rendered widgets / overlays"]
Styler --> TextStorage["NSTextStorage attributes"]
```
Invalidation audit:
- Initial rendering is still a full presentation pass. `EditorDirtyLineInvalidator` emits `.initial` with every line index, and the presentation model assigns one source line and all remaining lines as rendered.
- Source edits still use dirty-line invalidation and preserve neighbor-line coverage for constructs affected by adjacent text.
- Active-line transitions still dirty only the previous and current active line.
- View updates with unchanged source and unchanged active line still skip styling.
- The important timing fix is that native adapters now derive the active line from the current native text selection immediately before building the invalidation plan. Selection-change styling no longer depends on waiting for SwiftUI binding propagation to update the `activeLineIndex` value.
Rendered element architecture:
`DocumentPresentationState` now emits semantic `RenderedDocumentElement` values for supported constructs:
- `heading`
- `task`
- `codeBlock`
- `link`
- `inlineCode`
- `blockquote`
- `horizontalRule`
- `listItem`
- `tableRow`
Plain paragraphs are intentionally not materialized as semantic elements in normal passes. They are represented by their `DocumentPresentationLine` and render plan, which avoids allocating tens of thousands of no-op paragraph elements in large documents.
Code block lifecycle:
Fenced code blocks are represented as semantic multi-line `RenderedCodeBlockElement` values with:
- all participating line indexes
- the source range spanning the block
- the optional language range
The attributed renderer still styles code blocks line-by-line inside TextKit, but the document presentation model now has a block-level representation that future widgets can consume without reparsing source text.
Tradeoffs:
- Text remains source-preserving. Hidden syntax is still done with attributes, not source replacement.
- The native text view remains the scroll, selection, undo, and IME owner.
- Semantic elements are only emitted for constructs that need rendered behavior or future widget hooks.
- Dirty presentation only resolves code-block context when nearby fence syntax makes block context relevant; this preserves the Milestone 2.9 dirty-render scalability model.
Validation:
- `DocumentPresentationStateTests` verify one state per line, deterministic projection for identical input, semantic heading/task/link elements, semantic code-block state, and dirty code-block context recovery.
- Existing cursor, dirty invalidation, scroll stability, rendering, large-document, and performance tests continue to pass.
Before/after release benchmark comparison:
| Scenario | Milestone 3.1 total | Milestone 3.2 total | Milestone 3.1 dirty typing render | Milestone 3.2 dirty typing render |
| --- | ---: | ---: | ---: | ---: |
| sample document | 13.506 ms | 10.013 ms | 0.330 ms | 0.220 ms |
| 2,100-line prototype | 423.835 ms | 477.423 ms | 0.368 ms | 0.383 ms |
| 5 MB benchmark | 3,275.857 ms | 3,614.818 ms | 1.002 ms | 1.049 ms |
Tracked 5 MB interaction metrics after Milestone 3.2:
| Operation | Time |
| --- | ---: |
| active-line lookup | 0.000 ms |
| selection update | 0.005 ms |
| dirty click invalidation | 0.001 ms |
| typing state update | 0.077 ms |
| dirty typing invalidation | 0.003 ms |
| dirty typing render | 1.049 ms |
Conclusion:
The presentation lifecycle is now explicit and deterministic. Opening a document, moving the cursor, and editing source all project through the same state model: source for the active line, rendered for inactive lines. The measured cost is a modest increase in initial large-document work, while dirty interaction remains within the Milestone 2.9 responsiveness envelope.
## Finding #15 — Interactive Rendered Elements
Milestone 3.2 introduces task lists as the first interactive rendered element. The Markdown source remains authoritative; the checkbox is only a rendered control backed by a known source range.
Checklist architecture on macOS:
```mermaid
sequenceDiagram
participant User
participant Checkbox as Native NSButton checkbox
participant Coordinator as NSTextView Coordinator
participant Source as Markdown source
participant Index as DocumentLineIndex
participant Presentation as DocumentPresentationState
participant Styler as MarkdownTextStyler
User->>Checkbox: click
Checkbox->>Coordinator: toggle RenderedTaskElement
Coordinator->>Source: replace checkbox range with [ ] or [x]
Coordinator->>Index: apply DocumentLineIndexEdit
Coordinator->>Presentation: rebuild affected presentation
Presentation->>Styler: rendered/source line states
Styler->>Checkbox: sync overlay state and position
```
Implementation details:
- Rendered task lines produce `RenderedTaskElement` with marker, checkbox, content, checked state, and nesting level.
- The macOS `NSTextView` adapter creates native `NSButton` checkbox overlays for inactive rendered task lines.
- Clicking the checkbox replaces the Markdown checkbox source range with `[x]` or `[ ]`.
- The edit flows through the same source update, line-index update, dirty invalidation, and restyling path as typing.
- When the line becomes active, it returns to source mode and the overlay is removed for that line.
This is deliberately an overlay, not a text replacement. TextKit remains responsible for the source buffer and selection. The overlay consumes semantic rendered element data and writes back to source through `DocumentLineIndexEdit`.
Current platform scope:
- macOS has the native interactive checklist overlay.
- iOS keeps the attributed task-list fallback for now; it needs a `UITextView` overlay equivalent before task checkboxes are interactive there.
Consistency:
- Headings, inline Markdown, links, task lists, and code blocks now all pass through `DocumentPresentationState`.
- Active lines show source.
- Inactive lines show rendered presentation.
- Widgets are attached to semantic rendered elements, not to ad hoc regex matches in the native view.
Future extensibility:
The rendered element architecture can support images, wiki links, Mermaid, LaTeX, and embeds without fundamental redesign. The evidence is structural:
- The presentation layer already separates semantic element detection from platform rendering.
- Elements carry source ranges, so widgets can update or inspect Markdown while preserving source as truth.
- Code blocks already prove multi-line semantic elements can coexist with line-level dirty styling.
- Checklist overlays prove rendered controls can live above the source-preserving text view and write back through normal edit/invalidation paths.
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.
## 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.