Sapling/Docs/editor-scalability-roadmap.md

9.7 KiB

Editor Scalability Roadmap

Date: 2026-05-30

Milestone: 2.8 Editor Scalability

Status: Recommendations only. No optimization was implemented in this milestone.

Decision Context

D-013 remains valid: NSTextView is still a viable editor foundation. Milestone 2.7 restored correctness by replacing raw Swift Character("\n") scanning with DocumentLineIndex, so the 5 MB benchmark now segments into 51,482 editor lines and hybrid rendering works.

Milestone 2.8 shows that correctness did not make the editor responsive enough. The remaining problem is algorithmic shape: interaction paths still include multiple O(document) operations.

Ranking Method

Recommendations are ranked by:

  • measured impact on the 5 MB benchmark
  • whether the work directly affects typing, cursor movement, selection, or scrolling
  • implementation complexity and correctness risk
  • fit with D-013 and the current NSTextView foundation

Ranked Opportunities

Rank Opportunity Expected impact Complexity Risk Evidence
1 Cache and incrementally update DocumentLineIndex inside editor state Very high for cursor, selection, typing Medium Medium selection_update 398.262 ms, typing_state_update 416.540 ms, active_line_lookup 191.751 ms
2 Replace full-string dirty diffing with edited-range driven invalidation Very high for typing Medium Medium dirty_line_invalidation_typing 1,019.380 ms, dirty_line_invalidation_click 224.418 ms
3 Make dirty styling consume cached line boundaries High for typing and active-line changes Low-medium Low-medium render_update_typing_dirty 53.512 ms for only 3 styled lines because line list is rebuilt first
4 Cache render plans by line identity and invalidate by dirty line High for opening and active-line transitions Medium Medium Full render-plan generation is 611.781 ms
5 Reduce forced TextKit full-layout work High for open and cold/deep scroll positions High Medium-high layout_generation_full_document 1,314.100 ms and cold midpoint line fragment 838.968 ms
6 Introduce viewport-bounded styling/render scheduling Potentially very high for 100k+ lines High High Current styling and render planning are document-bound on initial load
7 Add visible-app scroll and Time Profiler traces before layout changes High diagnostic value Medium Low Benchmark does not reproduce live scroll-wheel latency

1. Cached Incremental Line Index

Current behavior:

flowchart TD
    Selection["Selection or source change"] --> Lookup["lineIndex(containing:)"]
    Lookup --> Scan["Scan source with DocumentLineIndex"]
    Selection --> Lines["lines(from:)"]
    Lines --> Rebuild["Rebuild all EditorLine values"]

Target behavior:

flowchart TD
    Source["Source edit"] --> Index["Persistent DocumentLineIndex"]
    Index --> Incremental["Update affected boundaries"]
    Selection["Selection change"] --> Query["Query cached boundaries"]
    Query --> Active["Active line in O(log lines) or O(1) with cursor locality"]

Expected impact:

  • Cursor movement should stop paying the 191.751 ms active-line scan and most of the 398.262 ms selection update.
  • Typing should avoid rebuilding 51,482 EditorLine values when the edit affects one line.

Complexity:

  • Medium. The editor needs a single owned line index and invalidation rules for insertions, deletions, and line-ending changes.

Risk:

  • Medium. Milestone 2.7 showed newline handling is correctness-critical. The cached index must preserve LF, CRLF, CR, mixed endings, and trailing blank lines.

2. Edited-Range Dirty Invalidation

Current behavior:

flowchart TD
    Edit["One-character edit"] --> Diff["Compare previous/current full strings"]
    Diff --> Prefix["Scan common prefix"]
    Diff --> Suffix["Scan common suffix"]
    Diff --> Lines["Rebuild current full line list"]
    Lines --> Dirty["Return 3 dirty lines"]

Target behavior:

flowchart TD
    TextKit["Native edited range"] --> Index["Cached line index"]
    Index --> Dirty["Affected line plus neighbors"]

Expected impact:

  • Directly targets the largest typing-stage cost: 1,019.380 ms.
  • Also removes the 224.418 ms click-path dirty-plan cost when the source did not change.

Complexity:

  • Medium. NSTextView/NSTextStorage already knows edited ranges, but the adapter needs to pass them through cleanly without destabilizing SwiftUI bindings.

Risk:

  • Medium. Invalidation must still include neighboring lines when Markdown interpretation can be affected by adjacent content.

3. Dirty Styling With Cached Line Boundaries

Current behavior:

MarkdownTextStyler.apply receives a dirty-line plan, but it first calls EditorActiveLineTracker.lines(from:) across the full text. The 5 MB typing path styles only 3 lines but still costs 53.512 ms.

