docs(editor): evaluate hybrid rendering architecture
This commit is contained in:
parent
1ee42300ce
commit
6b1d2f8b27
1 changed files with 152 additions and 13 deletions
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Date: 2026-05
|
||||
|
||||
Status: Milestone 1 validation
|
||||
Status: Milestone 2 validation
|
||||
|
||||
Related decisions:
|
||||
|
||||
|
|
@ -259,6 +259,138 @@ Result:
|
|||
|
||||
The prototype now opens into a focused writing layout with a larger text area, collapsible sidebar, readable line width, generous padding, a subtle current-line treatment, and a realistic sample document for testing.
|
||||
|
||||
## Finding #3 — Hybrid Editing Architecture
|
||||
|
||||
Milestone 2 keeps one canonical Markdown source buffer inside the native text view. The editor does not maintain a second rendered document, and it does not replace inactive lines with different strings. Active-line source mode and inactive-line rendered mode are presentation states derived from the same source.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
TextView["NSTextView / UITextView source buffer"] --> Delegate["Native delegate callbacks"]
|
||||
Delegate --> Coordinator["Bridge coordinator"]
|
||||
Coordinator --> ViewModel["HybridMarkdownEditorViewModel"]
|
||||
ViewModel --> State["EditorState"]
|
||||
State --> Tracker["EditorActiveLineTracker"]
|
||||
Tracker --> Lines["EditorLine ranges and modes"]
|
||||
Lines --> Styler["MarkdownTextStyler"]
|
||||
Styler --> TextStorage["NSTextStorage attributes"]
|
||||
TextStorage --> TextView
|
||||
Coordinator --> Metrics["EditorInstrumentationSnapshot"]
|
||||
```
|
||||
|
||||
The active-line model now has a single source of truth:
|
||||
|
||||
- `EditorState.selection` stores the current source-buffer selection.
|
||||
- `EditorActiveLineTracker` maps the selection location to one line index.
|
||||
- `EditorState.activeLineIndex` is derived from that mapping after every source or selection update.
|
||||
- `EditorLine.mode` is rebuilt from the active line and source ranges.
|
||||
- The native bridge only applies attributes from that state; it does not decide which line is active.
|
||||
|
||||
This avoids recursive update loops because the native adapter still owns programmatic-update suppression. Text, selection, and attribute changes caused by Sapling are wrapped in coordinator transactions and ignored by delegate callbacks. User-originated changes flow back to the view model only when the source or selection actually changed.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Native as Native text view
|
||||
participant Bridge as Coordinator
|
||||
participant State as EditorState
|
||||
participant Render as Attribute styler
|
||||
|
||||
User->>Native: Move cursor or edit text
|
||||
Native->>Bridge: Delegate notification
|
||||
Bridge->>State: Update source or selection
|
||||
State->>State: Derive activeLineIndex
|
||||
State->>Render: Apply attributes for active/inactive lines
|
||||
Render->>Native: Preserve selection and visible origin
|
||||
```
|
||||
|
||||
Implementation scope is intentionally minimal:
|
||||
|
||||
- Headings are visually emphasized on inactive lines.
|
||||
- Bold, italic, and inline code receive inline attributes.
|
||||
- Markdown delimiters are deemphasized, not hidden.
|
||||
- Links, tasks, lists, code blocks, tables, images, Mermaid, LaTeX, and attachments are not promoted in the hybrid renderer for this phase.
|
||||
|
||||
Cursor and selection behavior remain stable because the visible text length is still the source text length. Delimiters are styled but not removed, so source offsets, native selection ranges, undo grouping, and TextKit cursor movement continue to share the same coordinate system.
|
||||
|
||||
## Finding #4 — Rendering Strategy Evaluation
|
||||
|
||||
Milestone 2 compared four rendering strategies against the current product risk: editing correctness is more important than rendering quality.
|
||||
|
||||
| Approach | Complexity | Performance | Cursor stability | Maintainability | Finding |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| A. Attributed-string rendering | Low to medium | O(n) per full attribute pass today; optimizable by dirty ranges | Strong because source offsets are unchanged | Strong while rendering is inline and line-level | Chosen for Milestone 2 |
|
||||
| B. Overlay rendering | High | Potentially good with cached layout, but expensive to synchronize | Medium risk from hit testing, selection painting, and accessibility | Medium; requires layout synchronization code | Defer until rich block rendering |
|
||||
| C. Line replacement | High | Could be efficient per line, but requires source/render mapping | High risk because cursor offsets diverge from visible text | Fragile around undo, IME, and cross-line selection | Not appropriate for current milestone |
|
||||
| D. Mixed editor/render layers | Very high | Potentially best long term with a custom layout engine | Unknown until Sapling owns text input, IME, selection, undo, accessibility | Expensive; effectively a custom editor engine | Long-term fallback only |
|
||||
|
||||
Approach A was selected because it validates Sapling's core hybrid interaction while preserving native text editing. It does not prove that full rendered blocks can live inside `NSTextView`, but it does prove that active-line source editing and inactive-line presentation can coexist without destabilizing cursor and selection behavior.
|
||||
|
||||
```mermaid
|
||||
quadrantChart
|
||||
title Rendering Strategy Risk
|
||||
x-axis Low cursor stability --> High cursor stability
|
||||
y-axis Low implementation complexity --> High implementation complexity
|
||||
quadrant-1 "Good validation path"
|
||||
quadrant-2 "Powerful but expensive"
|
||||
quadrant-3 "Avoid"
|
||||
quadrant-4 "Defer"
|
||||
"Attributed attributes": [0.82, 0.28]
|
||||
"Overlay rendering": [0.55, 0.72]
|
||||
"Line replacement": [0.28, 0.68]
|
||||
"Mixed custom layers": [0.45, 0.92]
|
||||
```
|
||||
|
||||
The important tradeoff is honest: attributed rendering cannot hide Markdown syntax without leaving visible gaps or breaking cursor math. For Milestone 2, that is acceptable. It is better to keep the editor believable and predictable than to make inactive lines look fully rendered at the cost of offset instability.
|
||||
|
||||
## Finding #5 — Performance Characteristics
|
||||
|
||||
Instrumentation was added at the editor boundary:
|
||||
|
||||
- Source changes are counted in `EditorInstrumentationSnapshot.sourceChangeCount`.
|
||||
- Selection changes are counted in `selectionChangeCount`.
|
||||
- Active-line transitions are counted in `activeLineChangeCount`.
|
||||
- Native attribute passes are counted in `renderPassCount`.
|
||||
- Each render pass records reason, duration, source character count, line count, and active line index.
|
||||
|
||||
The counters are intentionally not `@Published`. They can be inspected by tests, logs, or future debug UI without causing every render pass to trigger another SwiftUI update.
|
||||
|
||||
Automated validation added in `SaplingEditorTests` covers:
|
||||
|
||||
- Cursor-derived active-line transitions.
|
||||
- Selection clamping after deletion.
|
||||
- Trailing blank-line range tracking.
|
||||
- Heading, bold, italic, and inline-code render plans.
|
||||
- Links and tasks remaining unsupported in the Milestone 2 renderer.
|
||||
- A 2,100-line prototype document shape.
|
||||
|
||||
Current measured automated results on May 29, 2026:
|
||||
|
||||
- `swift test`: 13 tests passed.
|
||||
- Large render-plan test: 2,100 lines processed in roughly 0.04 to 0.06 seconds during the XCTest run.
|
||||
- Full suite runtime: roughly 0.05 to 0.07 seconds after build.
|
||||
|
||||
Prototype documents were added for repeatable manual validation:
|
||||
|
||||
- `Docs/EditorPrototypes/hybrid-small-50.md`
|
||||
- `Docs/EditorPrototypes/hybrid-medium-500.md`
|
||||
- `Docs/EditorPrototypes/hybrid-large-2100.md`
|
||||
|
||||
Scroll stability work:
|
||||
|
||||
- Attribute passes restore the pre-render visible origin.
|
||||
- Selection restoration after styling does not call scroll-to-visible.
|
||||
- Explicit programmatic selection changes still scroll the selected range into view.
|
||||
|
||||
This means rendering updates should not yank the viewport while the user scrolls or while inactive-line attributes refresh. Native cursor movement still gets to scroll naturally when the insertion point moves outside the visible area.
|
||||
|
||||
Known performance limitation:
|
||||
|
||||
The current styler still applies a full-buffer attribute pass when the source changes or the active line changes. The pass is guarded by a text/active-line cache, so redundant SwiftUI updates do not restyle, but active-line navigation through a large document is still O(n). This is acceptable for architecture validation but should become dirty-line invalidation before larger Markdown features are added.
|
||||
|
||||
Recommended next optimization:
|
||||
|
||||
Track the previous active line and new active line, then restyle only those two lines when the source is unchanged. Source edits can be narrowed to changed line ranges after text-diffing or native edited-range callbacks are introduced.
|
||||
|
||||
## AttributedString and NSAttributedString
|
||||
|
||||
Swift `AttributedString` is useful for renderer-facing APIs and SwiftUI previews.
|
||||
|
|
@ -283,7 +415,7 @@ Open challenges:
|
|||
|
||||
- Multi-line selection across source and rendered lines.
|
||||
- Selection after visually hidden or replaced Markdown delimiters.
|
||||
- Cursor placement around links, emphasis markers, and task markers.
|
||||
- Cursor placement if future phases hide links, list markers, task markers, or other delimiters.
|
||||
- IME composition and marked text handling.
|
||||
- Very large documents where full restyling on each selection change is too expensive.
|
||||
|
||||
|
|
@ -296,8 +428,8 @@ The current spike uses line-level styling, not full Markdown rendering.
|
|||
Validated:
|
||||
|
||||
- Headings can be styled by line.
|
||||
- Emphasis and links can be styled inline.
|
||||
- Task markers can be visually deemphasized.
|
||||
- Bold, italic, and inline code can be styled inline.
|
||||
- Unsupported constructs can remain plain source text without breaking the editor model.
|
||||
- Active source line can remain monospaced and visibly distinct.
|
||||
|
||||
Not validated:
|
||||
|
|
@ -309,7 +441,7 @@ Not validated:
|
|||
|
||||
## Hybrid Editing Feasibility
|
||||
|
||||
Hybrid editing is realistically achievable for Milestone 2 if the first implementation is scoped to line-level attributed styling.
|
||||
Hybrid editing is achievable for Milestone 2 when the first implementation is scoped to line-level attributed styling.
|
||||
|
||||
The safest path is:
|
||||
|
||||
|
|
@ -319,18 +451,17 @@ The safest path is:
|
|||
4. Apply rendered-style attributes to inactive lines.
|
||||
5. Delay true line replacement and overlay rendering until selection behavior is better understood.
|
||||
|
||||
This will not produce perfect rendered Markdown, but it should validate the writing feel and cursor behavior before Sapling invests in a custom editor engine.
|
||||
This does not produce perfect rendered Markdown, but it validates the writing feel and cursor behavior before Sapling invests in a custom editor engine.
|
||||
|
||||
## Recommended Path Forward
|
||||
|
||||
Use `NSTextView` for Milestone 2 on macOS and keep the iOS `UITextView` adapter available behind the same editor abstraction.
|
||||
Continue with `NSTextView` for the next editor milestone on macOS and keep the iOS `UITextView` adapter available behind the same editor abstraction.
|
||||
|
||||
Immediate next steps:
|
||||
|
||||
- Move Markdown styling into a dedicated renderer adapter.
|
||||
- Add incremental line invalidation instead of restyling the whole buffer.
|
||||
- Add tests for line ranges, trailing newlines, and multi-line selections.
|
||||
- Manually test long documents, undo/redo, IME input, and keyboard navigation.
|
||||
- Add automated coverage for multi-line selections and edited-range invalidation.
|
||||
- Manually test long documents, undo/redo, IME input, and keyboard navigation with the prototype files.
|
||||
- Explore delimiter hiding with temporary attributes only after selection mapping is robust.
|
||||
|
||||
Future investigation:
|
||||
|
|
@ -341,10 +472,18 @@ Future investigation:
|
|||
|
||||
## Decision
|
||||
|
||||
`NSTextView` is sufficient for Milestone 2 validation.
|
||||
`NSTextView` can continue to support Sapling's roadmap through the next phase.
|
||||
|
||||
Hybrid editing is achievable, but the near-term implementation should be attribute-based rather than replacement-based.
|
||||
Evidence:
|
||||
|
||||
A future custom editor engine may be required for full rich block rendering, but Sapling should not start there.
|
||||
- The active-line source and inactive-line presentation model works while preserving a single source buffer.
|
||||
- Cursor and selection ranges remain native source ranges because rendering does not replace text.
|
||||
- Scroll position can be preserved around rendering updates.
|
||||
- Redundant render passes are avoided when source text and active line are unchanged.
|
||||
- The large 2,100-line prototype shape is tractable for render planning, though the native attribute pass still needs dirty-line invalidation.
|
||||
|
||||
Sapling should not begin a custom editor engine now. The evidence supports continuing with native text systems while moving toward incremental invalidation and richer overlay experiments.
|
||||
|
||||
A future custom editor engine may still be required for high-fidelity block rendering, especially images, tables, Mermaid, LaTeX, attachments, and fully hidden delimiters. That decision should be made only after overlay rendering and dirty-line attributed rendering have been measured against real documents.
|
||||
|
||||
The architecture should continue with a SwiftUI shell, a Sapling editor abstraction, and native platform text views hidden behind replaceable adapters.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue