Sapling/Docs/editor-interaction-traces.md

9.5 KiB

Editor Interaction Traces

Date: 2026-05-30

Milestone: 2.8 Editor Scalability

Status: Investigation only. No editor optimization, renderer redesign, or TextKit replacement was performed.

Source Measurements

Measurements come from the release benchmark command:

swift run -c release SaplingEditorBenchmark

The 5 MB benchmark document is Docs/Benchmarks/5mb.md.

Property Value
Bytes 5,526,168
Characters 5,474,683
UTF-16 units 5,526,164
Editor lines 51,482
Rendered/source trace 51,481 rendered, 1 source

Performance budgets used in this trace:

Interaction Target
Typing <16 ms
Cursor movement <16 ms
Selection update <16 ms
Scroll update <16 ms when layout is cached

Document Opening

sequenceDiagram
    participant File as "File system"
    participant VM as "HybridMarkdownEditorViewModel"
    participant State as "EditorState"
    participant Lines as "DocumentLineIndex"
    participant Styler as "MarkdownTextStyler"
    participant TextKit as "NSTextStorage / NSLayoutManager"

    File->>VM: "String(contentsOf:)"
    VM->>State: "EditorState(document:)"
    State->>Lines: "Build all editor lines"
    Lines-->>State: "51,482 lines"
    State->>Styler: "Initial full render plan"
    Styler->>TextKit: "Apply attributes to full storage"
    TextKit->>TextKit: "Generate glyphs and layout"
Stage Time Complexity Scaling dependency
file_read 2.525 ms O(document bytes) Total document
document_parse_editor_state_init 217.066 ms O(document UTF-16 units + lines) Total document
line_model_generation 205.885 ms O(document UTF-16 units + lines) Total document
render_plan_generation_all_lines 611.781 ms O(lines + inline parse work) Total document
attributed_string_generation_initial 787.685 ms O(document UTF-16 units + lines) Total document
text_storage_initial_update 14.469 ms O(document UTF-16 units) Total document
glyph_generation_full_document 200.528 ms O(document UTF-16 units) Total document
layout_generation_full_document 1,314.100 ms O(layout fragments) Total document

Opening is document-bound. The file read is not the bottleneck; editor line generation, render planning, attributed styling, and TextKit full layout are.

Cursor Movement

Cursor movement means a collapsed selection change without source text changes.

sequenceDiagram
    participant User
    participant TextView as "NSTextView"
    participant Adapter as "Coordinator"
    participant Dirty as "EditorDirtyLineInvalidator"
    participant Styler as "MarkdownTextStyler"
    participant VM as "ViewModel binding"
    participant State as "EditorState"

    User->>TextView: "Move insertion point"
    TextView->>Adapter: "textViewDidChangeSelection"
    Adapter->>Dirty: "plan(previousText,currentText,activeLine)"
    Dirty->>Dirty: "Build current lines for full document"
    Dirty-->>Adapter: "0 dirty lines or active-line dirty lines"
    Adapter->>Styler: "Apply only if plan requires styling"
    Adapter->>VM: "parent.selection = newSelection"
    VM->>State: "updateSelection"
    State->>State: "lineIndex + full line rebuild"

Measured cursor-related stages for the 5 MB document:

Stage Time Budget result Complexity Evidence
dirty_line_invalidation_click 224.418 ms over budget O(document) Builds current lines before deciding 0 dirty lines
selection_update 398.262 ms over budget O(document) EditorState.updateSelection rebuilds line model
active_line_lookup 191.751 ms over budget O(document) Midpoint line lookup scans line boundaries
render_update_click_dirty below top-20 threshold within measured threshold O(0) when no dirty lines Render skipped when dirty plan has no styling

Cursor movement is document-bound today. The expensive work happens before Markdown rendering.

Selection Changes

Multi-character and multi-line selection changes use the same path as cursor movement, with a different EditorSelection.length.

flowchart TD
    Selection["Selection range changes"] --> Adapter["Coordinator.textViewDidChangeSelection"]
    Adapter --> Dirty["Dirty invalidation over current full text"]
    Adapter --> Binding["SwiftUI binding update"]
    Binding --> State["EditorState.updateSelection"]
    State --> LineIndex["lineIndex(containing:)"]
    State --> Lines["lines(from:)"]