Target behavior:

  • Resolve dirty line indexes through the cached line index.
  • Avoid constructing EditorLine values for lines outside the dirty set.

Expected impact:

  • Should bring dirty styling closer to the local TextKit edit and layout costs, which are below 1 ms in the benchmark.

Complexity:

  • Low-medium if implemented after cached line index ownership exists.

Risk:

  • Low-medium. Rendering behavior should remain unchanged if line ranges come from the same DocumentLineIndex.

4. Render Plan Cache

Current behavior:

Full render-plan generation across 51,482 lines costs 611.781 ms. Dirty rendering currently runs render plans only for styled lines, but initial render and future full refreshes remain document-bound.

Target behavior:

  • Cache HybridMarkdownLineRenderPlan by line range/content generation.
  • Recompute plans only for dirty lines and neighboring affected lines.

Expected impact:

  • Improves initial refreshes, active-line transitions, and any future operation that currently schedules broad render planning.

Complexity:

  • Medium. Needs cache invalidation tied to source edits and line identity.

Risk:

  • Medium. Incorrect cache identity could show stale Markdown styling.

5. TextKit Full-Layout Reduction

Evidence:

Operation Time
layout_generation_full_document 1,314.100 ms
cold_line_fragment_calculation_midpoint 838.968 ms
glyph_generation_full_document 200.528 ms
cold_viewport_glyph_range_middle 38.597 ms
layout_after_typing 0.916 ms

Current behavior:

The benchmark explicitly forces full layout to measure worst-case TextKit cost. Cached viewport queries are cheap, but cold/deep layout queries can require significant work.

Potential directions:

  • Avoid explicit full-document ensureLayout in interaction paths.
  • Confirm whether the live app forces layout via scrollRangeToVisible, selection restoration, status updates, accessibility, or full attribute changes.
  • Use Instruments or a visible AppKit scroll benchmark before changing layout strategy.

Expected impact:

  • High for document open, cold scroll to deep positions, and any operation that invalidates broad layout.

Complexity:

  • High because TextKit behavior is platform-managed and easy to perturb.

Risk:

  • Medium-high. Selection stability and scroll preservation depend on native TextKit behavior.

6. Viewport-Bounded Styling And Rendering

Current behavior:

flowchart TD
    Source["Full document"] --> Lines["All editor lines"]
    Lines --> Plans["All render plans on full render"]
    Lines --> Styling["Styling receives full line list"]
    Styling --> TextKit["TextStorage attributes"]
    TextKit --> Viewport["Viewport"]

Potential target:

flowchart TD
    Source["Full document"] --> Index["Cached line index"]
    Viewport["Visible rect + buffer"] --> VisibleLines["Visible line ranges"]
    Dirty["Dirty edit ranges"] --> WorkSet["Visible lines union dirty lines union active line"]
    Index --> WorkSet
    WorkSet --> Styling["Bounded styling/render planning"]

Expected impact:

  • Potentially the largest improvement for 100k+ line documents.

Complexity:

  • High. Requires careful coordination among visible range calculation, active line rendering, offscreen selection, search, accessibility, and full-document export/print scenarios.

Risk:

  • High. This should follow lower-risk line-index and dirty-invalidation work, not precede it.

7. Visible-App Scroll And Time Profiler Traces

The benchmark proves that cached glyph-range lookup is cheap and cold layout can be expensive. It does not prove the exact source of subjective live scroll slowness.

Next measurement work:

  • capture a visible Sapling scroll trace for the 5 MB document
  • rank CPU stacks during scroll-wheel events
  • determine whether scrolling triggers selection updates, styling, layout invalidation, drawing, or accessibility work

Expected impact:

  • High diagnostic value before any TextKit-level changes.

Complexity:

  • Medium because it requires an app-level profiling workflow.

Risk:

  • Low. This is measurement-only.

Recommendation

The next implementation milestone should begin with cached, incrementally updated line indexing and edited-range dirty invalidation. These target the largest interaction costs without violating D-013 or replacing NSTextView.

Viewport-only styling and TextKit layout strategy changes should wait until after the document-bound editor-side scans are removed. Otherwise TextKit measurements will stay mixed with avoidable Sapling work.

100k+ Line Assessment

The current architecture cannot feel responsive at 100k+ lines while cursor movement, selection updates, typing updates, dirty invalidation, and dirty styling setup remain O(document). NSTextView is not disproven by the current data, but Sapling's current interaction pipeline must become line-index cached and edited-range driven before 100k+ line support is realistic.