208 lines
9.5 KiB
Markdown
208 lines
9.5 KiB
Markdown
|
|
# 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:
|
||
|
|
|
||
|
|
```sh
|
||
|
|
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
|
||
|
|
|
||
|
|
```mermaid
|
||
|
|
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.
|
||
|
|
|
||
|
|
```mermaid
|
||
|
|
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`.
|
||
|
|
|
||
|
|
```mermaid
|
||
|
|
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
|
||
|
|
|
||
|
|
```mermaid
|
||
|
|
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.
|
||
|
|
|
||
|
|
```mermaid
|
||
|
|
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.
|
||
|
|
|