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.