# 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:

```mermaid
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:

```mermaid
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:

```mermaid
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:

```mermaid
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:

```mermaid
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:

```mermaid
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.