System Trigger Time Complexity Viewport-bound?
Dirty invalidation Every observed selection change before binding update 224.418 ms click proxy O(document) No
Active-line lookup Every EditorState.updateSelection 191.751 ms standalone O(document) No
Full line rebuild Every EditorState.updateSelection included in 398.262 ms O(document + lines) No

Selection state is correct after Milestone 2.7, but it is not scalable because selection changes are not using cached line boundaries.

Typing

sequenceDiagram
    participant User
    participant TextView as "NSTextView"
    participant Storage as "NSTextStorage"
    participant Adapter as "Coordinator"
    participant Dirty as "EditorDirtyLineInvalidator"
    participant Styler as "MarkdownTextStyler"
    participant Layout as "NSLayoutManager"
    participant VM as "ViewModel binding"
    participant State as "EditorState"

    User->>TextView: "Insert one character"
    TextView->>Storage: "Native text edit"
    TextView->>Adapter: "textDidChange"
    Adapter->>Dirty: "Diff previous/current full text"
    Dirty->>Dirty: "Build current lines and scan prefix/suffix"
    Dirty-->>Adapter: "3 dirty lines"
    Adapter->>Styler: "Style dirty lines"
    Styler->>Styler: "Rebuild full line list before dirty loop"
    Styler->>Layout: "TextKit attributes and local layout"
    Adapter->>VM: "parent.text = textView.string"
    VM->>State: "updateSource"
    State->>State: "lineIndex + full line rebuild"

Measured typing stages for the 5 MB document:

Stage Time Budget result Complexity Scaling dependency
text_storage_typing_insert 0.779 ms within budget local TextKit edit Edited range
dirty_line_invalidation_typing 1,019.380 ms over budget O(document) Total document
render_update_typing_dirty 53.512 ms over budget O(document + dirty lines) today Total document due line-list rebuild
layout_after_typing 0.916 ms within budget local layout after edit Edited range
typing_state_update 416.540 ms over budget O(document) Total document

Typing is not slow because TextKit cannot insert a character. The native insert and post-edit layout are below 1 ms in the benchmark. Typing is slow because Sapling does full-document dirty invalidation, full-document line-list rebuilding in the styler, and full-document EditorState.updateSource.

Scrolling

The benchmark separates cached viewport queries from cold layout-dependent queries.

flowchart TD
    Scroll["Scroll viewport"] --> Query["glyphRange(forBoundingRect:)"]
    Query --> Cached{"Layout already generated?"}
    Cached -->|Yes| Cheap["Cached viewport lookup"]
    Cached -->|No or target beyond cache| Layout["Generate glyph/layout fragments to requested position"]
    Layout --> Result["Return glyph range"]

Measured TextKit scroll and layout proxies:

Stage Time Interpretation
cold_viewport_glyph_range_middle 38.597 ms A viewport query before explicit full layout can force layout work
cold_line_fragment_calculation_midpoint 838.968 ms Mid-document line-fragment lookup before explicit full layout is document-position-bound
layout_generation_full_document 1,314.100 ms Full TextKit layout is the largest single measured stage
glyph_generation_full_document 200.528 ms Full glyph generation is document-bound
layout_after_typing 0.916 ms Local post-edit layout is cheap after CRLF correctness
Cached scroll latency measurements below top-20 threshold for 5 MB Cached viewport queries are not the measured bottleneck

The harness does not prove live scroll-wheel latency in the app. It does prove that TextKit can answer cached viewport queries cheaply, while cold or deep layout-dependent queries can scale with document position and total layout work.

Interaction Summary

Interaction Dominant measured work Current scaling Meets 16 ms budget?
Document open Full styling, full render planning, full TextKit layout Total document No budget set; multi-second
Typing Dirty invalidation + source update + dirty styling setup Total document No
Cursor movement Selection update + dirty plan + active-line lookup Total document No
Selection changes Same as cursor movement Total document No
Cached scrolling TextKit cached glyph-range lookup Viewport/cached layout Yes in harness
Cold/deep layout during scroll TextKit layout to target position Document position / layout fragments No

Scalability Conclusion

The current editor is correct after Milestone 2.7, but most editor-originated interaction work remains document-bound. TextKit is a major open and cold-layout cost, but the measured typing and cursor bottlenecks are primarily Sapling's repeated full-document line indexing, dirty invalidation, and state rebuilding.