From 54792e86bf18a1dd495675fdb5359bb5ca8e28ec Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Tue, 7 Jul 2026 09:56:20 +0200 Subject: [PATCH 01/39] chore: temporary PerfTrace typing instrumentation (Debug-only) Prints one line per keystroke with a per-phase breakdown (wiki, backtick, parse, activeTok, latexMap, restyle, codeSel, overscroll) plus the document length, so typing costs that scale with file size become visible. Deep notes count wasted layout fragments and re-rendered tables. Debug-only (compiled out in Release; silence with env MD_PERF=0). Remove after the perf work lands. Co-Authored-By: Claude Fable 5 --- .../Diagnostics/PerfTrace.swift | 81 +++++++++++++++++++ .../Renderer/WideTableOverlay.swift | 4 + .../Styling/MarkdownStyler+Tables.swift | 9 +++ ...tiveTextViewCoordinator+TextDelegate.swift | 51 +++++++----- .../NativeTextView+FrameAndOverscroll.swift | 6 +- 5 files changed, 130 insertions(+), 21 deletions(-) create mode 100644 Sources/MarkdownEngine/Diagnostics/PerfTrace.swift diff --git a/Sources/MarkdownEngine/Diagnostics/PerfTrace.swift b/Sources/MarkdownEngine/Diagnostics/PerfTrace.swift new file mode 100644 index 00000000..c7d032eb --- /dev/null +++ b/Sources/MarkdownEngine/Diagnostics/PerfTrace.swift @@ -0,0 +1,81 @@ +// +// PerfTrace.swift +// MarkdownEngine +// +// Created by Luca Chen on 07.07.26. +// +// TEMP diagnostics (typing performance). Prints one compact line per keystroke +// with a per-phase breakdown plus the current document length, so we can see +// which costs grow with file size instead of staying constant. The whole point: +// type in a short file, then a long one, and compare `total` for the same edit. +// +// Toggle: set the env var MD_PERF=0 in the run scheme to silence. +// Debug-only — the whole thing compiles out in Release. +// Remove before shipping (this file + the `PerfTrace.` call sites). +// + +import Foundation + +enum PerfTrace { +#if DEBUG + static var enabled = ProcessInfo.processInfo.environment["MD_PERF"] != "0" +#else + static let enabled = false +#endif + + // All call sites run on the main thread (the coordinator + text view are + // main-actor), so plain static state is safe under the package's Swift 5 mode. + private static var active = false + private static var frameStart: UInt64 = 0 + private static var docLength = 0 + private static var phases: [(String, Double)] = [] + private static var notes: [String] = [] + + private static func nowMs() -> Double { + Double(DispatchTime.now().uptimeNanoseconds) / 1_000_000 + } + + /// Open a per-keystroke frame. Every `measure`/`note` until `end()` attaches to it. + static func begin(docLength len: Int) { + guard enabled else { return } + active = true + docLength = len + phases.removeAll(keepingCapacity: true) + notes.removeAll(keepingCapacity: true) + frameStart = DispatchTime.now().uptimeNanoseconds + } + + /// Time one sequential top-level phase of the current frame. + @discardableResult + static func measure(_ label: String, _ body: () -> T) -> T { + guard enabled, active else { return body() } + let t0 = nowMs() + let result = body() + phases.append((label, nowMs() - t0)) + return result + } + + /// Attach a free-form detail line (e.g. how many tables were re-rendered). + /// The closure only runs when tracing is active, so it costs nothing when off. + static func note(_ make: () -> String) { + guard enabled, active else { return } + notes.append(make()) + } + + /// Close the frame and print total + per-phase breakdown + notes. + static func end() { + guard enabled, active else { return } + active = false + let total = Double(DispatchTime.now().uptimeNanoseconds - frameStart) / 1_000_000 + let breakdown = phases.map { String(format: "%@=%.2f", $0.0, $0.1) }.joined(separator: " ") + print(String(format: "⌨️ PERF doc=%dch total=%.2fms | %@", docLength, total, breakdown)) + for note in notes { print(" └─ \(note)") } + } + + /// Standalone timing print for a cost that runs *outside* the keystroke frame + /// (e.g. the async wide-table overlay reconcile fired after the edit settles). + static func stamp(_ label: String, _ ms: Double, _ detail: @autoclosure () -> String = "") { + guard enabled else { return } + print(String(format: "⏱️ PERF %@ %.2fms %@", label, ms, detail())) + } +} diff --git a/Sources/MarkdownEngine/Renderer/WideTableOverlay.swift b/Sources/MarkdownEngine/Renderer/WideTableOverlay.swift index d6b18d23..03c48712 100644 --- a/Sources/MarkdownEngine/Renderer/WideTableOverlay.swift +++ b/Sources/MarkdownEngine/Renderer/WideTableOverlay.swift @@ -244,7 +244,11 @@ extension NativeTextView { } // Settle layout before measuring — stale fragments would yield wrong anchor Ys. + let overlayT0 = DispatchTime.now().uptimeNanoseconds tlm.ensureLayout(for: tlm.documentRange) + PerfTrace.stamp("wideTableOverlay.ensureLayout(fullDoc)", + Double(DispatchTime.now().uptimeNanoseconds - overlayT0) / 1_000_000, + "docLen=\(storage.length)") storage.enumerateAttribute(.scrollableBlockSourceID, in: fullRange, options: []) { value, attrRange, _ in guard let sourceID = value as? Int, diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift index 8fb543d0..7f8b3843 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift @@ -29,7 +29,11 @@ extension MarkdownStyler { var attrs: [StyledRange] = [] // Per-content occurrence counter so identical tables get distinct sourceIDs. var occurrenceByContentHash: [Int: Int] = [:] + var tableCount = 0 + var renderedCount = 0 + let tablesT0 = DispatchTime.now().uptimeNanoseconds for (idx, token) in ctx.tokens.enumerated() where token.kind == .table { + tableCount += 1 // Tokenizer already drops tables overlapping fenced code, so no re-check here. attrs.append((token.range, [.spellingState: 0])) @@ -70,6 +74,7 @@ extension MarkdownStyler { latex: ctx.services.latex, appearance: renderAppearance ) + renderedCount += 1 let imageBounds = CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height) // Wide tables → scrollable mode (NSScrollView overlay); narrow → collapsed. let containerWidth = effectiveContainerWidth(for: ctx) @@ -98,6 +103,10 @@ extension MarkdownStyler { attrs: &attrs ) } + if tableCount > 0 { + let ms = Double(DispatchTime.now().uptimeNanoseconds - tablesT0) / 1_000_000 + PerfTrace.note { "styleTables scanned=\(tableCount) tables, re-rendered=\(renderedCount) NSImage in \(String(format: "%.2f", ms))ms" } + } return attrs } diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift index a52985f9..9f2c50be 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift @@ -108,12 +108,15 @@ extension NativeTextViewCoordinator { let safeLocation = min(rawSelRange.location, fullLength) let safeSelRange = NSRange(location: safeLocation, length: 0) previousCaretLocation = safeSelRange.location + PerfTrace.begin(docLength: fullLength) if !wtActive { - let storageState = WikiLinkService.makeStorageState( - from: tv.string, - existingMetadata: self.wikiLinkMetadata, - textStorage: tv.textStorage - ) + let storageState = PerfTrace.measure("wiki") { + WikiLinkService.makeStorageState( + from: tv.string, + existingMetadata: self.wikiLinkMetadata, + textStorage: tv.textStorage + ) + } self.wikiLinkMetadata = storageState.metadata if storageState.storage != self.lastSyncedText { DispatchQueue.main.async { @@ -153,11 +156,11 @@ extension NativeTextViewCoordinator { nextParagraph ] + editedParagraphs - let backtickCount = tv.string.components(separatedBy: "```").count - 1 + let backtickCount = PerfTrace.measure("backtick") { tv.string.components(separatedBy: "```").count - 1 } let codeBlockStructureChanged = backtickCount != previousBacktickCount previousBacktickCount = backtickCount - let parsed = parsedDocument(for: tv.string) + let parsed = PerfTrace.measure("parse") { parsedDocument(for: tv.string) } let tokens = parsed.tokens let codeTokens = parsed.codeTokens let latexTokens = parsed.latexTokens @@ -165,12 +168,14 @@ extension NativeTextViewCoordinator { let preEditActiveTokenIndices = pendingPreEditActiveTokenIndices ?? previousActiveTokenIndices pendingPreEditActiveTokenIndices = nil - activeTokenIndices = MarkdownDetection.computeActiveTokenIndices( - selectionRange: safeSelRange, - tokens: tokens, - in: fullText, - suppressed: !tv.isEditable - ) + activeTokenIndices = PerfTrace.measure("activeTok") { + MarkdownDetection.computeActiveTokenIndices( + selectionRange: safeSelRange, + tokens: tokens, + in: fullText, + suppressed: !tv.isEditable + ) + } filterImageEmbedActiveTokens(parsed: parsed, text: fullText, selectionLocation: safeSelRange.location) updateAutocorrectSettings( tv, @@ -185,7 +190,9 @@ extension NativeTextViewCoordinator { effectiveParagraphCandidates = [NSRange(location: 0, length: fullText.length)] } // Always restyle paragraphs containing latex/imageEmbed tokens to avoid stale raw text. - let latexParagraphs = (latexTokens + blockLatexTokens + parsed.imageEmbedTokens).map { fullText.paragraphRange(for: $0.range) } + let latexParagraphs = PerfTrace.measure("latexMap") { + (latexTokens + blockLatexTokens + parsed.imageEmbedTokens).map { fullText.paragraphRange(for: $0.range) } + } effectiveParagraphCandidates.append(contentsOf: latexParagraphs) // A table renders as ONE image anchored on the block's FIRST paragraph. // When an edit touches any of its rows (typing in a row, or a paste @@ -203,18 +210,22 @@ extension NativeTextViewCoordinator { previousActiveTokenIndices: preEditActiveTokenIndices )) - restyleTextView(tv, paragraphCandidates: effectiveParagraphCandidates, tokens: tokens) - updateCodeBlockSelection(textView: tv, tokens: tokens) + PerfTrace.measure("restyle") { restyleTextView(tv, paragraphCandidates: effectiveParagraphCandidates, tokens: tokens) } + PerfTrace.measure("codeSel") { updateCodeBlockSelection(textView: tv, tokens: tokens) } if wtActive { previousActiveTokenIndices = activeTokenIndices + PerfTrace.end() return } - if let bottomTextView = tv as? NativeTextView, - let scrollView = tv.enclosingScrollView { - bottomTextView.recalcOverscroll(for: scrollView, debugTag: "textDidChange") - (scrollView as? ClampedScrollView)?.clampToInsets() + PerfTrace.measure("overscroll") { + if let bottomTextView = tv as? NativeTextView, + let scrollView = tv.enclosingScrollView { + bottomTextView.recalcOverscroll(for: scrollView, debugTag: "textDidChange") + (scrollView as? ClampedScrollView)?.clampToInsets() + } } previousActiveTokenIndices = activeTokenIndices + PerfTrace.end() } public func textViewDidChangeSelection(_ notification: Notification) { diff --git a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift index 3782ef0f..51bd48d3 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift @@ -402,11 +402,15 @@ extension NativeTextView { guard let tlm = textLayoutManager else { return } let visTop = visibleRect.minY let visBot = visibleRect.maxY + var walked = 0 + var aboveViewport = 0 tlm.enumerateTextLayoutFragments(from: tlm.documentRange.location, options: [.ensuresLayout]) { fragment in + walked += 1 let fr = fragment.layoutFragmentFrame - if fr.maxY < visTop { return true } + if fr.maxY < visTop { aboveViewport += 1; return true } if fr.minY > visBot { return false } return true } + PerfTrace.note { "ensureVisibleLayout walked=\(walked) frags, \(aboveViewport) above viewport (wasted)" } } } From b39f61cc31e8509f1adc7a118459e8fb686d9ce6 Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Tue, 7 Jul 2026 09:56:30 +0200 Subject: [PATCH 02/39] docs: typing-perf hot-path implementation plan Six-task TDD plan to make per-keystroke cost O(1) in document length, based on the multi-agent audit + measured PerfTrace baseline (139k doc ~12.8ms/key). Note: /docs is gitignored in this repo; force-added onto this feature branch per request. Drop before merging to main if the convention should hold. Co-Authored-By: Claude Fable 5 --- .../plans/2026-07-07-typing-perf-hotpath.md | 963 ++++++++++++++++++ 1 file changed, 963 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-07-typing-perf-hotpath.md diff --git a/docs/superpowers/plans/2026-07-07-typing-perf-hotpath.md b/docs/superpowers/plans/2026-07-07-typing-perf-hotpath.md new file mode 100644 index 00000000..257828a1 --- /dev/null +++ b/docs/superpowers/plans/2026-07-07-typing-perf-hotpath.md @@ -0,0 +1,963 @@ +# Typing-Performance Hot-Path Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Cut the per-keystroke cost that scales with document length, so typing latency stays (near-)constant regardless of file size. + +**Architecture:** The engine's per-keystroke path is `NativeTextViewCoordinator.textDidChange` → wiki-link storage sync → backtick census → `parsedDocument` (tokenize) → paragraph-scoped restyle → `ensureVisibleLayout`. Measured with PerfTrace (Debug build): a 139k-char doc costs ~12.8 ms/keystroke vs ~1.1 ms for a 456-char doc; a doc with 10 tables costs 13–28 ms because every table re-renders to NSImage on every keystroke. Each task below removes one measured O(doc) cost with a bounded, fallback-guarded replacement. No behavioral changes intended — every task must leave `swift test` green. + +**Tech Stack:** Swift 5.9 SPM package (`swift build` / `swift test`), AppKit NSTextView + TextKit 2, Swift Testing (`import Testing`, `@Suite`/`@Test`/`#expect`) for tests. + +## Global Constraints + +- Repo: `/Users/lucachen/Desktop/swift-markdown-engine` (the `~/Documents/GitHub` clone is stale — never touch it). +- Work happens on branch `perf/typing-hotpath`, created from `main` in Task 0. Commit per task; **never push** and never tag/release — Luca releases manually after sign-off. +- Every **new** source file starts with the Xcode header comment: `// .swift` / `// MarkdownEngine` / `// Created by Luca Chen on 07.07.26.` +- The engine ships API only — no UI components. +- Build check: `swift build` → `Build complete!`. Test check: `swift test` → all suites pass. SourceKit "cannot find type" diagnostics in extensions are known false positives — trust `swift build` only. +- End commit messages with `Co-Authored-By: Claude Fable 5 `. +- The Nodes app repo (`~/Documents/GitHub/Nodes`) currently points at this local engine checkout via an uncommitted pbxproj override — needed for live measurement in Task 6. Do not commit anything in the app repo. +- PerfTrace instrumentation (Debug-only) stays in place through all tasks — Task 6 uses it for before/after numbers. Its eventual removal is a separate post-sign-off step. +- All measured numbers are Debug (`-Onone`); ratios are meaningful, absolute times are inflated. Algorithmic fixes here are build-independent. + +**Measured baseline (2026-07-07, Debug):** + +| Scenario | total/keystroke | dominant phases | +|---|---|---| +| 139k chars, plain | ~12.8 ms | parse 6.4, wiki 3.4, restyle 1.7, backtick 0.75 | +| 456 chars | ~1.1 ms | restyle ~1.0 | +| 7.9k chars, 10 tables | 13–28 ms | restyle 12–26 (styleTables 7–17, all 10 tables re-rendered), ensureVisibleLayout walks 130 frags (115 wasted) | + +--- + +### Task 0: Branch setup + commit the PerfTrace instrumentation + +**Files:** +- No source changes; git only. The working tree currently sits on `pr/87` with uncommitted edits to 4 files + the new `Sources/MarkdownEngine/Diagnostics/PerfTrace.swift`. None of these files differ between `pr/87` and `main` (verified via `git diff main...HEAD --name-only`), so the edits carry across the switch. + +**Interfaces:** +- Produces: branch `perf/typing-hotpath` (from `main`) containing the committed PerfTrace instrumentation that all later tasks build on and Task 6 measures with. + +- [ ] **Step 1: Switch to main and branch** + +```bash +cd /Users/lucachen/Desktop/swift-markdown-engine +git fetch origin +git switch main +git pull --ff-only +git switch -c perf/typing-hotpath +``` + +Expected: switch succeeds with "M" markers for the 4 modified files (edits carried over), no conflicts. + +- [ ] **Step 2: Build to verify the carried edits compile on main** + +Run: `swift build` +Expected: `Build complete!` + +- [ ] **Step 3: Commit the instrumentation** + +```bash +git add Sources/MarkdownEngine/Diagnostics/PerfTrace.swift \ + Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift \ + Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift \ + Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift \ + Sources/MarkdownEngine/Renderer/WideTableOverlay.swift +git commit -m "chore: temporary PerfTrace typing instrumentation (Debug-only) + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 1: Cache rendered table images (biggest measured win: 7–17 ms → ~0 in table docs) + +**Files:** +- Modify: `Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift` (styleTables ~line 28–110) +- Test: Create `Tests/MarkdownEngineTests/TableImageCacheTests.swift` + +**Interfaces:** +- Consumes: existing `renderTable(_:baseFont:theme:codeBackgroundColor:latex:appearance:)` and `parseTableSource(_:) -> ParsedTable?` in the same file; `MarkdownStyler.StylingContext` (fields: `baseFont`, `codeBackgroundColor`, `configuration`, `services`). +- Produces: `static func tableImage(for source: String, parsed: ParsedTable, ctx: StylingContext, appearance: NSAppearance) -> (image: NSImage, rendered: Bool)` on `MarkdownStyler` — `rendered == false` means cache hit. + +**Why:** `styleTables` currently calls `renderTable` for **every inactive table in the document on every keystroke** (PerfTrace: `scanned=10, re-rendered=10 NSImage in 7–17ms`). Table content only changes when its source text changes — a source-keyed cache makes steady-state typing free. + +- [ ] **Step 1: Write the failing test** + +Create `Tests/MarkdownEngineTests/TableImageCacheTests.swift`: + +```swift +// +// TableImageCacheTests.swift +// MarkdownEngine +// +// Created by Luca Chen on 07.07.26. +// + +import AppKit +import Foundation +import Testing +@testable import MarkdownEngine + +@Suite("Table image cache") +struct TableImageCacheTests { + + private func makeContext(for source: String) -> MarkdownStyler.StylingContext { + let font = NSFont.systemFont(ofSize: 15) + return MarkdownStyler.StylingContext( + nsText: source as NSString, + tokens: [], + codeTokens: [], + activeTokenIndices: [], + baseFont: font, + layoutBridge: nil, + baseDefaultLineHeight: 18, + codeBackgroundColor: .windowBackgroundColor, + latexMarkerFont: font, + configuration: .default, + wikiLinkIDProvider: { _ in nil } + ) + } + + @Test func secondRequestIsServedFromCache() throws { + let source = "| alpha | beta |\n|---|---|\n| 1 | 2 |" + let parsed = try #require(MarkdownStyler.parseTableSource(source)) + let ctx = makeContext(for: source) + let aqua = try #require(NSAppearance(named: .aqua)) + + let first = MarkdownStyler.tableImage(for: source, parsed: parsed, ctx: ctx, appearance: aqua) + let second = MarkdownStyler.tableImage(for: source, parsed: parsed, ctx: ctx, appearance: aqua) + + #expect(first.rendered) + #expect(!second.rendered) + #expect(first.image === second.image) + } + + @Test func appearanceChangeRendersFresh() throws { + let source = "| gamma | delta |\n|---|---|\n| 3 | 4 |" + let parsed = try #require(MarkdownStyler.parseTableSource(source)) + let ctx = makeContext(for: source) + let aqua = try #require(NSAppearance(named: .aqua)) + let dark = try #require(NSAppearance(named: .darkAqua)) + + _ = MarkdownStyler.tableImage(for: source, parsed: parsed, ctx: ctx, appearance: aqua) + let darkResult = MarkdownStyler.tableImage(for: source, parsed: parsed, ctx: ctx, appearance: dark) + + #expect(darkResult.rendered) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `swift test --filter TableImageCacheTests 2>&1 | tail -5` +Expected: FAIL / compile error — `tableImage(for:parsed:ctx:appearance:)` does not exist. + +- [ ] **Step 3: Implement the cache** + +In `Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift`, add inside `extension MarkdownStyler` (above `styleTables`): + +```swift + /// Rendered-table image cache. A table's pixels depend only on its source, + /// font, colors, and appearance — so identical keys can reuse the NSImage + /// instead of re-rendering every inactive table on every keystroke. + private static let tableImageCache: NSCache = { + let cache = NSCache() + cache.countLimit = 128 + return cache + }() + + /// Returns the rendered image for `source`, from cache when possible. + /// `rendered` is true only when a fresh render actually happened. + static func tableImage( + for source: String, + parsed: ParsedTable, + ctx: StylingContext, + appearance: NSAppearance + ) -> (image: NSImage, rendered: Bool) { + let key = "\(ctx.baseFont.fontName)|\(ctx.baseFont.pointSize)|\(appearance.name.rawValue)|\(ctx.configuration.theme.bodyText)|\(ctx.codeBackgroundColor)|\(source)" as NSString + if let cached = tableImageCache.object(forKey: key) { + return (cached, false) + } + let image = renderTable( + parsed, + baseFont: ctx.baseFont, + theme: ctx.configuration.theme, + codeBackgroundColor: ctx.codeBackgroundColor, + latex: ctx.services.latex, + appearance: appearance + ) + tableImageCache.setObject(image, forKey: key) + return (image, true) + } +``` + +Then in `styleTables`, replace the direct render call: + +```swift + // See renderTable: resolve table colors under the text view's real appearance. + let renderAppearance = ctx.layoutBridge?.firstTextContainer?.textView?.effectiveAppearance + ?? NSApp.effectiveAppearance + let image = renderTable( + parsed, + baseFont: ctx.baseFont, + theme: ctx.configuration.theme, + codeBackgroundColor: ctx.codeBackgroundColor, + latex: ctx.services.latex, + appearance: renderAppearance + ) + renderedCount += 1 + let imageBounds = CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height) +``` + +with: + +```swift + // See renderTable: resolve table colors under the text view's real appearance. + let renderAppearance = ctx.layoutBridge?.firstTextContainer?.textView?.effectiveAppearance + ?? NSApp.effectiveAppearance + let (image, rendered) = tableImage( + for: source, + parsed: parsed, + ctx: ctx, + appearance: renderAppearance + ) + if rendered { renderedCount += 1 } + let imageBounds = CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height) +``` + +(The existing PerfTrace note line stays as is — after this task, steady-state typing should print `re-rendered=0`.) + +- [ ] **Step 4: Run tests** + +Run: `swift test --filter TableImageCacheTests 2>&1 | tail -5` → PASS. +Run: `swift test 2>&1 | tail -5` → all suites pass (table rendering behavior unchanged). + +- [ ] **Step 5: Commit** + +```bash +git add Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift Tests/MarkdownEngineTests/TableImageCacheTests.swift +git commit -m "perf(tables): cache rendered table images keyed by source+font+appearance + +Every keystroke re-rendered every inactive table to a fresh NSImage +(7-17ms with 10 tables). Table pixels depend only on source, font, +colors and appearance, so identical keys now reuse the cached image. + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 2: Incremental wiki-link storage sync (3.4 ms → ~0.1 ms at 139k) + +**Files:** +- Modify: `Sources/MarkdownEngine/Services/WikiLinkService.swift` (add one function after `makeStorageState`, ~line 190) +- Modify: `Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift` (add 2 stored properties near line 88) +- Modify: `Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift` (`textDidChange`, ~lines 105–137) +- Modify: `Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift` (`rebuildTextStorageAndStyle`, seed the new state, ~line 39) +- Test: Create `Tests/MarkdownEngineTests/WikiLinkIncrementalTests.swift` + +**Interfaces:** +- Consumes: `WikiLinkService.RangeKey` (`.location`, `.length`, `init(_ NSRange)`), `WikiLinkService.LinkMetadata` (`.id`, `.storageRange`, public init), `WikiLinkService.makeStorageState(from:existingMetadata:textStorage:)` as the fallback; coordinator vars `lastSyncedText`, `wikiLinkMetadata`, `pendingEditedRange`. +- Produces: `WikiLinkService.updatedStorageState(displayText:editedRange:changeInLength:previousStorage:previousMetadata:) -> (storage: String, metadata: [RangeKey: LinkMetadata])?` — nil means "fall back to full rebuild". New coordinator vars `previousDisplayLength: Int`, `lastComputedStorage: String`, `wikiVerifyCounter: UInt` that Task 6 relies on being maintained. + +**Why:** `makeStorageState` rescans and rebuilds the **entire** document string on every keystroke as soon as one `[[` exists anywhere (measured 3.4 ms at 139k). Outside link syntax, display text and storage text are byte-identical — a keystroke that doesn't touch link syntax can be spliced into the previous storage string in O(edit + #links), with all link ranges after the edit shifted by the length delta. + +- [ ] **Step 1: Write the failing tests** + +Create `Tests/MarkdownEngineTests/WikiLinkIncrementalTests.swift`: + +```swift +// +// WikiLinkIncrementalTests.swift +// MarkdownEngine +// +// Created by Luca Chen on 07.07.26. +// + +import Foundation +import Testing +@testable import MarkdownEngine + +@Suite("WikiLink incremental storage state") +struct WikiLinkIncrementalTests { + + /// storage: "Hello [[Note|abc123]] world" → display: "Hello [[Note]] world" + /// display link range {6, 8}, storage link range {6, 15}. + private func seed() -> (display: String, storage: String, + meta: [WikiLinkService.RangeKey: WikiLinkService.LinkMetadata]) { + let storage = "Hello [[Note|abc123]] world" + let state = WikiLinkService.makeDisplayState(from: storage) + return (state.display, storage, state.metadata) + } + + @Test func appendAfterLinkSplicesStorage() throws { + let (display, storage, meta) = seed() + let newDisplay = display + "x" // typed "x" at the end + let result = try #require(WikiLinkService.updatedStorageState( + displayText: newDisplay, + editedRange: NSRange(location: (display as NSString).length, length: 1), + changeInLength: 1, + previousStorage: storage, + previousMetadata: meta + )) + #expect(result.storage == "Hello [[Note|abc123]] worldx") + // Link before the edit: metadata unchanged. + let key = try #require(result.metadata.keys.first) + #expect(key.location == 6 && key.length == 8) + #expect(result.metadata[key]?.id == "abc123") + #expect(result.metadata[key]?.storageRange == NSRange(location: 6, length: 15)) + } + + @Test func insertBeforeLinkShiftsMetadata() throws { + let (display, storage, meta) = seed() + let newDisplay = "x" + display // typed "x" at position 0 + let result = try #require(WikiLinkService.updatedStorageState( + displayText: newDisplay, + editedRange: NSRange(location: 0, length: 1), + changeInLength: 1, + previousStorage: storage, + previousMetadata: meta + )) + #expect(result.storage == "xHello [[Note|abc123]] world") + let key = try #require(result.metadata.keys.first) + #expect(key.location == 7 && key.length == 8) // shifted by +1 + #expect(result.metadata[key]?.storageRange == NSRange(location: 7, length: 15)) + #expect(result.metadata[key]?.id == "abc123") + } + + @Test func deletionInPlainTextWorks() throws { + let (display, storage, meta) = seed() + // Delete the "l" of "world" (display location 18 — far enough from the + // link that neither the ±3 probe nor the metadata guard band trips). + let ns = display as NSString + let newDisplay = ns.replacingCharacters(in: NSRange(location: 18, length: 1), with: "") + let result = try #require(WikiLinkService.updatedStorageState( + displayText: newDisplay, + editedRange: NSRange(location: 18, length: 0), + changeInLength: -1, + previousStorage: storage, + previousMetadata: meta + )) + #expect(result.storage == "Hello [[Note|abc123]] word") + } + + @Test func editTouchingLinkFallsBack() { + let (display, storage, meta) = seed() + // Typing directly after "]]" (display location 14) is inside the ±3 guard band. + let result = WikiLinkService.updatedStorageState( + displayText: display + " ", + editedRange: NSRange(location: 14, length: 1), + changeInLength: 1, + previousStorage: storage, + previousMetadata: meta + ) + #expect(result == nil) + } + + @Test func editCreatingLinkSyntaxFallsBack() { + // "[[x]" + typed "]" completes a link → the new-text probe sees "]]" → bail. + let display = "pre [[x] post" + let ns = display as NSString + let newDisplay = ns.replacingCharacters(in: NSRange(location: 8, length: 0), with: "]") + let result = WikiLinkService.updatedStorageState( + displayText: newDisplay, + editedRange: NSRange(location: 8, length: 1), + changeInLength: 1, + previousStorage: display, + previousMetadata: [:] + ) + #expect(result == nil) + } + + @Test func unknownDeltaFallsBack() { + let (display, storage, meta) = seed() + let result = WikiLinkService.updatedStorageState( + displayText: display, + editedRange: NSRange(location: 0, length: 0), + changeInLength: Int.min, + previousStorage: storage, + previousMetadata: meta + ) + #expect(result == nil) + } + + /// Property sweep: inserting one char at every position that takes the fast + /// path must yield a storage string that (a) round-trips back to exactly the + /// new display text and (b) preserves every link id. (Comparing against a + /// full `makeStorageState(textStorage: nil)` rebuild would be wrong here — + /// the full rebuild loses ids when link ranges shift, which is precisely + /// what the incremental path fixes.) + @Test func spliceRoundTripsAtEverySafePosition() { + let storage = "aaaa [[One|id1]] bbbb [[Two|id2]] cccc" + let state = WikiLinkService.makeDisplayState(from: storage) + let display = state.display as NSString + var fastPathTaken = 0 + + for position in 0...display.length { + let newDisplay = display.replacingCharacters(in: NSRange(location: position, length: 0), with: "q") + guard let fast = WikiLinkService.updatedStorageState( + displayText: newDisplay, + editedRange: NSRange(location: position, length: 1), + changeInLength: 1, + previousStorage: storage, + previousMetadata: state.metadata + ) else { continue } // guarded position → fallback, fine + fastPathTaken += 1 + let roundTrip = WikiLinkService.makeDisplayState(from: fast.storage) + #expect(roundTrip.display == newDisplay, "display round-trip diverged at position \(position)") + #expect(fast.metadata.values.compactMap(\.id).sorted() == ["id1", "id2"], + "link id lost at position \(position)") + } + #expect(fastPathTaken > 0, "sweep was vacuous — no position took the fast path") + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `swift test --filter WikiLinkIncrementalTests 2>&1 | tail -5` +Expected: compile error — `updatedStorageState` does not exist. + +- [ ] **Step 3: Implement `updatedStorageState`** + +In `Sources/MarkdownEngine/Services/WikiLinkService.swift`, add after `makeStorageState` (after line ~190): + +```swift + /// Incremental counterpart to `makeStorageState`: splice a single contiguous + /// edit into the previous storage form in O(edit + #links). + /// + /// Outside link syntax, display and storage text are identical, so an edit + /// that provably cannot create, destroy, or touch a link maps 1:1 into the + /// storage string; links after the edit just shift by the length delta. + /// Returns nil whenever that proof fails — callers fall back to the full + /// `makeStorageState` rebuild. + public static func updatedStorageState( + displayText: String, + editedRange: NSRange, + changeInLength delta: Int, + previousStorage: String, + previousMetadata: [RangeKey: LinkMetadata] + ) -> (storage: String, metadata: [RangeKey: LinkMetadata])? { + let nsDisplay = displayText as NSString + let nsPrevStorage = previousStorage as NSString + + // Only contiguous, small, well-formed edits take the fast path. + guard delta != Int.min, + editedRange.location != NSNotFound, + editedRange.length >= 0, editedRange.length <= 4096, + NSMaxRange(editedRange) <= nsDisplay.length, + editedRange.length - delta >= 0 else { return nil } + + let oldEditLength = editedRange.length - delta + let oldEditRange = NSRange(location: editedRange.location, length: oldEditLength) + + // The edit must not create or complete link syntax: no [[ or ]] near it + // in the NEW text (±3 covers a bracket typed against an existing one)… + let probeStart = max(0, editedRange.location - 3) + let probeEnd = min(nsDisplay.length, NSMaxRange(editedRange) + 3) + let probe = nsDisplay.substring(with: NSRange(location: probeStart, length: probeEnd - probeStart)) + if probe.contains("[[") || probe.contains("]]") { return nil } + + // …and no existing link may overlap the edit (old display coordinates; + // ±3 also rejects edits adjacent to a link's markers). + let guardRange = NSRange(location: max(0, oldEditRange.location - 3), length: oldEditLength + 6) + for key in previousMetadata.keys { + if NSIntersectionRange(NSRange(location: key.location, length: key.length), guardRange).length > 0 { + return nil + } + } + + // Map the display edit offset into storage coordinates: every link + // before the edit is longer in storage by (storage length − display length). + var storageOffsetDelta = 0 + for (key, meta) in previousMetadata where key.location < editedRange.location { + storageOffsetDelta += meta.storageRange.length - key.length + } + let storageEditStart = editedRange.location + storageOffsetDelta + guard storageEditStart >= 0, + storageEditStart + oldEditLength <= nsPrevStorage.length else { return nil } + + // Splice — outside links the replaced/inserted characters are identical + // in both forms. + let replacement = nsDisplay.substring(with: editedRange) + let storage = nsPrevStorage.replacingCharacters( + in: NSRange(location: storageEditStart, length: oldEditLength), + with: replacement + ) + + // Shift every link after the edit by the delta; links before it are untouched. + var metadata: [RangeKey: LinkMetadata] = [:] + metadata.reserveCapacity(previousMetadata.count) + for (key, meta) in previousMetadata { + if key.location >= NSMaxRange(oldEditRange) { + metadata[RangeKey(NSRange(location: key.location + delta, length: key.length))] = + LinkMetadata(id: meta.id, + storageRange: NSRange(location: meta.storageRange.location + delta, + length: meta.storageRange.length)) + } else { + metadata[key] = meta + } + } + return (storage, metadata) + } +``` + +- [ ] **Step 4: Run the service tests** + +Run: `swift test --filter WikiLinkIncrementalTests 2>&1 | tail -5` +Expected: PASS (all 7 tests). + +- [ ] **Step 5: Wire it into the coordinator** + +In `Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift`, next to `var previousBacktickCount: Int = 0` (line ~88), add: + +```swift + /// Display-text length after the previous textDidChange — yields the edit's + /// length delta without retaining the previous text. + var previousDisplayLength: Int = -1 + /// Storage form computed by the previous wiki sync, kept synchronously + /// (unlike `lastSyncedText`, which updates via async dispatch and can lag a + /// keystroke). This is the splice base for the incremental path. + var lastComputedStorage: String = "" + /// DEBUG-only sampling counter for verifying splices against full rebuilds. + var wikiVerifyCounter: UInt = 0 +``` + +In `NativeTextViewCoordinator+TextDelegate.swift`, `textDidChange`: replace the block from `let rawSelRange = tv.selectedRange()` down to the end of the `if !wtActive { … }` wiki-sync block (currently ~lines 105–128) with: + +```swift + let rawSelRange = tv.selectedRange() + let docString = tv.string + let fullText = docString as NSString + let fullLength = fullText.length + guard !tv.hasMarkedText() else { return } + let safeLocation = min(rawSelRange.location, fullLength) + let safeSelRange = NSRange(location: safeLocation, length: 0) + previousCaretLocation = safeSelRange.location + PerfTrace.begin(docLength: fullLength) + + // Edit descriptor, hoisted above the wiki sync so both it and the + // paragraph scoping below share it. + let editedRange = pendingEditedRange ?? tv.textStorage?.editedRange ?? safeSelRange + pendingEditedRange = nil + let lengthDelta = previousDisplayLength >= 0 ? fullLength - previousDisplayLength : Int.min + previousDisplayLength = fullLength + + if !wtActive { + let storageState = PerfTrace.measure("wiki") { + WikiLinkService.updatedStorageState( + displayText: docString, + editedRange: editedRange, + changeInLength: lengthDelta, + previousStorage: lastComputedStorage, + previousMetadata: wikiLinkMetadata + ) ?? WikiLinkService.makeStorageState( + from: docString, + existingMetadata: wikiLinkMetadata, + textStorage: tv.textStorage + ) + } + self.wikiLinkMetadata = storageState.metadata + self.lastComputedStorage = storageState.storage +#if DEBUG + // Sampled safety net: every 64th keystroke, prove the splice equals + // a full rebuild. Remove together with PerfTrace after sign-off. + wikiVerifyCounter &+= 1 + if wikiVerifyCounter % 64 == 0 { + let reference = WikiLinkService.makeStorageState( + from: docString, + existingMetadata: wikiLinkMetadata, + textStorage: tv.textStorage + ) + assert(reference.storage == storageState.storage, + "wiki incremental splice diverged from full rebuild") + } +#endif + if storageState.storage != self.lastSyncedText { + DispatchQueue.main.async { + self.lastSyncedText = storageState.storage + self.text = storageState.storage + } + } + } +``` + +Then, a few lines below, **delete** the now-duplicate declarations (the old `let fullText = tv.string as NSString` line and the old `let editedRange = pendingEditedRange ?? …` / `pendingEditedRange = nil` pair further down, ~old lines 126 and 136–137) — `fullText`, `fullLength`, and `editedRange` now come from the hoisted block. The later `let safeEditedRange` computation keeps working unchanged. + +In `NativeTextViewCoordinator+Restyling.swift`, `rebuildTextStorageAndStyle`, directly after `lastSyncedText = text` (line ~39), seed the new state: + +```swift + lastSyncedText = text + lastComputedStorage = text + previousDisplayLength = (displayText as NSString).length +``` + +- [ ] **Step 6: Build and run the full suite** + +Run: `swift build && swift test 2>&1 | tail -5` +Expected: `Build complete!`, all suites pass. + +- [ ] **Step 7: Commit** + +```bash +git add Sources/MarkdownEngine/Services/WikiLinkService.swift \ + Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift \ + Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift \ + Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift \ + Tests/MarkdownEngineTests/WikiLinkIncrementalTests.swift +git commit -m "perf(wiki): splice keystroke edits into the storage form incrementally + +makeStorageState rescanned and rebuilt the whole document per keystroke +once any [[ existed (3.4ms at 139k chars). Edits that provably cannot +touch link syntax now splice into the previous storage string in +O(edit + links); anything ambiguous falls back to the full rebuild. +DEBUG samples every 64th keystroke against the full rebuild. + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 3: Backtick census without full-document split (0.75 ms → ~0.05 ms) + +**Files:** +- Modify: `Sources/MarkdownEngine/Parser/MarkdownDetection.swift` (add one function) +- Modify: `Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift` (the `backtick` measure line, ~line 156) +- Test: Create `Tests/MarkdownEngineTests/BacktickCensusTests.swift` + +**Interfaces:** +- Consumes: `fullText: NSString` already hoisted in Task 2's `textDidChange` block. +- Produces: `MarkdownDetection.tripleBacktickCount(in: NSString) -> Int` with semantics identical to `components(separatedBy: "```").count - 1` (non-overlapping, left-to-right). + +**Why:** `components(separatedBy: "```")` allocates an array of substrings spanning the whole document on every keystroke. A single UTF-16 scan does the same count allocation-free. + +- [ ] **Step 1: Write the failing test** + +Create `Tests/MarkdownEngineTests/BacktickCensusTests.swift`: + +```swift +// +// BacktickCensusTests.swift +// MarkdownEngine +// +// Created by Luca Chen on 07.07.26. +// + +import Foundation +import Testing +@testable import MarkdownEngine + +@Suite("Backtick census") +struct BacktickCensusTests { + + @Test func matchesComponentsSemantics() { + let samples = [ + "", "`", "``", "```", "````", "`````", "``````", + "a```b```c", + "x\n```swift\nlet a = 1\n```\ny", + "inline `code` only", + "```````" + ] + for sample in samples { + let expected = sample.components(separatedBy: "```").count - 1 + #expect(MarkdownDetection.tripleBacktickCount(in: sample as NSString) == expected, + "mismatch for \(sample.debugDescription)") + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `swift test --filter BacktickCensusTests 2>&1 | tail -5` +Expected: compile error — `tripleBacktickCount` does not exist. + +- [ ] **Step 3: Implement** + +In `Sources/MarkdownEngine/Parser/MarkdownDetection.swift`, add inside `enum MarkdownDetection`: + +```swift + /// Count of non-overlapping ``` occurrences, scanning left to right — + /// exactly `components(separatedBy: "```").count - 1`, but as one UTF-16 + /// pass with no substring-array allocation. + static func tripleBacktickCount(in text: NSString) -> Int { + let length = text.length + guard length >= 3 else { return 0 } + var buffer = [unichar](repeating: 0, count: length) + text.getCharacters(&buffer, range: NSRange(location: 0, length: length)) + var count = 0 + var i = 0 + while i + 2 < length { // i can reach length - 3 + if buffer[i] == 0x60, buffer[i + 1] == 0x60, buffer[i + 2] == 0x60 { + count += 1 + i += 3 + } else { + i += 1 + } + } + return count + } +``` + +In `NativeTextViewCoordinator+TextDelegate.swift`, replace: + +```swift + let backtickCount = PerfTrace.measure("backtick") { tv.string.components(separatedBy: "```").count - 1 } +``` + +with: + +```swift + let backtickCount = PerfTrace.measure("backtick") { MarkdownDetection.tripleBacktickCount(in: fullText) } +``` + +Also replace the two remaining `tv.string` uses in `textDidChange` with the hoisted `docString` (the `parse` measure line 163: `parsedDocument(for: docString)`) so the bridge happens once per keystroke. + +- [ ] **Step 4: Run tests** + +Run: `swift test --filter BacktickCensusTests 2>&1 | tail -5` → PASS. +Run: `swift test 2>&1 | tail -5` → all pass. + +- [ ] **Step 5: Commit** + +```bash +git add Sources/MarkdownEngine/Parser/MarkdownDetection.swift \ + Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift \ + Tests/MarkdownEngineTests/BacktickCensusTests.swift +git commit -m "perf(parse): count code fences with one UTF-16 scan, hoist tv.string + +components(separatedBy:) allocated a whole-document substring array per +keystroke just to count fences. One allocation-free scan replaces it, +and textDidChange bridges tv.string exactly once. + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 4: `ensureVisibleLayout` starts at the viewport, not the document head + +**Files:** +- Modify: `Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift` (`ensureVisibleLayout`, ~line 400) +- Test: none (AppKit layout — verified live via the PerfTrace `walked=` counter in Task 6; per Luca's convention no unit tests for view-layer tweaks) + +**Interfaces:** +- Consumes: `textLayoutManager` (TextKit 2 `NSTextLayoutManager`), its `textViewportLayoutController.viewportRange`. +- Produces: same function, same guarantee ("visible fragments have ensured layout"), cost now O(viewport) instead of O(caret position). + +**Why:** the walk currently starts at `documentRange.location` with `.ensuresLayout`, so typing at the bottom of a document forces a pass over every fragment above the viewport (measured: 115 of 130 fragments wasted at only 7.9k chars; grows linearly with position). + +- [ ] **Step 1: Reimplement the walk** + +Replace the body of `ensureVisibleLayout()`: + +```swift + /// Force TextKit 2 to lay out all fragments within the current visible rect. + func ensureVisibleLayout() { + guard let tlm = textLayoutManager else { return } + let visBot = visibleRect.maxY + // Start at the viewport instead of the document head: walking from the + // start forces layout of every fragment above the viewport, making a + // keystroke cost O(caret position in document). + let start = tlm.textViewportLayoutController.viewportRange?.location + ?? tlm.documentRange.location + var walked = 0 + tlm.enumerateTextLayoutFragments(from: start, options: [.ensuresLayout]) { fragment in + walked += 1 + return fragment.layoutFragmentFrame.minY <= visBot + } + PerfTrace.note { "ensureVisibleLayout walked=\(walked) frags from viewport" } + } +``` + +- [ ] **Step 2: Build and run the suite** + +Run: `swift build && swift test 2>&1 | tail -5` +Expected: `Build complete!`, all pass (HeightBehaviorTests and ScrollingHeaderControllerTests are the layout-adjacent suites to watch). + +- [ ] **Step 3: Commit** + +```bash +git add Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift +git commit -m "perf(layout): ensureVisibleLayout walks from the viewport, not the document head + +Enumerating from documentRange.location with .ensuresLayout laid out +every fragment above the viewport on each keystroke - O(caret position). +Starting at viewportRange keeps the walk O(viewport). + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 5: O(1) `parsedDocument` cache hits + single UTF-16 extraction (parse 6.4 ms → target ≤ 3 ms) + +**Files:** +- Modify: `Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift` (cache vars near line 97) +- Modify: `Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift` (`parsedDocument`, line ~162; `rebuildTextStorageAndStyle`) +- Modify: `Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift` (`shouldChangeTextIn`, line ~460) +- Modify: `Sources/MarkdownEngine/Parser/BlockScopedTokenizer.swift` (`parseTokensViaAST`, line ~29) +- Modify: `Sources/MarkdownEngine/Parser/BlockParser.swift` (`parse`, line ~51) +- Test: existing parser suites must stay green (`BlockParserTests`, `ASTPipelineTests`, `InlineParserTests`, `ListParsingTests`); no new tests (pure caching/plumbing, observable behavior unchanged). + +**Interfaces:** +- Consumes: `parsedDocument(for:)` call sites (TextDelegate, Restyling, Autocorrect, ContextMenu, InlineSelection — all pass the live display text). +- Produces: `BlockParser.parse(_ text: String, utf16Chars: [unichar]? = nil) -> [Block]` (existing single-argument calls keep compiling via the default); coordinator vars `parseGeneration: UInt64`, `cachedParseGeneration: UInt64`, `cachedParsedLength: Int` that any future storage-mutating code must bump (`parseGeneration &+= 1`). + +**Why (two independent O(doc) costs inside the measured 6.4 ms):** +1. The `cachedParsedText == text` compare is O(doc) — it runs (and fails) on every keystroke, and runs (and fully scans) on every caret move. +2. `parseTokensViaAST` extracts the full UTF-16 buffer, then `BlockParser.parse` extracts the **same buffer again**. + +- [ ] **Step 1: Generation-tagged cache** + +In `NativeTextViewCoordinator.swift`, next to `var cachedParsedText: String?` (line ~97), add: + +```swift + /// Monotonic edit counter: bumped whenever the text storage can have + /// changed. Lets `parsedDocument` return cache hits in O(1) instead of an + /// O(doc) string compare. Any code that mutates the storage directly + /// (bypassing shouldChangeText/textDidChange) must bump this. + var parseGeneration: UInt64 = 0 + var cachedParseGeneration: UInt64 = .max + var cachedParsedLength: Int = -1 +``` + +In `NativeTextViewCoordinator+Restyling.swift`, rewrite the head and tail of `parsedDocument(for:)`: + +```swift + func parsedDocument(for text: String) -> ParsedDocument { + let length = (text as NSString).length + if let cachedParsedDocument, cachedParsedLength == length { + // O(1) hit: nothing has edited the storage since the cached parse. + if cachedParseGeneration == parseGeneration { + return cachedParsedDocument + } + // Generation moved but the text may still be identical (e.g. an + // attribute-only pass): verify once, then it's O(1) again. + if let cachedParsedText, cachedParsedText == text { + cachedParseGeneration = parseGeneration + return cachedParsedDocument + } + } + + let tokens = MarkdownTokenizer.parseTokensViaAST(in: text) + // … (existing token classification loop stays byte-identical) … + + cachedParsedText = text + cachedParsedLength = length + cachedParseGeneration = parseGeneration + cachedParsedDocument = parsed + return parsed + } +``` + +Bump sites: +1. `NativeTextViewCoordinator+TextDelegate.swift`, inside `textView(_:shouldChangeTextIn:replacementString:)` (line ~460), first line of the body: `parseGeneration &+= 1`. +2. Same file, `textDidChange`, directly after `PerfTrace.begin(docLength: fullLength)`: `parseGeneration &+= 1` (belt for edits that reach `didChangeText` without the delegate ask — undo, some IME paths). +3. `NativeTextViewCoordinator+Restyling.swift`, `rebuildTextStorageAndStyle`, directly after `textView.string = displayText` inside the `if textView.string != displayText` branch: `parseGeneration &+= 1`. +4. Run `grep -rn "replaceCharacters(in:\|insertText(" Sources/MarkdownEngine --include="*.swift" | grep -v Tests` and for every hit that mutates the **editor's** textStorage without going through `shouldChangeText`/`didChangeText` (candidates: task-checkbox toggle, wiki-link snapback, the inline-replacement pipeline in Restyling), add `parseGeneration &+= 1` (via the coordinator reference available at that site) right after the mutation. If a site has no coordinator access, leave it — the length + string-compare fallback keeps it correct, just slower. + +- [ ] **Step 2: Share the UTF-16 buffer** + +In `BlockParser.swift`, change the `parse` signature and buffer setup (line ~51): + +```swift + /// Splits `text` into gap-free tiling blocks; memoizes the last parse so both per-keystroke callers share one line-scan. + /// Pass `utf16Chars` when the caller already extracted the buffer (must match `text`). + static func parse(_ text: String, utf16Chars: [unichar]? = nil) -> [Block] { + let textNS = text as NSString + let newLen = textNS.length + let newChars: [unichar] + if let utf16Chars, utf16Chars.count == newLen { + newChars = utf16Chars + } else { + var buffer = [unichar](repeating: 0, count: newLen) + if newLen > 0 { textNS.getCharacters(&buffer, range: NSRange(location: 0, length: newLen)) } + newChars = buffer + } +``` + +(the rest of the function body is unchanged — it already uses `newChars`). + +In `BlockScopedTokenizer.swift`, `parseTokensViaAST` (line ~29), pass the buffer it already extracted: + +```swift + let blocks = BlockParser.parse(text, utf16Chars: newChars) +``` + +- [ ] **Step 3: Build and run the full suite** + +Run: `swift build && swift test 2>&1 | tail -5` +Expected: `Build complete!`, all suites pass — especially `BlockParserTests` and `ASTPipelineTests` (identical outputs, only plumbing changed). + +- [ ] **Step 4: Commit** + +```bash +git add Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift \ + Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift \ + Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift \ + Sources/MarkdownEngine/Parser/BlockParser.swift \ + Sources/MarkdownEngine/Parser/BlockScopedTokenizer.swift +git commit -m "perf(parse): O(1) parsedDocument cache hits, single UTF-16 extraction + +The parse cache compared the whole document string on every call - +O(doc) per keystroke AND per caret move. A generation counter bumped on +every storage edit makes hits O(1), with the string compare kept as a +correctness fallback. BlockParser now reuses the UTF-16 buffer the +tokenizer already extracted instead of re-extracting it. + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 6: Live before/after measurement and report + +**Files:** +- No engine changes. Uses the Nodes app (already wired to this local engine checkout) and the PerfTrace output. + +**Interfaces:** +- Consumes: PerfTrace `⌨️ PERF` lines and `└─` notes from Tasks 0–5. + +- [ ] **Step 1: Build the app against the finished branch** + +```bash +cd /Users/lucachen/Documents/GitHub/Nodes +xcodebuild -project Nodes.xcodeproj -scheme Nodes -configuration Debug CODE_SIGNING_ALLOWED=NO build 2>&1 | tail -3 +``` + +Expected: `** BUILD SUCCEEDED **` + +- [ ] **Step 2: Ask Luca to repeat the exact baseline scenario** + +Same three docs as the 2026-07-07 baseline: the 139k-char note (typing mid-document), the ~456-char note, and the 10-table note (typing at the bottom). ~10 keystrokes each, paste the console. + +- [ ] **Step 3: Evaluate against these acceptance targets (Debug numbers)** + +| Metric | Baseline | Target | +|---|---|---| +| 139k doc: `total` | ~12.8 ms | ≤ 5 ms | +| 139k doc: `wiki` | ~3.4 ms | ≤ 0.3 ms steady (no fallback churn) | +| 139k doc: `parse` | ~6.4 ms | ≤ 3 ms | +| 139k doc: `backtick` | ~0.75 ms | ≤ 0.1 ms | +| Table doc: `styleTables` note | `re-rendered=10` | `re-rendered=0` steady-state | +| Table doc: `total` | 13–28 ms | ≤ 6 ms | +| Bottom-of-doc: `ensureVisibleLayout` | `115 above viewport (wasted)` | walked ≈ viewport fragment count, independent of caret position | + +Also confirm zero `assertionFailure` from the wiki DEBUG sampler and that links still round-trip (type near links, rename nothing, check `[[Name|UUID]]` survives in the saved file). + +- [ ] **Step 4: Report to Luca and stop** + +Summarize before/after per metric. Do **not** push, do not open a PR, do not release — Luca decides integration (and the eventual PerfTrace removal) after reviewing the numbers. + +--- + +## Explicitly Out of Scope (follow-ups, not part of this plan) + +- **Latex/imageEmbed restyle scope** (`TextDelegate.swift:188` appends every latex/image paragraph in the doc to each restyle): measured 0.00 ms in the baseline docs (none present). Re-measure with a formula-heavy doc first; only then scope it. +- **Green-tree incremental block parse** (relative-width ranges, no suffix shifting): the BlockParser header marks this as deliberate "Phase 3"; today's suffix-shift is O(#tokens) struct copies and acceptable. +- **First-keystroke-after-switch spike** (64 ms / restyle 45 ms once): one-time cost, different mechanism (full initial restyle), separate investigation. +- **`updateCodeBlockSelection`** (0.35 ms at 139k) and **overscroll recalc** (0.14 ms): below the noise floor after the fixes above. +- **WideTableOverlay full-doc ensureLayout**: measured 0.1 ms (layout already settled when it runs) — the audit flagged the code shape, but the data says leave it. +- **PerfTrace removal** and the app repo's pbxproj revert to the remote engine reference: after Luca signs off. From 5e0b3b6cd547580c11fcde0a8c5ddbb9bc8918a0 Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Wed, 8 Jul 2026 18:55:21 +0200 Subject: [PATCH 03/39] perf(tables): cache rendered table images keyed by source+font+appearance Every keystroke re-rendered every inactive table to a fresh NSImage (7-17ms with 10 tables). Table pixels depend only on source, font, colors and appearance, so identical keys now reuse the cached image. Co-Authored-By: Claude Fable 5 --- .../Styling/MarkdownStyler+Tables.swift | 45 +++++++++++--- .../TableImageCacheTests.swift | 59 +++++++++++++++++++ 2 files changed, 97 insertions(+), 7 deletions(-) create mode 100644 Tests/MarkdownEngineTests/TableImageCacheTests.swift diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift index 7f8b3843..7cabefb3 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift @@ -25,6 +25,39 @@ extension MarkdownStyler { let rows: [[String]] } + /// Rendered-table image cache. A table's pixels depend only on its source, + /// font, colors, and appearance — so identical keys can reuse the NSImage + /// instead of re-rendering every inactive table on every keystroke. + private static let tableImageCache: NSCache = { + let cache = NSCache() + cache.countLimit = 128 + return cache + }() + + /// Returns the rendered image for `source`, from cache when possible. + /// `rendered` is true only when a fresh render actually happened. + static func tableImage( + for source: String, + parsed: ParsedTable, + ctx: StylingContext, + appearance: NSAppearance + ) -> (image: NSImage, rendered: Bool) { + let key = "\(ctx.baseFont.fontName)|\(ctx.baseFont.pointSize)|\(appearance.name.rawValue)|\(ctx.configuration.theme.bodyText)|\(ctx.codeBackgroundColor)|\(source)" as NSString + if let cached = tableImageCache.object(forKey: key) { + return (cached, false) + } + let image = renderTable( + parsed, + baseFont: ctx.baseFont, + theme: ctx.configuration.theme, + codeBackgroundColor: ctx.codeBackgroundColor, + latex: ctx.services.latex, + appearance: appearance + ) + tableImageCache.setObject(image, forKey: key) + return (image, true) + } + static func styleTables(_ ctx: StylingContext) -> [StyledRange] { var attrs: [StyledRange] = [] // Per-content occurrence counter so identical tables get distinct sourceIDs. @@ -66,15 +99,13 @@ extension MarkdownStyler { // See renderTable: resolve table colors under the text view's real appearance. let renderAppearance = ctx.layoutBridge?.firstTextContainer?.textView?.effectiveAppearance ?? NSApp.effectiveAppearance - let image = renderTable( - parsed, - baseFont: ctx.baseFont, - theme: ctx.configuration.theme, - codeBackgroundColor: ctx.codeBackgroundColor, - latex: ctx.services.latex, + let (image, rendered) = tableImage( + for: source, + parsed: parsed, + ctx: ctx, appearance: renderAppearance ) - renderedCount += 1 + if rendered { renderedCount += 1 } let imageBounds = CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height) // Wide tables → scrollable mode (NSScrollView overlay); narrow → collapsed. let containerWidth = effectiveContainerWidth(for: ctx) diff --git a/Tests/MarkdownEngineTests/TableImageCacheTests.swift b/Tests/MarkdownEngineTests/TableImageCacheTests.swift new file mode 100644 index 00000000..0ca30605 --- /dev/null +++ b/Tests/MarkdownEngineTests/TableImageCacheTests.swift @@ -0,0 +1,59 @@ +// +// TableImageCacheTests.swift +// MarkdownEngine +// +// Created by Luca Chen on 07.07.26. +// + +import AppKit +import Foundation +import Testing +@testable import MarkdownEngine + +@Suite("Table image cache") +struct TableImageCacheTests { + + private func makeContext(for source: String) -> MarkdownStyler.StylingContext { + let font = NSFont.systemFont(ofSize: 15) + return MarkdownStyler.StylingContext( + nsText: source as NSString, + tokens: [], + codeTokens: [], + activeTokenIndices: [], + baseFont: font, + layoutBridge: nil, + baseDefaultLineHeight: 18, + codeBackgroundColor: .windowBackgroundColor, + latexMarkerFont: font, + configuration: .default, + wikiLinkIDProvider: { _ in nil } + ) + } + + @Test func secondRequestIsServedFromCache() throws { + let source = "| alpha | beta |\n|---|---|\n| 1 | 2 |" + let parsed = try #require(MarkdownStyler.parseTableSource(source)) + let ctx = makeContext(for: source) + let aqua = try #require(NSAppearance(named: .aqua)) + + let first = MarkdownStyler.tableImage(for: source, parsed: parsed, ctx: ctx, appearance: aqua) + let second = MarkdownStyler.tableImage(for: source, parsed: parsed, ctx: ctx, appearance: aqua) + + #expect(first.rendered) + #expect(!second.rendered) + #expect(first.image === second.image) + } + + @Test func appearanceChangeRendersFresh() throws { + let source = "| gamma | delta |\n|---|---|\n| 3 | 4 |" + let parsed = try #require(MarkdownStyler.parseTableSource(source)) + let ctx = makeContext(for: source) + let aqua = try #require(NSAppearance(named: .aqua)) + let dark = try #require(NSAppearance(named: .darkAqua)) + + _ = MarkdownStyler.tableImage(for: source, parsed: parsed, ctx: ctx, appearance: aqua) + let darkResult = MarkdownStyler.tableImage(for: source, parsed: parsed, ctx: ctx, appearance: dark) + + #expect(darkResult.rendered) + } +} From c7dc8095612b4b4c9ad2eb2630786e8b7ceca595 Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Wed, 8 Jul 2026 19:00:03 +0200 Subject: [PATCH 04/39] perf(wiki): splice keystroke edits into the storage form incrementally makeStorageState rescanned and rebuilt the whole document per keystroke once any [[ existed (3.4ms at 139k chars). Edits that provably cannot touch link syntax now splice into the previous storage string in O(edit + links); anything ambiguous falls back to the full rebuild. DEBUG samples every 64th keystroke against the full rebuild. Co-Authored-By: Claude Fable 5 --- .../Services/WikiLinkService.swift | 78 ++++++++++ .../NativeTextViewCoordinator+Restyling.swift | 2 + ...tiveTextViewCoordinator+TextDelegate.swift | 42 ++++- .../NativeTextViewCoordinator.swift | 10 ++ .../WikiLinkIncrementalTests.swift | 144 ++++++++++++++++++ 5 files changed, 269 insertions(+), 7 deletions(-) create mode 100644 Tests/MarkdownEngineTests/WikiLinkIncrementalTests.swift diff --git a/Sources/MarkdownEngine/Services/WikiLinkService.swift b/Sources/MarkdownEngine/Services/WikiLinkService.swift index 6a265128..47db0d23 100644 --- a/Sources/MarkdownEngine/Services/WikiLinkService.swift +++ b/Sources/MarkdownEngine/Services/WikiLinkService.swift @@ -189,6 +189,84 @@ public enum WikiLinkService { return (storage, metadata) } + /// Incremental counterpart to `makeStorageState`: splice a single contiguous + /// edit into the previous storage form in O(edit + #links). + /// + /// Outside link syntax, display and storage text are identical, so an edit + /// that provably cannot create, destroy, or touch a link maps 1:1 into the + /// storage string; links after the edit just shift by the length delta. + /// Returns nil whenever that proof fails — callers fall back to the full + /// `makeStorageState` rebuild. + public static func updatedStorageState( + displayText: String, + editedRange: NSRange, + changeInLength delta: Int, + previousStorage: String, + previousMetadata: [RangeKey: LinkMetadata] + ) -> (storage: String, metadata: [RangeKey: LinkMetadata])? { + let nsDisplay = displayText as NSString + let nsPrevStorage = previousStorage as NSString + + // Only contiguous, small, well-formed edits take the fast path. + guard delta != Int.min, + editedRange.location != NSNotFound, + editedRange.length >= 0, editedRange.length <= 4096, + NSMaxRange(editedRange) <= nsDisplay.length, + editedRange.length - delta >= 0 else { return nil } + + let oldEditLength = editedRange.length - delta + let oldEditRange = NSRange(location: editedRange.location, length: oldEditLength) + + // The edit must not create or complete link syntax: no [[ or ]] near it + // in the NEW text (±3 covers a bracket typed against an existing one)… + let probeStart = max(0, editedRange.location - 3) + let probeEnd = min(nsDisplay.length, NSMaxRange(editedRange) + 3) + let probe = nsDisplay.substring(with: NSRange(location: probeStart, length: probeEnd - probeStart)) + if probe.contains("[[") || probe.contains("]]") { return nil } + + // …and no existing link may overlap the edit (old display coordinates; + // ±3 also rejects edits adjacent to a link's markers). + let guardRange = NSRange(location: max(0, oldEditRange.location - 3), length: oldEditLength + 6) + for key in previousMetadata.keys { + if NSIntersectionRange(NSRange(location: key.location, length: key.length), guardRange).length > 0 { + return nil + } + } + + // Map the display edit offset into storage coordinates: every link + // before the edit is longer in storage by (storage length − display length). + var storageOffsetDelta = 0 + for (key, meta) in previousMetadata where key.location < editedRange.location { + storageOffsetDelta += meta.storageRange.length - key.length + } + let storageEditStart = editedRange.location + storageOffsetDelta + guard storageEditStart >= 0, + storageEditStart + oldEditLength <= nsPrevStorage.length else { return nil } + + // Splice — outside links the replaced/inserted characters are identical + // in both forms. + let replacement = nsDisplay.substring(with: editedRange) + let storage = nsPrevStorage.replacingCharacters( + in: NSRange(location: storageEditStart, length: oldEditLength), + with: replacement + ) + + // Shift every link after the edit by the delta; links before it are untouched. + var metadata: [RangeKey: LinkMetadata] = [:] + metadata.reserveCapacity(previousMetadata.count) + for (key, meta) in previousMetadata { + if key.location >= NSMaxRange(oldEditRange) { + metadata[RangeKey(NSRange(location: key.location + delta, length: key.length))] = + LinkMetadata(id: meta.id, + storageRange: NSRange(location: meta.storageRange.location + delta, + length: meta.storageRange.length)) + } else { + metadata[key] = meta + } + } + return (storage, metadata) + } + /// Hand scan for display-form wiki links `(? [(range: NSRange, isImage: Bool)] { let len = s.length diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift index 0246f4ec..a292269b 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift @@ -37,6 +37,8 @@ extension NativeTextViewCoordinator { textView.string = displayText } lastSyncedText = text + lastComputedStorage = text + previousDisplayLength = (displayText as NSString).length let nsDisplay = displayText as NSString let fullRange = NSRange(location: 0, length: nsDisplay.length) diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift index 9f2c50be..86dfd4fa 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift @@ -103,21 +103,52 @@ extension NativeTextViewCoordinator { let rawSelRange = tv.selectedRange() - let fullLength = (tv.string as NSString).length + let docString = tv.string + let fullText = docString as NSString + let fullLength = fullText.length guard !tv.hasMarkedText() else { return } let safeLocation = min(rawSelRange.location, fullLength) let safeSelRange = NSRange(location: safeLocation, length: 0) previousCaretLocation = safeSelRange.location PerfTrace.begin(docLength: fullLength) + + // Edit descriptor, hoisted above the wiki sync so both it and the + // paragraph scoping below share it. + let editedRange = pendingEditedRange ?? tv.textStorage?.editedRange ?? safeSelRange + pendingEditedRange = nil + let lengthDelta = previousDisplayLength >= 0 ? fullLength - previousDisplayLength : Int.min + previousDisplayLength = fullLength + if !wtActive { let storageState = PerfTrace.measure("wiki") { - WikiLinkService.makeStorageState( - from: tv.string, - existingMetadata: self.wikiLinkMetadata, + WikiLinkService.updatedStorageState( + displayText: docString, + editedRange: editedRange, + changeInLength: lengthDelta, + previousStorage: lastComputedStorage, + previousMetadata: wikiLinkMetadata + ) ?? WikiLinkService.makeStorageState( + from: docString, + existingMetadata: wikiLinkMetadata, textStorage: tv.textStorage ) } self.wikiLinkMetadata = storageState.metadata + self.lastComputedStorage = storageState.storage +#if DEBUG + // Sampled safety net: every 64th keystroke, prove the splice equals + // a full rebuild. Remove together with PerfTrace after sign-off. + wikiVerifyCounter &+= 1 + if wikiVerifyCounter % 64 == 0 { + let reference = WikiLinkService.makeStorageState( + from: docString, + existingMetadata: wikiLinkMetadata, + textStorage: tv.textStorage + ) + assert(reference.storage == storageState.storage, + "wiki incremental splice diverged from full rebuild") + } +#endif if storageState.storage != self.lastSyncedText { DispatchQueue.main.async { self.lastSyncedText = storageState.storage @@ -126,7 +157,6 @@ extension NativeTextViewCoordinator { } } - let fullText = tv.string as NSString let paragraphRange = fullText.paragraphRange(for: safeSelRange) let documentLength = fullText.length let nextLocation = min(documentLength, NSMaxRange(paragraphRange)) @@ -136,8 +166,6 @@ extension NativeTextViewCoordinator { let nextParagraph = nextLocation < documentLength ? fullText.paragraphRange(for: NSRange(location: nextLocation, length: 0)) : NSRange(location: NSNotFound, length: 0) - let editedRange = pendingEditedRange ?? tv.textStorage?.editedRange ?? safeSelRange - pendingEditedRange = nil let wtEditedFallback: NSRange? = { guard wtActive, let sel = wtInitialSelectionRange else { return nil } let docLength = fullText.length diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift index 56dcf07a..f71b566a 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift @@ -87,6 +87,16 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate { var wikiLinkMetadata: [WikiLinkService.RangeKey: WikiLinkService.LinkMetadata] = [:] var previousBacktickCount: Int = 0 + /// Display-text length after the previous textDidChange — yields the edit's + /// length delta without retaining the previous text. + var previousDisplayLength: Int = -1 + /// Storage form computed by the previous wiki sync, kept synchronously + /// (unlike `lastSyncedText`, which updates via async dispatch and can lag a + /// keystroke). This is the splice base for the incremental path. + var lastComputedStorage: String = "" + /// DEBUG-only sampling counter for verifying splices against full rebuilds. + var wikiVerifyCounter: UInt = 0 + var pendingEditedRange: NSRange? = nil var pendingPreEditActiveTokenIndices: Set? = nil var previousCaretLocation: Int? = nil diff --git a/Tests/MarkdownEngineTests/WikiLinkIncrementalTests.swift b/Tests/MarkdownEngineTests/WikiLinkIncrementalTests.swift new file mode 100644 index 00000000..18cc56fa --- /dev/null +++ b/Tests/MarkdownEngineTests/WikiLinkIncrementalTests.swift @@ -0,0 +1,144 @@ +// +// WikiLinkIncrementalTests.swift +// MarkdownEngine +// +// Created by Luca Chen on 07.07.26. +// + +import Foundation +import Testing +@testable import MarkdownEngine + +@Suite("WikiLink incremental storage state") +struct WikiLinkIncrementalTests { + + /// storage: "Hello [[Note|abc123]] world" → display: "Hello [[Note]] world" + /// display link range {6, 8}, storage link range {6, 15}. + private func seed() -> (display: String, storage: String, + meta: [WikiLinkService.RangeKey: WikiLinkService.LinkMetadata]) { + let storage = "Hello [[Note|abc123]] world" + let state = WikiLinkService.makeDisplayState(from: storage) + return (state.display, storage, state.metadata) + } + + @Test func appendAfterLinkSplicesStorage() throws { + let (display, storage, meta) = seed() + let newDisplay = display + "x" // typed "x" at the end + let result = try #require(WikiLinkService.updatedStorageState( + displayText: newDisplay, + editedRange: NSRange(location: (display as NSString).length, length: 1), + changeInLength: 1, + previousStorage: storage, + previousMetadata: meta + )) + #expect(result.storage == "Hello [[Note|abc123]] worldx") + // Link before the edit: metadata unchanged. + let key = try #require(result.metadata.keys.first) + #expect(key.location == 6 && key.length == 8) + #expect(result.metadata[key]?.id == "abc123") + #expect(result.metadata[key]?.storageRange == NSRange(location: 6, length: 15)) + } + + @Test func insertBeforeLinkShiftsMetadata() throws { + let (display, storage, meta) = seed() + let newDisplay = "x" + display // typed "x" at position 0 + let result = try #require(WikiLinkService.updatedStorageState( + displayText: newDisplay, + editedRange: NSRange(location: 0, length: 1), + changeInLength: 1, + previousStorage: storage, + previousMetadata: meta + )) + #expect(result.storage == "xHello [[Note|abc123]] world") + let key = try #require(result.metadata.keys.first) + #expect(key.location == 7 && key.length == 8) // shifted by +1 + #expect(result.metadata[key]?.storageRange == NSRange(location: 7, length: 15)) + #expect(result.metadata[key]?.id == "abc123") + } + + @Test func deletionInPlainTextWorks() throws { + let (display, storage, meta) = seed() + // Delete the "l" of "world" (display location 18 — far enough from the + // link that neither the ±3 probe nor the metadata guard band trips). + let ns = display as NSString + let newDisplay = ns.replacingCharacters(in: NSRange(location: 18, length: 1), with: "") + let result = try #require(WikiLinkService.updatedStorageState( + displayText: newDisplay, + editedRange: NSRange(location: 18, length: 0), + changeInLength: -1, + previousStorage: storage, + previousMetadata: meta + )) + #expect(result.storage == "Hello [[Note|abc123]] word") + } + + @Test func editTouchingLinkFallsBack() { + let (display, storage, meta) = seed() + // Typing directly after "]]" (display location 14) is inside the ±3 guard band. + let result = WikiLinkService.updatedStorageState( + displayText: display + " ", + editedRange: NSRange(location: 14, length: 1), + changeInLength: 1, + previousStorage: storage, + previousMetadata: meta + ) + #expect(result == nil) + } + + @Test func editCreatingLinkSyntaxFallsBack() { + // "[[x]" + typed "]" completes a link → the new-text probe sees "]]" → bail. + let display = "pre [[x] post" + let ns = display as NSString + let newDisplay = ns.replacingCharacters(in: NSRange(location: 8, length: 0), with: "]") + let result = WikiLinkService.updatedStorageState( + displayText: newDisplay, + editedRange: NSRange(location: 8, length: 1), + changeInLength: 1, + previousStorage: display, + previousMetadata: [:] + ) + #expect(result == nil) + } + + @Test func unknownDeltaFallsBack() { + let (display, storage, meta) = seed() + let result = WikiLinkService.updatedStorageState( + displayText: display, + editedRange: NSRange(location: 0, length: 0), + changeInLength: Int.min, + previousStorage: storage, + previousMetadata: meta + ) + #expect(result == nil) + } + + /// Property sweep: inserting one char at every position that takes the fast + /// path must yield a storage string that (a) round-trips back to exactly the + /// new display text and (b) preserves every link id. (Comparing against a + /// full `makeStorageState(textStorage: nil)` rebuild would be wrong here — + /// the full rebuild loses ids when link ranges shift, which is precisely + /// what the incremental path fixes.) + @Test func spliceRoundTripsAtEverySafePosition() { + let storage = "aaaa [[One|id1]] bbbb [[Two|id2]] cccc" + let state = WikiLinkService.makeDisplayState(from: storage) + let display = state.display as NSString + var fastPathTaken = 0 + + for position in 0...display.length { + let newDisplay = display.replacingCharacters(in: NSRange(location: position, length: 0), with: "q") + guard let fast = WikiLinkService.updatedStorageState( + displayText: newDisplay, + editedRange: NSRange(location: position, length: 1), + changeInLength: 1, + previousStorage: storage, + previousMetadata: state.metadata + ) else { continue } // guarded position → fallback, fine + fastPathTaken += 1 + let roundTrip = WikiLinkService.makeDisplayState(from: fast.storage) + #expect(roundTrip.display == newDisplay, "display round-trip diverged at position \(position)") + #expect(fast.metadata.values.compactMap(\.id).sorted() == ["id1", "id2"], + "link id lost at position \(position)") + } + #expect(fastPathTaken > 0, "sweep was vacuous — no position took the fast path") + } +} From 40c85084b38a5c62c5d9ef3246a993a20d2e09ae Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Wed, 8 Jul 2026 19:04:39 +0200 Subject: [PATCH 05/39] perf(parse): count code fences with one UTF-16 scan, hoist tv.string components(separatedBy:) allocated a whole-document substring array per keystroke just to count fences. One allocation-free scan replaces it, and textDidChange bridges tv.string exactly once. Co-Authored-By: Claude Fable 5 --- .../Parser/MarkdownDetection.swift | 21 ++++++++++++++ ...tiveTextViewCoordinator+TextDelegate.swift | 4 +-- .../BacktickCensusTests.swift | 29 +++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 Tests/MarkdownEngineTests/BacktickCensusTests.swift diff --git a/Sources/MarkdownEngine/Parser/MarkdownDetection.swift b/Sources/MarkdownEngine/Parser/MarkdownDetection.swift index e6ff9666..e54e8ed4 100644 --- a/Sources/MarkdownEngine/Parser/MarkdownDetection.swift +++ b/Sources/MarkdownEngine/Parser/MarkdownDetection.swift @@ -95,6 +95,27 @@ enum MarkdownDetection { isInsideCodeBlock(range: NSRange(location: location, length: 0), codeTokens: codeTokens) } + /// Count of non-overlapping ``` occurrences, scanning left to right — + /// exactly `components(separatedBy: "```").count - 1`, but as one UTF-16 + /// pass with no substring-array allocation. + static func tripleBacktickCount(in text: NSString) -> Int { + let length = text.length + guard length >= 3 else { return 0 } + var buffer = [unichar](repeating: 0, count: length) + text.getCharacters(&buffer, range: NSRange(location: 0, length: length)) + var count = 0 + var i = 0 + while i + 2 < length { // i can reach length - 3 + if buffer[i] == 0x60, buffer[i + 1] == 0x60, buffer[i + 2] == 0x60 { + count += 1 + i += 3 + } else { + i += 1 + } + } + return count + } + // MARK: - LaTeX Detection static func isInsideLatex(location: Int, in text: String) -> Bool { diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift index 86dfd4fa..3db29236 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift @@ -184,11 +184,11 @@ extension NativeTextViewCoordinator { nextParagraph ] + editedParagraphs - let backtickCount = PerfTrace.measure("backtick") { tv.string.components(separatedBy: "```").count - 1 } + let backtickCount = PerfTrace.measure("backtick") { MarkdownDetection.tripleBacktickCount(in: fullText) } let codeBlockStructureChanged = backtickCount != previousBacktickCount previousBacktickCount = backtickCount - let parsed = PerfTrace.measure("parse") { parsedDocument(for: tv.string) } + let parsed = PerfTrace.measure("parse") { parsedDocument(for: docString) } let tokens = parsed.tokens let codeTokens = parsed.codeTokens let latexTokens = parsed.latexTokens diff --git a/Tests/MarkdownEngineTests/BacktickCensusTests.swift b/Tests/MarkdownEngineTests/BacktickCensusTests.swift new file mode 100644 index 00000000..34758b08 --- /dev/null +++ b/Tests/MarkdownEngineTests/BacktickCensusTests.swift @@ -0,0 +1,29 @@ +// +// BacktickCensusTests.swift +// MarkdownEngine +// +// Created by Luca Chen on 07.07.26. +// + +import Foundation +import Testing +@testable import MarkdownEngine + +@Suite("Backtick census") +struct BacktickCensusTests { + + @Test func matchesComponentsSemantics() { + let samples = [ + "", "`", "``", "```", "````", "`````", "``````", + "a```b```c", + "x\n```swift\nlet a = 1\n```\ny", + "inline `code` only", + "```````" + ] + for sample in samples { + let expected = sample.components(separatedBy: "```").count - 1 + #expect(MarkdownDetection.tripleBacktickCount(in: sample as NSString) == expected, + "mismatch for \(sample.debugDescription)") + } + } +} From 1ea27128bc2b81b75737fcc48680e509cd40b2da Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Wed, 8 Jul 2026 19:07:24 +0200 Subject: [PATCH 06/39] perf(layout): ensureVisibleLayout walks from the viewport, not the document head Enumerating from documentRange.location with .ensuresLayout laid out every fragment above the viewport on each keystroke - O(caret position). Starting at viewportRange keeps the walk O(viewport). Co-Authored-By: Claude Fable 5 --- .../NativeTextView+FrameAndOverscroll.swift | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift index 51bd48d3..8ff197f0 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift @@ -400,17 +400,17 @@ extension NativeTextView { /// Force TextKit 2 to lay out all fragments within the current visible rect. func ensureVisibleLayout() { guard let tlm = textLayoutManager else { return } - let visTop = visibleRect.minY let visBot = visibleRect.maxY + // Start at the viewport instead of the document head: walking from the + // start forces layout of every fragment above the viewport, making a + // keystroke cost O(caret position in document). + let start = tlm.textViewportLayoutController.viewportRange?.location + ?? tlm.documentRange.location var walked = 0 - var aboveViewport = 0 - tlm.enumerateTextLayoutFragments(from: tlm.documentRange.location, options: [.ensuresLayout]) { fragment in + tlm.enumerateTextLayoutFragments(from: start, options: [.ensuresLayout]) { fragment in walked += 1 - let fr = fragment.layoutFragmentFrame - if fr.maxY < visTop { aboveViewport += 1; return true } - if fr.minY > visBot { return false } - return true + return fragment.layoutFragmentFrame.minY <= visBot } - PerfTrace.note { "ensureVisibleLayout walked=\(walked) frags, \(aboveViewport) above viewport (wasted)" } + PerfTrace.note { "ensureVisibleLayout walked=\(walked) frags from viewport" } } } From e4d4df3c078ac28770a6dca70f7e3e2e5136d599 Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Wed, 8 Jul 2026 19:12:27 +0200 Subject: [PATCH 07/39] perf(parse): O(1) parsedDocument cache hits, single UTF-16 extraction The parse cache compared the whole document string on every call - O(doc) per keystroke AND per caret move. A generation counter bumped on every storage edit makes hits O(1), with the string compare kept as a correctness fallback. BlockParser now reuses the UTF-16 buffer the tokenizer already extracted instead of re-extracting it. Co-Authored-By: Claude Fable 5 --- Sources/MarkdownEngine/Parser/BlockParser.swift | 13 ++++++++++--- .../Parser/BlockScopedTokenizer.swift | 2 +- .../NativeTextViewCoordinator+Restyling.swift | 17 +++++++++++++++-- ...NativeTextViewCoordinator+TextDelegate.swift | 2 ++ .../Coordinator/NativeTextViewCoordinator.swift | 7 +++++++ 5 files changed, 35 insertions(+), 6 deletions(-) diff --git a/Sources/MarkdownEngine/Parser/BlockParser.swift b/Sources/MarkdownEngine/Parser/BlockParser.swift index a9e4b556..c2d0b536 100644 --- a/Sources/MarkdownEngine/Parser/BlockParser.swift +++ b/Sources/MarkdownEngine/Parser/BlockParser.swift @@ -48,11 +48,18 @@ enum BlockParser { private static var cachedBlocks: [Block]? /// Splits `text` into gap-free tiling blocks; memoizes the last parse so both per-keystroke callers share one line-scan. - static func parse(_ text: String) -> [Block] { + /// Pass `utf16Chars` when the caller already extracted the buffer (must match `text`). + static func parse(_ text: String, utf16Chars: [unichar]? = nil) -> [Block] { let textNS = text as NSString let newLen = textNS.length - var newChars = [unichar](repeating: 0, count: newLen) - if newLen > 0 { textNS.getCharacters(&newChars, range: NSRange(location: 0, length: newLen)) } + let newChars: [unichar] + if let utf16Chars, utf16Chars.count == newLen { + newChars = utf16Chars + } else { + var buffer = [unichar](repeating: 0, count: newLen) + if newLen > 0 { textNS.getCharacters(&buffer, range: NSRange(location: 0, length: newLen)) } + newChars = buffer + } cacheLock.lock() let prevChars = cachedChars diff --git a/Sources/MarkdownEngine/Parser/BlockScopedTokenizer.swift b/Sources/MarkdownEngine/Parser/BlockScopedTokenizer.swift index 3bb7aa8c..18e83e90 100644 --- a/Sources/MarkdownEngine/Parser/BlockScopedTokenizer.swift +++ b/Sources/MarkdownEngine/Parser/BlockScopedTokenizer.swift @@ -32,7 +32,7 @@ extension MarkdownTokenizer { var newChars = [unichar](repeating: 0, count: newLen) if newLen > 0 { ns.getCharacters(&newChars, range: NSRange(location: 0, length: newLen)) } - let blocks = BlockParser.parse(text) + let blocks = BlockParser.parse(text, utf16Chars: newChars) tokensLock.lock() let prevChars = cachedTokenChars diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift index a292269b..988cbe89 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift @@ -35,6 +35,7 @@ extension NativeTextViewCoordinator { if textView.string != displayText { textView.string = displayText + parseGeneration &+= 1 } lastSyncedText = text lastComputedStorage = text @@ -152,8 +153,18 @@ extension NativeTextViewCoordinator { } func parsedDocument(for text: String) -> ParsedDocument { - if cachedParsedText == text, let cachedParsedDocument { - return cachedParsedDocument + let length = (text as NSString).length + if let cachedParsedDocument, cachedParsedLength == length { + // O(1) hit: nothing has edited the storage since the cached parse. + if cachedParseGeneration == parseGeneration { + return cachedParsedDocument + } + // Generation moved but the text may still be identical (e.g. an + // attribute-only pass): verify once, then it's O(1) again. + if let cachedParsedText, cachedParsedText == text { + cachedParseGeneration = parseGeneration + return cachedParsedDocument + } } let tokens = MarkdownTokenizer.parseTokensViaAST(in: text) @@ -194,6 +205,8 @@ extension NativeTextViewCoordinator { imageEmbedTokens: imageEmbedTokens ) cachedParsedText = text + cachedParsedLength = length + cachedParseGeneration = parseGeneration cachedParsedDocument = parsed return parsed } diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift index 3db29236..bbc6516b 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift @@ -111,6 +111,7 @@ extension NativeTextViewCoordinator { let safeSelRange = NSRange(location: safeLocation, length: 0) previousCaretLocation = safeSelRange.location PerfTrace.begin(docLength: fullLength) + parseGeneration &+= 1 // Edit descriptor, hoisted above the wiki sync so both it and the // paragraph scoping below share it. @@ -495,6 +496,7 @@ extension NativeTextViewCoordinator { } public func textView(_ textView: NSTextView, shouldChangeTextIn affectedCharRange: NSRange, replacementString: String?) -> Bool { + parseGeneration &+= 1 if isProgrammaticEdit { return true } if isWritingToolsActive { return true } // Raw mode: plain-text editing — no smart Markdown input. diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift index f71b566a..0c5a00d0 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift @@ -106,6 +106,13 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate { var cachedCodeBlockTokens: [(index: Int, token: MarkdownToken)] = [] var cachedParsedText: String? var cachedParsedDocument: ParsedDocument? + /// Monotonic edit counter: bumped whenever the text storage can have + /// changed. Lets `parsedDocument` return cache hits in O(1) instead of an + /// O(doc) string compare. Any code that mutates the storage directly + /// (bypassing shouldChangeText/textDidChange) must bump this. + var parseGeneration: UInt64 = 0 + var cachedParseGeneration: UInt64 = .max + var cachedParsedLength: Int = -1 // Skip spellcheck property setters when the state wouldn't change. var cachedSpellingDisabled: Bool? From 60faf0bdb877f588661e4c317df90ea4b8c33bfa Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Sat, 11 Jul 2026 16:48:29 +0200 Subject: [PATCH 08/39] fix(perf): close three semantic gaps the hot-path commits opened Adversarial review of the rebase onto main (clipboard pipeline) confirmed: 1. Smart-input interceptors (auto-pair, ->-arrow, Tab indent, list-exit, $$-wrap) suppress the typed keystroke and perform a different programmatic edit, but the suppressed edit's pendingEditedRange was left as the wiki splice descriptor - silently diverging the storage form (and the saved file) from the display. Refresh the descriptor for programmatic edits too. 2. The table-image cache keyed colors by NSColor description, which is not an identity (named dynamic colors collide, unnamed never hit) and omitted mutedText/highlightColor/latex inputs renderTable draws with. Key on appearance-resolved sRGB components of every color used, plus the latex renderer type. 3. IME composition updates (setMarkedText) mutate the storage without textDidChange while shouldChangeTextIn's own parse re-caches the pre-edit string at the current generation - same-length updates then restyled from a stale parse. Bump the generation in setMarkedText. Co-Authored-By: Claude Fable 5 --- .../Styling/MarkdownStyler+Tables.swift | 33 ++++++++- ...tiveTextViewCoordinator+TextDelegate.swift | 7 +- .../NativeTextView/NativeTextView.swift | 5 ++ .../InterceptorStorageSyncTests.swift | 70 +++++++++++++++++++ .../TableImageCacheTests.swift | 50 ++++++++++++- 5 files changed, 161 insertions(+), 4 deletions(-) create mode 100644 Tests/MarkdownEngineTests/InterceptorStorageSyncTests.swift diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift index 7cabefb3..193162f0 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift @@ -34,6 +34,21 @@ extension MarkdownStyler { return cache }() + /// Pixel-level fingerprint of a theme color: its sRGB components resolved + /// under `appearance`. NSColor descriptions are not sound identities — + /// named dynamic colors describe by name only (two providers collide), + /// unnamed ones by per-instance UUID (never hit) — so key on what actually + /// reaches the bitmap. + private static func colorKey(_ color: NSColor, under appearance: NSAppearance) -> String { + var srgb: NSColor? + appearance.performAsCurrentDrawingAppearance { + srgb = color.usingColorSpace(.sRGB) + } + guard let c = srgb else { return "\(color)" } + return String(format: "%.4f,%.4f,%.4f,%.4f", + c.redComponent, c.greenComponent, c.blueComponent, c.alphaComponent) + } + /// Returns the rendered image for `source`, from cache when possible. /// `rendered` is true only when a fresh render actually happened. static func tableImage( @@ -42,7 +57,23 @@ extension MarkdownStyler { ctx: StylingContext, appearance: NSAppearance ) -> (image: NSImage, rendered: Bool) { - let key = "\(ctx.baseFont.fontName)|\(ctx.baseFont.pointSize)|\(appearance.name.rawValue)|\(ctx.configuration.theme.bodyText)|\(ctx.codeBackgroundColor)|\(source)" as NSString + // Every input renderTable reads must be in the key: fonts, all theme + // colors it draws with, and the latex renderer (by type — a NoOp and a + // real renderer must not share entries). + let theme = ctx.configuration.theme + let key = [ + ctx.baseFont.fontName, + "\(ctx.baseFont.pointSize)", + appearance.name.rawValue, + colorKey(theme.bodyText, under: appearance), + colorKey(theme.mutedText, under: appearance), + colorKey(theme.highlightColor, under: appearance), + colorKey(ctx.codeBackgroundColor, under: appearance), + colorKey(theme.latexLightModeText, under: appearance), + colorKey(theme.latexDarkModeText, under: appearance), + "\(ObjectIdentifier(type(of: ctx.services.latex)))", + source, + ].joined(separator: "|") as NSString if let cached = tableImageCache.object(forKey: key) { return (cached, false) } diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift index bbc6516b..63564517 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift @@ -497,11 +497,16 @@ extension NativeTextViewCoordinator { public func textView(_ textView: NSTextView, shouldChangeTextIn affectedCharRange: NSRange, replacementString: String?) -> Bool { parseGeneration &+= 1 + // Refresh the descriptor for EVERY proposed edit — including programmatic + // ones. A smart-input interceptor that suppresses a keystroke and performs + // a different edit (auto-pair, "->"→"→", Tab indent, list-exit, $$-wrap) + // would otherwise leave the suppressed edit's descriptor behind, and the + // wiki splice in textDidChange would corrupt the storage form from it. + pendingEditedRange = NSRange(location: affectedCharRange.location, length: replacementString?.utf16.count ?? 0) if isProgrammaticEdit { return true } if isWritingToolsActive { return true } // Raw mode: plain-text editing — no smart Markdown input. if configuration.rawSourceMode { return true } - pendingEditedRange = NSRange(location: affectedCharRange.location, length: replacementString?.utf16.count ?? 0) let currentLen = (textView.string as NSString).length let maxR = affectedCharRange.location + affectedCharRange.length if affectedCharRange.location > currentLen || maxR > currentLen { diff --git a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView.swift b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView.swift index b7fcd800..e8569276 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView.swift @@ -89,6 +89,11 @@ final class NativeTextView: NSTextView { let coord = delegate as? NativeTextViewCoordinator else { return } let marked = markedRange() guard marked.location != NSNotFound, marked.length > 0 else { return } + // The composition mutated the storage without textDidChange, and + // shouldChangeTextIn's own parse re-cached the PRE-edit string at the + // current generation — bump so the restyle below reparses instead of + // serving that stale document (same-length composition updates). + coord.parseGeneration &+= 1 let nsText = self.string as NSString let paragraph = nsText.paragraphRange(for: marked) coord.restyleParagraphs([paragraph], in: self) diff --git a/Tests/MarkdownEngineTests/InterceptorStorageSyncTests.swift b/Tests/MarkdownEngineTests/InterceptorStorageSyncTests.swift new file mode 100644 index 00000000..654c166d --- /dev/null +++ b/Tests/MarkdownEngineTests/InterceptorStorageSyncTests.swift @@ -0,0 +1,70 @@ +// +// InterceptorStorageSyncTests.swift +// MarkdownEngine +// +// Created by Luca Chen on 11.07.26. +// +// Regression: smart-input interceptors suppress the typed keystroke and +// perform a different programmatic edit. The incremental wiki splice must +// see THAT edit's descriptor — with the suppressed keystroke's stale one it +// silently diverged the storage form (and the persisted file) from the +// display text. +// + +import AppKit +import SwiftUI +import Testing +@testable import MarkdownEngine + +@MainActor +@Suite("Interceptor edits keep the storage form in sync") +struct InterceptorStorageSyncTests { + + /// Editor wired like production: coordinator as delegate, storage-sync + /// state seeded as rebuildTextStorageAndStyle would. + private func makeEditor(text: String) -> (NativeTextView, NativeTextViewCoordinator) { + _ = NSApplication.shared // selection-change path reads NSApp.currentEvent + let textView = NativeTextView(frame: NSRect(x: 0, y: 0, width: 800, height: 600)) + textView.isEditable = true + textView.configuration = .default + let coordinator = NativeTextViewCoordinator( + text: .constant(text), + fontName: "SF Pro Text", + fontSize: 14, + isWikiLinkActive: .constant(false), + onLinkClick: nil, + onInlineSelectionChange: nil + ) + coordinator.textView = textView + textView.delegate = coordinator + textView.string = text + coordinator.lastSyncedText = text + coordinator.lastComputedStorage = text + coordinator.previousDisplayLength = (text as NSString).length + return (textView, coordinator) + } + + // "->" arrow substitution: same length delta, different location — the + // stale descriptor made the splice an identity op that kept "- " forever. + @Test func arrowSubstitutionSyncsStorage() { + let (tv, coord) = makeEditor(text: "abc - def") + tv.setSelectedRange(NSRange(location: 5, length: 0)) // right after "-" + + tv.insertText(">", replacementRange: NSRange(location: 5, length: 0)) + + #expect(tv.string == "abc → def") + #expect(coord.lastComputedStorage == "abc → def") + } + + // Tab list-indent: the edit lands at the line start, not at the caret the + // suppressed keystroke described. + @Test func tabIndentSyncsStorage() { + let (tv, coord) = makeEditor(text: "- hello") + tv.setSelectedRange(NSRange(location: 7, length: 0)) // end of the item + + tv.insertText("\t", replacementRange: NSRange(location: 7, length: 0)) + + #expect(tv.string == "\t- hello") + #expect(coord.lastComputedStorage == "\t- hello") + } +} diff --git a/Tests/MarkdownEngineTests/TableImageCacheTests.swift b/Tests/MarkdownEngineTests/TableImageCacheTests.swift index 0ca30605..6dca5993 100644 --- a/Tests/MarkdownEngineTests/TableImageCacheTests.swift +++ b/Tests/MarkdownEngineTests/TableImageCacheTests.swift @@ -13,7 +13,10 @@ import Testing @Suite("Table image cache") struct TableImageCacheTests { - private func makeContext(for source: String) -> MarkdownStyler.StylingContext { + private func makeContext( + for source: String, + configuration: MarkdownEditorConfiguration = .default + ) -> MarkdownStyler.StylingContext { let font = NSFont.systemFont(ofSize: 15) return MarkdownStyler.StylingContext( nsText: source as NSString, @@ -25,7 +28,7 @@ struct TableImageCacheTests { baseDefaultLineHeight: 18, codeBackgroundColor: .windowBackgroundColor, latexMarkerFont: font, - configuration: .default, + configuration: configuration, wikiLinkIDProvider: { _ in nil } ) } @@ -56,4 +59,47 @@ struct TableImageCacheTests { #expect(darkResult.rendered) } + + // The key must cover every color renderTable draws with — mutedText paints + // the border and header fill, so a theme differing only there is a miss. + @Test func mutedTextChangeRendersFresh() throws { + let source = "| epsilon | zeta |\n|---|---|\n| 7 | 8 |" + let parsed = try #require(MarkdownStyler.parseTableSource(source)) + let aqua = try #require(NSAppearance(named: .aqua)) + + var themed = MarkdownEditorConfiguration.default + themed.theme.mutedText = .systemPink + + _ = MarkdownStyler.tableImage(for: source, parsed: parsed, ctx: makeContext(for: source), appearance: aqua) + let repainted = MarkdownStyler.tableImage( + for: source, parsed: parsed, + ctx: makeContext(for: source, configuration: themed), appearance: aqua + ) + + #expect(repainted.rendered) + } + + // NSColor descriptions are not identities: two named dynamic colors sharing + // a name describe identically. The key must use resolved components instead. + @Test func sameNamedDynamicColorsDoNotCollide() throws { + let source = "| eta | theta |\n|---|---|\n| 9 | 10 |" + let parsed = try #require(MarkdownStyler.parseTableSource(source)) + let aqua = try #require(NSAppearance(named: .aqua)) + + var blueBody = MarkdownEditorConfiguration.default + blueBody.theme.bodyText = NSColor(name: "body") { _ in .systemBlue } + var redBody = MarkdownEditorConfiguration.default + redBody.theme.bodyText = NSColor(name: "body") { _ in .systemRed } + + _ = MarkdownStyler.tableImage( + for: source, parsed: parsed, + ctx: makeContext(for: source, configuration: blueBody), appearance: aqua + ) + let redRender = MarkdownStyler.tableImage( + for: source, parsed: parsed, + ctx: makeContext(for: source, configuration: redBody), appearance: aqua + ) + + #expect(redRender.rendered) + } } From 9a5a78a6e0bbb78bacbd7774e289979e5094feed Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Sat, 11 Jul 2026 18:17:17 +0200 Subject: [PATCH 09/39] perf(parse): descriptor-driven incremental parse state, O(edit) backtick census Live profiling decomposed the remaining ~6.5ms parse at 139k chars: 59% was one prefix/suffix diff scan run TWICE (BlockParser and the tokenizer each did their own), plus a full-document String == in the parse-cache verify that cost ~6ms per keystroke on its own. - DocumentParseState: per-editor buffer + blocks + tokens evolve under ONE edit descriptor; steady-state typing splices the UTF-16 buffer in O(edit) and re-tokenizes only the touched block window. Trust gate: exactly one proposed edit per cycle; anything else (interceptor substitutions, IME commits, WT batches) falls back to a single shared scan, and a sampled DEBUG assert re-extracts every 64th keystroke. - The state publishes each result into the static BlockParser/tokenizer memos, so the restyle's DocumentAST.parse hits via memcmp instead of re-splicing against a one-keystroke-stale cache (~1.5ms saved). - parsedDocument's verify compares via NSString.isEqual (byte compare) instead of bridged String == (~6ms -> <1ms), and the selection-change pre-parse hands the pending descriptor through. - Backtick census updates from the edited window only: the greedy count is exactly the sum of floor(runLen/3) over maximal backtick runs, so expanding the window through adjacent backticks composes exactly. Reseeded per document; IME compositions force a full rescan. - Table image cache: resolve theme colors once per theme+appearance (identity-memoized), not six times per table per keystroke. Fuzz suite: after every random edit (descriptor-driven, scan-driven, widened descriptors) tokens must equal a from-scratch parse; census composition fuzzed incl. cross-boundary fence merges. Measured at 139k chars (Debug): total 10.5 -> 3.6-4.8ms per keystroke (parse 6.0 -> 0.7-0.95, backtick 0.95 -> 0.00, restyle 3.5 -> 2.0-2.4). Co-Authored-By: Claude Fable 5 --- .../MarkdownEngine/Parser/BlockParser.swift | 80 ++++++--- .../Parser/BlockScopedTokenizer.swift | 44 +++-- .../Parser/DocumentParseState.swift | 143 +++++++++++++++ .../Parser/MarkdownDetection.swift | 27 +++ .../Styling/MarkdownStyler+Tables.swift | 58 ++++-- .../NativeTextViewCoordinator+Restyling.swift | 19 +- ...tiveTextViewCoordinator+TextDelegate.swift | 70 +++++++- .../NativeTextViewCoordinator.swift | 16 ++ .../NativeTextView/NativeTextView.swift | 2 + .../ParseIncrementalEquivalenceTests.swift | 169 ++++++++++++++++++ 10 files changed, 569 insertions(+), 59 deletions(-) create mode 100644 Sources/MarkdownEngine/Parser/DocumentParseState.swift create mode 100644 Tests/MarkdownEngineTests/ParseIncrementalEquivalenceTests.swift diff --git a/Sources/MarkdownEngine/Parser/BlockParser.swift b/Sources/MarkdownEngine/Parser/BlockParser.swift index c2d0b536..a313f053 100644 --- a/Sources/MarkdownEngine/Parser/BlockParser.swift +++ b/Sources/MarkdownEngine/Parser/BlockParser.swift @@ -41,6 +41,17 @@ struct Block: Equatable { let range: NSRange } +/// A resolved contiguous change between two buffer states, in UTF-16 units. +/// `changeStart ..< changeEndOld` in the old buffer was replaced by +/// `changeStart ..< changeEndNew` in the new one. The region may be wider +/// than the minimal diff — splice logic only requires containment. +struct BufferDiff { + let changeStart: Int + let changeEndOld: Int + let changeEndNew: Int + let delta: Int +} + enum BlockParser { private static let cacheLock = NSLock() @@ -66,23 +77,35 @@ enum BlockParser { let prevBlocks = cachedBlocks cacheLock.unlock() - // Identical text → return cached (memcmp the buffer, not a slow bridged `String ==`). - if let prevChars, let prevBlocks, equalBuffers(prevChars, newChars) { - return prevBlocks - } - - // Incremental: reparse only the affected block window, else fall back to a full reparse. - if let prevChars, let prevBlocks, - let (incr, _) = incrementalParse(oldChars: prevChars, oldBlocks: prevBlocks, newChars: newChars, newNS: textNS) { - cacheLock.lock(); cachedChars = newChars; cachedBlocks = incr; cacheLock.unlock() - return incr + let t0 = DispatchTime.now().uptimeNanoseconds + if let prevChars, let prevBlocks { + // Identical text → memcmp hit (the scan below would walk O(doc)). + if equalBuffers(prevChars, newChars) { + PerfTrace.note { "🧱 BlockParser.static EQUAL \(String(format: "%.2f", Double(DispatchTime.now().uptimeNanoseconds - t0) / 1_000_000))ms" } + return prevBlocks + } + if let diff = scanDiff(old: prevChars, new: newChars), + let (incr, _) = incrementalParse(oldChars: prevChars, oldBlocks: prevBlocks, newChars: newChars, newNS: textNS, diff: diff) { + cacheLock.lock(); cachedChars = newChars; cachedBlocks = incr; cacheLock.unlock() + PerfTrace.note { "🧱 BlockParser.static INCR \(String(format: "%.2f", Double(DispatchTime.now().uptimeNanoseconds - t0) / 1_000_000))ms" } + return incr + } } let blocks = computeBlocks(text) cacheLock.lock(); cachedChars = newChars; cachedBlocks = blocks; cacheLock.unlock() + PerfTrace.note { "🧱 BlockParser.static FULL \(String(format: "%.2f", Double(DispatchTime.now().uptimeNanoseconds - t0) / 1_000_000))ms" } return blocks } + /// Adopt an externally computed parse (DocumentParseState publishes its + /// per-keystroke result) so static-path callers — the restyle's + /// DocumentAST.parse above all — take the memcmp hit instead of + /// re-splicing against a one-keystroke-stale cache. + static func seedCache(chars: [unichar], blocks: [Block]) { + cacheLock.lock(); cachedChars = chars; cachedBlocks = blocks; cacheLock.unlock() + } + private static func equalBuffers(_ a: [unichar], _ b: [unichar]) -> Bool { guard a.count == b.count else { return false } if a.isEmpty { return true } @@ -91,6 +114,19 @@ enum BlockParser { } } + /// Common prefix/suffix scan; nil when the buffers are identical. + static func scanDiff(old: [unichar], new: [unichar]) -> BufferDiff? { + let oldLen = old.count, newLen = new.count + var p = 0 + let maxPre = min(oldLen, newLen) + while p < maxPre, old[p] == new[p] { p += 1 } + if p == oldLen, oldLen == newLen { return nil } + var s = 0 + let maxSuf = maxPre - p + while s < maxSuf, old[oldLen - 1 - s] == new[newLen - 1 - s] { s += 1 } + return BufferDiff(changeStart: p, changeEndOld: oldLen - s, changeEndNew: newLen - s, delta: newLen - oldLen) + } + /// Does `[lo, hi)` (± margin for an edit-boundary delimiter) contain a `$$` or ``` that can ripple? static func hasBlockDelimiter(_ buf: [unichar], _ lo: Int, _ hi: Int) -> Bool { var i = max(0, lo - 3) @@ -106,25 +142,21 @@ enum BlockParser { return false } - /// Diff old→new, reparse the affected window, splice between untouched prefix/suffix; nil to fall back to full. - private static func incrementalParse(oldChars o: [unichar], oldBlocks: [Block], newChars n: [unichar], newNS: NSString) -> (blocks: [Block], window: Int)? { + /// Splice-parse against a precomputed change region (descriptor- or scan-derived): + /// reparse the affected block window, splice between untouched prefix/suffix; nil to fall back to full. + static func incrementalParse(oldChars o: [unichar], oldBlocks: [Block], newChars n: [unichar], newNS: NSString, diff: BufferDiff) -> (blocks: [Block], window: Int)? { guard !oldBlocks.isEmpty else { return nil } let oldLen = o.count, newLen = n.count guard oldLen > 0, newLen > 0 else { return nil } - // 1. Common prefix/suffix over the cached UTF-16 buffers (no re-extract). - var p = 0 - let maxPre = min(oldLen, newLen) - while p < maxPre, o[p] == n[p] { p += 1 } - var s = 0 - let maxSuf = maxPre - p - while s < maxSuf, o[oldLen - 1 - s] == n[newLen - 1 - s] { s += 1 } - let delta = newLen - oldLen - let changeStart = p - let changeEnd = oldLen - s // [changeStart, changeEnd) in old + let delta = diff.delta + let changeStart = diff.changeStart + let changeEnd = diff.changeEndOld // [changeStart, changeEnd) in old + guard changeStart >= 0, changeEnd <= oldLen, diff.changeEndNew <= newLen, + changeStart <= changeEnd, changeStart <= diff.changeEndNew else { return nil } // A fence/block-LaTeX delimiter in the edit can pair with a distant partner → full reparse. - if hasBlockDelimiter(o, changeStart, changeEnd) || hasBlockDelimiter(n, changeStart, newLen - s) { + if hasBlockDelimiter(o, changeStart, changeEnd) || hasBlockDelimiter(n, changeStart, diff.changeEndNew) { return nil } @@ -173,7 +205,7 @@ enum BlockParser { return (result, reparsed.count) } - private static func computeBlocks(_ text: String) -> [Block] { + static func computeBlocks(_ text: String) -> [Block] { let nsText = text as NSString let length = nsText.length guard length > 0 else { return [] } diff --git a/Sources/MarkdownEngine/Parser/BlockScopedTokenizer.swift b/Sources/MarkdownEngine/Parser/BlockScopedTokenizer.swift index 18e83e90..89dc5dd9 100644 --- a/Sources/MarkdownEngine/Parser/BlockScopedTokenizer.swift +++ b/Sources/MarkdownEngine/Parser/BlockScopedTokenizer.swift @@ -27,6 +27,10 @@ extension MarkdownTokenizer { /// The live tokenizer: block-level tokens + inline AST tokens; fenced code emits only its code-block token. static func parseTokensViaAST(in text: String) -> [MarkdownToken] { + let t0 = DispatchTime.now().uptimeNanoseconds + defer { + PerfTrace.note { "🗜️ tokenizer.static \(String(format: "%.2f", Double(DispatchTime.now().uptimeNanoseconds - t0) / 1_000_000))ms" } + } let ns = text as NSString let newLen = ns.length var newChars = [unichar](repeating: 0, count: newLen) @@ -40,9 +44,13 @@ extension MarkdownTokenizer { tokensLock.unlock() let result: [MarkdownToken] - if let prevChars, let prevTokens, - let (incr, _) = incrementalTokens(oldChars: prevChars, prevTokens: prevTokens, newChars: newChars, blocks: blocks, ns: ns) { - result = incr + if let prevChars, let prevTokens { + if let diff = BlockParser.scanDiff(old: prevChars, new: newChars) { + result = incrementalTokens(oldChars: prevChars, prevTokens: prevTokens, newChars: newChars, blocks: blocks, ns: ns, diff: diff)?.tokens + ?? fullTokens(blocks: blocks, ns: ns) + } else { + result = prevTokens // identical text + } } else { result = fullTokens(blocks: blocks, ns: ns) } @@ -51,7 +59,14 @@ extension MarkdownTokenizer { return result } - private static func fullTokens(blocks: [Block], ns: NSString) -> [MarkdownToken] { + /// Adopt an externally computed parse (DocumentParseState publishes its + /// per-keystroke result) so static-path callers hit instead of re-splicing + /// against a one-keystroke-stale cache. + static func seedCache(chars: [unichar], tokens: [MarkdownToken]) { + tokensLock.lock(); cachedTokenChars = chars; cachedTokens = tokens; tokensLock.unlock() + } + + static func fullTokens(blocks: [Block], ns: NSString) -> [MarkdownToken] { var result: [MarkdownToken] = [] for block in blocks { let delta = block.range.location @@ -61,22 +76,19 @@ extension MarkdownTokenizer { return result } - /// Reuse prefix/suffix tokens (suffix shifted) and re-tokenize only touched blocks; nil to fall back to full. - private static func incrementalTokens(oldChars o: [unichar], prevTokens: [MarkdownToken], newChars n: [unichar], blocks: [Block], ns: NSString) -> (tokens: [MarkdownToken], retok: Int)? { + /// Reuse prefix/suffix tokens (suffix shifted) and re-tokenize only touched blocks, + /// against a precomputed change region; nil to fall back to full. + static func incrementalTokens(oldChars o: [unichar], prevTokens: [MarkdownToken], newChars n: [unichar], blocks: [Block], ns: NSString, diff: BufferDiff) -> (tokens: [MarkdownToken], retok: Int)? { let oldLen = o.count, newLen = n.count guard oldLen > 0, newLen > 0, !blocks.isEmpty else { return nil } - var p = 0 - let maxPre = min(oldLen, newLen) - while p < maxPre, o[p] == n[p] { p += 1 } - var s = 0 - let maxSuf = maxPre - p - while s < maxSuf, o[oldLen - 1 - s] == n[newLen - 1 - s] { s += 1 } - let delta = newLen - oldLen - let changeStart = p, changeEndNew = newLen - s + let delta = diff.delta + let changeStart = diff.changeStart, changeEndNew = diff.changeEndNew + guard changeStart >= 0, diff.changeEndOld <= oldLen, changeEndNew <= newLen, + changeStart <= diff.changeEndOld, changeStart <= changeEndNew else { return nil } // A fence/block-LaTeX delimiter can pair with a distant partner and ripple far → full tokenization. - if BlockParser.hasBlockDelimiter(o, changeStart, oldLen - s) + if BlockParser.hasBlockDelimiter(o, changeStart, diff.changeEndOld) || BlockParser.hasBlockDelimiter(n, changeStart, changeEndNew) { return nil } // New blocks touching the changed char range [changeStart, changeEndNew]. @@ -118,7 +130,7 @@ extension MarkdownTokenizer { } /// Cached block-relative tokens for `sub` (computed on miss); a pure memo over the token logic. - private static func cachedBlockTokens(kind: BlockKind, sub: String) -> [MarkdownToken] { + static func cachedBlockTokens(kind: BlockKind, sub: String) -> [MarkdownToken] { blockTokenLock.lock() if let cached = blockTokenCache[sub] { blockTokenLock.unlock() diff --git a/Sources/MarkdownEngine/Parser/DocumentParseState.swift b/Sources/MarkdownEngine/Parser/DocumentParseState.swift new file mode 100644 index 00000000..948d7ed6 --- /dev/null +++ b/Sources/MarkdownEngine/Parser/DocumentParseState.swift @@ -0,0 +1,143 @@ +// +// DocumentParseState.swift +// MarkdownEngine +// +// Created by Luca Chen on 11.07.26. +// +// Per-editor incremental parse state: one UTF-16 buffer, its block list, and +// its token list evolve together under a single edit descriptor. A keystroke +// then pays one O(edit) buffer splice and a block-window re-tokenize instead +// of a full-document re-extraction plus two independent O(doc) prefix/suffix +// diff scans (BlockParser and the tokenizer each ran their own). +// + +import Foundation + +/// A contiguous edit in NEW-text coordinates plus the length delta, as +/// delivered by shouldChangeTextIn/textDidChange. The described region may be +/// wider than the minimal diff — splice logic only requires containment. +struct ParseEditDescriptor { + let editedRange: NSRange // post-edit coords: location + replacement length + let delta: Int +} + +final class DocumentParseState { + private let lock = NSLock() + private var chars: [unichar] = [] + private var blocks: [Block] = [] + private var tokens: [MarkdownToken] = [] + private var valid = false +#if DEBUG + private var verifyCounter: UInt = 0 +#endif + + /// Drop all state (document switch / full rebuild) — the next parse + /// re-extracts and re-parses from scratch. + func invalidate() { + lock.lock() + valid = false + chars = []; blocks = []; tokens = [] + lock.unlock() + } + + /// Tokens for `text`. With a trustworthy `edit` the update is + /// O(edit + touched blocks + suffix shift); without one, a single shared + /// O(doc) diff scan replaces the two independent scans of the static path. + func tokens(for text: String, edit: ParseEditDescriptor?) -> [MarkdownToken] { + let tStart = DispatchTime.now().uptimeNanoseconds + let ns = text as NSString + let newLen = ns.length + + lock.lock() + let prevChars = chars + let prevBlocks = blocks + let prevTokens = tokens + let wasValid = valid + lock.unlock() + + // 1. New buffer + change region — spliced O(edit) when the descriptor + // passes every sanity check, extracted O(doc) otherwise. + var newChars: [unichar] + var diff: BufferDiff? + if wasValid, let edit, + edit.delta != Int.min, + edit.editedRange.location != NSNotFound, + edit.editedRange.location >= 0, edit.editedRange.length >= 0, + NSMaxRange(edit.editedRange) <= newLen, + edit.editedRange.length - edit.delta >= 0, + prevChars.count == newLen - edit.delta { + let changeStart = edit.editedRange.location + let changeEndNew = NSMaxRange(edit.editedRange) + let changeEndOld = changeEndNew - edit.delta + var replacement = [unichar](repeating: 0, count: edit.editedRange.length) + if edit.editedRange.length > 0 { ns.getCharacters(&replacement, range: edit.editedRange) } + newChars = prevChars + newChars.replaceSubrange(changeStart.. 0 { ns.getCharacters(&fresh, range: NSRange(location: 0, length: newLen)) } + assert(fresh == newChars, "spliced parse buffer diverged from the text storage") + } +#endif + } else { + var buffer = [unichar](repeating: 0, count: newLen) + if newLen > 0 { ns.getCharacters(&buffer, range: NSRange(location: 0, length: newLen)) } + newChars = buffer + if wasValid { + diff = BlockParser.scanDiff(old: prevChars, new: newChars) + if diff == nil, prevChars.count == newLen { + return prevTokens // identical text + } + } + } + + let tBlocks = DispatchTime.now().uptimeNanoseconds + + // 2. Blocks: window splice on the shared diff, full reparse fallback. + var newBlocks: [Block]? + if wasValid, let diff { + newBlocks = BlockParser.incrementalParse( + oldChars: prevChars, oldBlocks: prevBlocks, + newChars: newChars, newNS: ns, diff: diff + )?.blocks + } + let resolvedBlocks = newBlocks ?? BlockParser.computeBlocks(text) + let tTokens = DispatchTime.now().uptimeNanoseconds + + // 3. Tokens: prefix/suffix reuse on the same diff, full fallback. + var newTokens: [MarkdownToken]? + if wasValid, let diff, newBlocks != nil { + newTokens = MarkdownTokenizer.incrementalTokens( + oldChars: prevChars, prevTokens: prevTokens, + newChars: newChars, blocks: resolvedBlocks, ns: ns, diff: diff + )?.tokens + } + let resolvedTokens = newTokens ?? MarkdownTokenizer.fullTokens(blocks: resolvedBlocks, ns: ns) + let tEnd = DispatchTime.now().uptimeNanoseconds + PerfTrace.note { + let path = !wasValid ? "INVALID" : (edit != nil ? "SPLICE" : "SCAN") + let blocksPath = newBlocks != nil ? "incr" : "FULL" + let tokensPath = newTokens != nil ? "incr" : "FULL" + return "🧩 parseState \(path) blocks=\(blocksPath):\(String(format: "%.2f", Double(tTokens - tBlocks) / 1_000_000))ms tokens=\(tokensPath):\(String(format: "%.2f", Double(tEnd - tTokens) / 1_000_000))ms buffer=\(String(format: "%.2f", Double(tBlocks - tStart) / 1_000_000))ms" + } + + lock.lock() + chars = newChars + blocks = resolvedBlocks + tokens = resolvedTokens + valid = true + lock.unlock() + + // Publish to the static memos so their callers (restyle's + // DocumentAST.parse, smart-input helpers) take the memcmp hit instead + // of splicing against a one-keystroke-stale cache every time. + BlockParser.seedCache(chars: newChars, blocks: resolvedBlocks) + MarkdownTokenizer.seedCache(chars: newChars, tokens: resolvedTokens) + return resolvedTokens + } +} diff --git a/Sources/MarkdownEngine/Parser/MarkdownDetection.swift b/Sources/MarkdownEngine/Parser/MarkdownDetection.swift index e54e8ed4..afe72d78 100644 --- a/Sources/MarkdownEngine/Parser/MarkdownDetection.swift +++ b/Sources/MarkdownEngine/Parser/MarkdownDetection.swift @@ -116,6 +116,33 @@ enum MarkdownDetection { return count } + /// The ``` count contributed by the backtick runs that intersect `range`. + /// The window expands through adjacent backticks on both sides, so every + /// run inside it is a MAXIMAL run of the whole text — and the greedy global + /// count is exactly Σ floor(runLen/3) over maximal runs, which makes these + /// window counts composable: full = fullBefore − windowBefore + windowAfter. + static func backtickWindowCount(in text: NSString, around range: NSRange) -> Int { + let length = text.length + guard range.location >= 0, NSMaxRange(range) <= length else { return 0 } + var lo = range.location + while lo > 0, text.character(at: lo - 1) == 0x60 { lo -= 1 } + var hi = NSMaxRange(range) + while hi < length, text.character(at: hi) == 0x60 { hi += 1 } + var count = 0 + var run = 0 + var i = lo + while i < hi { + if text.character(at: i) == 0x60 { + run += 1 + } else { + count += run / 3 + run = 0 + } + i += 1 + } + return count + run / 3 + } + // MARK: - LaTeX Detection static func isInsideLatex(location: Int, in text: String) -> Bool { diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift index 193162f0..b03d2473 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift @@ -49,19 +49,32 @@ extension MarkdownStyler { c.redComponent, c.greenComponent, c.blueComponent, c.alphaComponent) } - /// Returns the rendered image for `source`, from cache when possible. - /// `rendered` is true only when a fresh render actually happened. - static func tableImage( - for source: String, - parsed: ParsedTable, - ctx: StylingContext, - appearance: NSAppearance - ) -> (image: NSImage, rendered: Bool) { + /// Resolving six colors per table per keystroke is measurable (10 tables × + /// 6 appearance-scoped resolutions). The resolved prefix depends only on + /// the color INSTANCES + appearance + font, so memoize it by identity — + /// theme copies keep the same NSColor references across keystrokes. + private static let themeKeyLock = NSLock() + private static var themeKeyCache: [String: String] = [:] + + private static func themeKeyPrefix(ctx: StylingContext, appearance: NSAppearance) -> String { + let theme = ctx.configuration.theme + let identity = "\(ctx.baseFont.fontName)|\(ctx.baseFont.pointSize)|\(appearance.name.rawValue)|" + + "\(ObjectIdentifier(theme.bodyText))|\(ObjectIdentifier(theme.mutedText))|" + + "\(ObjectIdentifier(theme.highlightColor))|\(ObjectIdentifier(ctx.codeBackgroundColor))|" + + "\(ObjectIdentifier(theme.latexLightModeText))|\(ObjectIdentifier(theme.latexDarkModeText))|" + + "\(ObjectIdentifier(type(of: ctx.services.latex)))" + + themeKeyLock.lock() + if let cached = themeKeyCache[identity] { + themeKeyLock.unlock() + return cached + } + themeKeyLock.unlock() + // Every input renderTable reads must be in the key: fonts, all theme // colors it draws with, and the latex renderer (by type — a NoOp and a // real renderer must not share entries). - let theme = ctx.configuration.theme - let key = [ + let prefix = [ ctx.baseFont.fontName, "\(ctx.baseFont.pointSize)", appearance.name.rawValue, @@ -72,8 +85,24 @@ extension MarkdownStyler { colorKey(theme.latexLightModeText, under: appearance), colorKey(theme.latexDarkModeText, under: appearance), "\(ObjectIdentifier(type(of: ctx.services.latex)))", - source, - ].joined(separator: "|") as NSString + ].joined(separator: "|") + + themeKeyLock.lock() + if themeKeyCache.count > 32 { themeKeyCache.removeAll() } + themeKeyCache[identity] = prefix + themeKeyLock.unlock() + return prefix + } + + /// Returns the rendered image for `source`, from cache when possible. + /// `rendered` is true only when a fresh render actually happened. + static func tableImage( + for source: String, + parsed: ParsedTable, + ctx: StylingContext, + appearance: NSAppearance + ) -> (image: NSImage, rendered: Bool) { + let key = (themeKeyPrefix(ctx: ctx, appearance: appearance) + "|" + source) as NSString if let cached = tableImageCache.object(forKey: key) { return (cached, false) } @@ -95,6 +124,7 @@ extension MarkdownStyler { var occurrenceByContentHash: [Int: Int] = [:] var tableCount = 0 var renderedCount = 0 + var tableTrace: [String] = [] // per-table: loc/state/height (Debug diagnosis) let tablesT0 = DispatchTime.now().uptimeNanoseconds for (idx, token) in ctx.tokens.enumerated() where token.kind == .table { tableCount += 1 @@ -111,6 +141,7 @@ extension MarkdownStyler { let isActive = ctx.activeTokenIndices.contains(idx) if isActive { + tableTrace.append("@\(token.range.location):ACTIVE-RAW") // Caret inside the table — show editable source, pipes muted like other syntax. let muted = ctx.configuration.theme.mutedText let body = ctx.configuration.theme.bodyText @@ -137,6 +168,7 @@ extension MarkdownStyler { appearance: renderAppearance ) if rendered { renderedCount += 1 } + tableTrace.append("@\(token.range.location):img h=\(Int(image.size.height))\(rendered ? " FRESH" : "")") let imageBounds = CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height) // Wide tables → scrollable mode (NSScrollView overlay); narrow → collapsed. let containerWidth = effectiveContainerWidth(for: ctx) @@ -167,7 +199,7 @@ extension MarkdownStyler { } if tableCount > 0 { let ms = Double(DispatchTime.now().uptimeNanoseconds - tablesT0) / 1_000_000 - PerfTrace.note { "styleTables scanned=\(tableCount) tables, re-rendered=\(renderedCount) NSImage in \(String(format: "%.2f", ms))ms" } + PerfTrace.note { "styleTables scanned=\(tableCount) tables, re-rendered=\(renderedCount) NSImage in \(String(format: "%.2f", ms))ms | \(tableTrace.joined(separator: " "))" } } return attrs } diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift index 988cbe89..0c1a326b 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift @@ -41,6 +41,13 @@ extension NativeTextViewCoordinator { lastComputedStorage = text previousDisplayLength = (displayText as NSString).length let nsDisplay = displayText as NSString + // Fresh document baseline: drop the incremental parse state and reseed + // the backtick census (a stale count from the previous document would + // force a spurious full-document restyle on the first keystroke). + parseState.invalidate() + pendingBacktickWindow = nil + backtickCensusNeedsRescan = false + previousBacktickCount = MarkdownDetection.tripleBacktickCount(in: nsDisplay) let fullRange = NSRange(location: 0, length: nsDisplay.length) let (baseFont, paragraph) = TextStylingService.makeBaseFontAndStyle( @@ -152,22 +159,28 @@ extension NativeTextViewCoordinator { } } - func parsedDocument(for text: String) -> ParsedDocument { + func parsedDocument(for text: String, edit: ParseEditDescriptor? = nil) -> ParsedDocument { + let t0 = DispatchTime.now().uptimeNanoseconds let length = (text as NSString).length if let cachedParsedDocument, cachedParsedLength == length { // O(1) hit: nothing has edited the storage since the cached parse. if cachedParseGeneration == parseGeneration { + PerfTrace.note { "📊 parsedDoc GEN-HIT" } return cachedParsedDocument } // Generation moved but the text may still be identical (e.g. an // attribute-only pass): verify once, then it's O(1) again. - if let cachedParsedText, cachedParsedText == text { + // NSString.isEqual is a byte compare; the bridged Swift `==` walked + // the 139k text character-wise at ~6ms per keystroke. + if let cachedParsedText, (cachedParsedText as NSString).isEqual(to: text) { cachedParseGeneration = parseGeneration + PerfTrace.note { "📊 parsedDoc VERIFY-HIT \(String(format: "%.2f", Double(DispatchTime.now().uptimeNanoseconds - t0) / 1_000_000))ms" } return cachedParsedDocument } } - let tokens = MarkdownTokenizer.parseTokensViaAST(in: text) + let tokens = parseState.tokens(for: text, edit: edit) + PerfTrace.note { "📊 parsedDoc MISS edit=\(edit != nil) stateTokens=\(String(format: "%.2f", Double(DispatchTime.now().uptimeNanoseconds - t0) / 1_000_000))ms" } var codeTokens: [MarkdownToken] = [] var latexTokens: [MarkdownToken] = [] var blockLatexTokens: [MarkdownToken] = [] diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift index 63564517..60805966 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift @@ -117,6 +117,11 @@ extension NativeTextViewCoordinator { // paragraph scoping below share it. let editedRange = pendingEditedRange ?? tv.textStorage?.editedRange ?? safeSelRange pendingEditedRange = nil + // Exactly one proposed edit since the last completed cycle means the + // descriptor describes THIS transition; anything else (interceptor + // substitutions, IME commits, WT batches) distrusts the fast paths. + let singleTrackedEdit = pendingEditCount == 1 + pendingEditCount = 0 let lengthDelta = previousDisplayLength >= 0 ? fullLength - previousDisplayLength : Int.min previousDisplayLength = fullLength @@ -185,11 +190,18 @@ extension NativeTextViewCoordinator { nextParagraph ] + editedParagraphs - let backtickCount = PerfTrace.measure("backtick") { MarkdownDetection.tripleBacktickCount(in: fullText) } + let backtickCount = PerfTrace.measure("backtick") { + incrementalBacktickCensus(fullText: fullText, editedRange: editedRange, + lengthDelta: lengthDelta, trusted: singleTrackedEdit) + } let codeBlockStructureChanged = backtickCount != previousBacktickCount previousBacktickCount = backtickCount - let parsed = PerfTrace.measure("parse") { parsedDocument(for: docString) } + let parsed = PerfTrace.measure("parse") { + parsedDocument(for: docString, edit: singleTrackedEdit + ? ParseEditDescriptor(editedRange: editedRange, delta: lengthDelta) + : nil) + } let tokens = parsed.tokens let codeTokens = parsed.codeTokens let latexTokens = parsed.latexTokens @@ -232,6 +244,9 @@ extension NativeTextViewCoordinator { .filter { $0.kind == .table && NSIntersectionRange($0.range, safeEditedRange).length > 0 } .map { fullText.paragraphRange(for: $0.range) } effectiveParagraphCandidates.append(contentsOf: editedTableParagraphs) + if !editedTableParagraphs.isEmpty { + PerfTrace.note { "📐 TABLE-RESTYLE blocks=\(editedTableParagraphs.map { "\($0.location)+\($0.length)" }.joined(separator: ",")) editedRange=\(safeEditedRange.location),\(safeEditedRange.length)" } + } effectiveParagraphCandidates.append(contentsOf: tokenRestyleParagraphs( in: fullText, tokens: tokens, @@ -276,7 +291,16 @@ extension NativeTextViewCoordinator { updateSelectionStates(tv) let selLoc = selRange.location - let parsed = parsedDocument(for: tv.string) + // Selection change fires BEFORE textDidChange mid-edit: hand the + // pending descriptor through so this (the keystroke's first post-edit + // parse) splices in O(edit) instead of scanning the whole document. + let selectionEdit: ParseEditDescriptor? = { + guard let pending = pendingEditedRange, pendingEditCount == 1, + previousDisplayLength >= 0 else { return nil } + let delta = (tv.string as NSString).length - previousDisplayLength + return ParseEditDescriptor(editedRange: pending, delta: delta) + }() + let parsed = parsedDocument(for: tv.string, edit: selectionEdit) let tokens = parsed.tokens let codeTokens = parsed.codeTokens let latexTokens = parsed.latexTokens @@ -495,6 +519,37 @@ extension NativeTextViewCoordinator { } } + /// Backtick census in O(edit window): the greedy ``` count equals + /// Σ floor(runLen/3) over maximal backtick runs, so an edit only changes + /// the contribution of runs it touches. `previousBacktickCount` minus the + /// pre-edit window count (captured in shouldChangeTextIn) plus the + /// post-edit window count is exact. Any doubt → full scan. + private func incrementalBacktickCensus(fullText: NSString, editedRange: NSRange, + lengthDelta: Int, trusted: Bool) -> Int { + defer { pendingBacktickWindow = nil } + guard trusted, !backtickCensusNeedsRescan, + let base = pendingBacktickWindow, + lengthDelta != Int.min, + base.location == editedRange.location, + editedRange.length - lengthDelta == base.oldLength, + editedRange.location >= 0, + NSMaxRange(editedRange) <= fullText.length + else { + backtickCensusNeedsRescan = false + return MarkdownDetection.tripleBacktickCount(in: fullText) + } + let newWindow = MarkdownDetection.backtickWindowCount(in: fullText, around: editedRange) + let count = previousBacktickCount - base.oldCount + newWindow +#if DEBUG + backtickVerifyCounter &+= 1 + if backtickVerifyCounter % 64 == 0 { + assert(count == MarkdownDetection.tripleBacktickCount(in: fullText), + "incremental backtick census diverged from the full scan") + } +#endif + return count + } + public func textView(_ textView: NSTextView, shouldChangeTextIn affectedCharRange: NSRange, replacementString: String?) -> Bool { parseGeneration &+= 1 // Refresh the descriptor for EVERY proposed edit — including programmatic @@ -503,6 +558,15 @@ extension NativeTextViewCoordinator { // would otherwise leave the suppressed edit's descriptor behind, and the // wiki splice in textDidChange would corrupt the storage form from it. pendingEditedRange = NSRange(location: affectedCharRange.location, length: replacementString?.utf16.count ?? 0) + pendingEditCount += 1 + // Pre-edit backtick window baseline for the incremental census. + let preNS = textView.string as NSString + if affectedCharRange.location >= 0, NSMaxRange(affectedCharRange) <= preNS.length { + pendingBacktickWindow = (affectedCharRange.location, affectedCharRange.length, + MarkdownDetection.backtickWindowCount(in: preNS, around: affectedCharRange)) + } else { + pendingBacktickWindow = nil + } if isProgrammaticEdit { return true } if isWritingToolsActive { return true } // Raw mode: plain-text editing — no smart Markdown input. diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift index 0c5a00d0..4dff49b0 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift @@ -86,6 +86,18 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate { var previousActiveTokenIndices: Set = [] var wikiLinkMetadata: [WikiLinkService.RangeKey: WikiLinkService.LinkMetadata] = [:] var previousBacktickCount: Int = 0 + /// Backtick census baseline captured in shouldChangeTextIn: the pre-edit + /// window count around the proposed edit, so textDidChange can update the + /// census from the edited window alone instead of rescanning the document. + var pendingBacktickWindow: (location: Int, oldLength: Int, oldCount: Int)? + /// Set when the storage mutated without the census bookkeeping seeing it + /// (IME composition) — forces the next census back to a full scan. + var backtickCensusNeedsRescan = false + /// DEBUG-only sampling counter for verifying the incremental census. + var backtickVerifyCounter: UInt = 0 + /// Incremental parse state for this editor (buffer + blocks + tokens + /// evolve together under the edit descriptor). + let parseState = DocumentParseState() /// Display-text length after the previous textDidChange — yields the edit's /// length delta without retaining the previous text. @@ -98,6 +110,10 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate { var wikiVerifyCounter: UInt = 0 var pendingEditedRange: NSRange? = nil + /// Proposed-edit cycles since the last completed textDidChange. Exactly 1 + /// means the hoisted editedRange/lengthDelta describe a single tracked + /// edit and incremental fast paths may trust them. + var pendingEditCount = 0 var pendingPreEditActiveTokenIndices: Set? = nil var previousCaretLocation: Int? = nil /// Drag-select suppressed a restyle; replayed on the next non-drag selection change. diff --git a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView.swift b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView.swift index e8569276..c263d415 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView.swift @@ -94,6 +94,8 @@ final class NativeTextView: NSTextView { // current generation — bump so the restyle below reparses instead of // serving that stale document (same-length composition updates). coord.parseGeneration &+= 1 + // Census bookkeeping never saw this mutation → next census full-scans. + coord.backtickCensusNeedsRescan = true let nsText = self.string as NSString let paragraph = nsText.paragraphRange(for: marked) coord.restyleParagraphs([paragraph], in: self) diff --git a/Tests/MarkdownEngineTests/ParseIncrementalEquivalenceTests.swift b/Tests/MarkdownEngineTests/ParseIncrementalEquivalenceTests.swift new file mode 100644 index 00000000..b1b0a350 --- /dev/null +++ b/Tests/MarkdownEngineTests/ParseIncrementalEquivalenceTests.swift @@ -0,0 +1,169 @@ +// +// ParseIncrementalEquivalenceTests.swift +// MarkdownEngine +// +// Created by Luca Chen on 11.07.26. +// +// Differential fuzz for the incremental parse pipeline: after every random +// edit, DocumentParseState (descriptor-driven and scan-driven) must produce +// tokens identical to a from-scratch full parse, and the incremental backtick +// census must equal the full scan. The incremental paths may fall back — +// equivalence is the only contract. +// + +import Foundation +import Testing +@testable import MarkdownEngine + +@Suite("Incremental parse ≡ full parse") +struct ParseIncrementalEquivalenceTests { + + // Deterministic PRNG (SplitMix64) — reproducible failures. + private struct Rng { + var state: UInt64 + mutating func next() -> UInt64 { + state &+= 0x9E3779B97F4A7C15 + var z = state + z = (z ^ (z >> 30)) &* 0xBF58476D1CE4E5B9 + z = (z ^ (z >> 27)) &* 0x94D049BB133111EB + return z ^ (z >> 31) + } + mutating func int(_ upper: Int) -> Int { upper <= 0 ? 0 : Int(next() % UInt64(upper)) } + mutating func pick(_ a: [T]) -> T { a[int(a.count)] } + } + + private static let lineTemplates = [ + "Plain prose with **bold** and *italic* text here.", + "# Heading level one", + "## Second heading", + "- list item with `inline code`", + "1. ordered item", + "> a blockquote line", + "| a | b |", "|---|---|", "| 1 | 2 |", + "```swift", "let x = 1", "```", + "$$", "E = mc^2", "$$", + "A [[Wiki Link]] and ==highlight== and ~~strike~~.", + "Inline $x^2$ latex and an ![[embed.png]] image.", + "", + ] + + private static let editSnippets = [ + "x", "ab", " ", "\n", "`", "``", "```", "$", "$$", "**", "- ", "# ", + "| c |", "[[N]]", "\n\n", "word and more", + ] + + private func makeDoc(_ rng: inout Rng, lines: Int) -> String { + var out: [String] = [] + for _ in 0.. [String] { + tokens.map { + "\($0.kind)|\($0.range)|\($0.contentRange)|\($0.markerRanges.map { r in "\(r)" }.joined(separator: ","))" + } + } + + private func groundTruth(_ text: String) -> [String] { + let ns = text as NSString + return dump(MarkdownTokenizer.fullTokens(blocks: BlockParser.computeBlocks(text), ns: ns)) + } + + private func runFuzz(seed: UInt64, useDescriptor: Bool, widen: Bool = false) { + var rng = Rng(state: seed) + let state = DocumentParseState() + var text = makeDoc(&rng, lines: 40) + + // Seed the state with the initial document. + _ = state.tokens(for: text, edit: nil) + + for step in 0..<250 { + let ns = NSMutableString(string: text) + let loc = rng.int(ns.length + 1) + let removeLen = min(rng.int(6), ns.length - loc) + let insert = rng.int(4) == 0 ? "" : rng.pick(Self.editSnippets) + ns.replaceCharacters(in: NSRange(location: loc, length: removeLen), with: insert) + text = ns as String + + var edit: ParseEditDescriptor? + if useDescriptor { + var range = NSRange(location: loc, length: (insert as NSString).length) + if widen { + // A containing region must be just as correct as the minimal one. + let grow = rng.int(3) + let lo = max(0, range.location - grow) + let hi = min(ns.length, NSMaxRange(range) + grow) + range = NSRange(location: lo, length: hi - lo) + } + edit = ParseEditDescriptor(editedRange: range, delta: (insert as NSString).length - removeLen) + } + + let incremental = dump(state.tokens(for: text, edit: edit)) + let full = groundTruth(text) + #expect(incremental == full, + "seed \(seed) step \(step): incremental tokens diverged (edit at \(loc), removed \(removeLen), inserted \(insert.debugDescription))") + if incremental != full { return } // stop at first divergence, keep output readable + } + } + + @Test func descriptorDrivenMatchesFullParse() { + runFuzz(seed: 0xA11CE, useDescriptor: true) + runFuzz(seed: 0xB0B, useDescriptor: true) + } + + @Test func widenedDescriptorMatchesFullParse() { + runFuzz(seed: 0xC0FFEE, useDescriptor: true, widen: true) + } + + @Test func scanDrivenMatchesFullParse() { + runFuzz(seed: 0xD00D, useDescriptor: false) + } + + // MARK: - Backtick census + + @Test func windowCensusComposesExactly() { + // Boundary cases: completing ``` between existing backticks, joining + // fences by deleting the separator, runs of 4/6/7. + let cases: [(before: String, edit: NSRange, insert: String)] = [ + ("a``b", NSRange(location: 2, length: 0), "`"), // `` + ` → ``` + ("```\n```", NSRange(location: 3, length: 1), ""), // join to `````` + ("````x````", NSRange(location: 4, length: 1), "`"), + ("abc", NSRange(location: 1, length: 0), "```"), + ("`````", NSRange(location: 2, length: 1), ""), + ] + for c in cases { + let beforeNS = c.before as NSString + let oldWindow = MarkdownDetection.backtickWindowCount(in: beforeNS, around: c.edit) + let after = beforeNS.replacingCharacters(in: c.edit, with: c.insert) as NSString + let newRange = NSRange(location: c.edit.location, length: (c.insert as NSString).length) + let newWindow = MarkdownDetection.backtickWindowCount(in: after, around: newRange) + let composed = MarkdownDetection.tripleBacktickCount(in: beforeNS) - oldWindow + newWindow + #expect(composed == MarkdownDetection.tripleBacktickCount(in: after), + "census composition failed for \(c.before.debugDescription) + \(c.insert.debugDescription)") + } + } + + @Test func windowCensusFuzz() { + var rng = Rng(state: 0xFACE) + let alphabet = ["`", "a", "\n", "``", "b`"] + var text = (0..<60).map { _ in rng.pick(alphabet) }.joined() + for step in 0..<400 { + let ns = text as NSString + let loc = rng.int(ns.length + 1) + let removeLen = min(rng.int(4), ns.length - loc) + let insert = rng.int(4) == 0 ? "" : rng.pick(alphabet) + let editOld = NSRange(location: loc, length: removeLen) + + let oldWindow = MarkdownDetection.backtickWindowCount(in: ns, around: editOld) + let after = ns.replacingCharacters(in: editOld, with: insert) as NSString + let editNew = NSRange(location: loc, length: (insert as NSString).length) + let newWindow = MarkdownDetection.backtickWindowCount(in: after, around: editNew) + + let composed = MarkdownDetection.tripleBacktickCount(in: ns) - oldWindow + newWindow + #expect(composed == MarkdownDetection.tripleBacktickCount(in: after), "step \(step)") + if composed != MarkdownDetection.tripleBacktickCount(in: after) { return } + text = after as String + } + } +} From be58a94a256215a707b7332e07ccc0c6ae086666 Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Sat, 11 Jul 2026 18:17:17 +0200 Subject: [PATCH 10/39] chore: TEMP table-shift diagnostics (docH/scrollY/vpStart in layout note) Debug-only PerfTrace additions for the table upward-shift hunt; remove with PerfTrace after sign-off. Co-Authored-By: Claude Fable 5 --- .../NativeTextView+FrameAndOverscroll.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift index 8ff197f0..6e7bd830 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift @@ -411,6 +411,12 @@ extension NativeTextView { walked += 1 return fragment.layoutFragmentFrame.minY <= visBot } - PerfTrace.note { "ensureVisibleLayout walked=\(walked) frags from viewport" } + PerfTrace.note { + let vpStart = tlm.textViewportLayoutController.viewportRange + .map { tlm.offset(from: tlm.documentRange.location, to: $0.location) } ?? -1 + let docH = String(format: "%.0f", self.frame.height) + let scrollY = String(format: "%.0f", self.visibleRect.minY) + return "ensureVisibleLayout walked=\(walked) frags | docH=\(docH) scrollY=\(scrollY) vpStart=\(vpStart)" + } } } From 60c945a14147260b617c5239e5617a26cf56a3c3 Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Sat, 11 Jul 2026 21:13:42 +0200 Subject: [PATCH 11/39] fix(layout): walk ensureVisibleLayout from the document head again; settle before caret reveals Reverts the viewport-scoped walk (1ea2712). Every fragment's Y is the sum of the heights above it, so leaving above/below-viewport fragments estimate-only broke the invariant that visible geometry is settled by draw time: TextKit estimates an invalidated table paragraph as a single text line (~250pt short), which made content shift upward while typing, made the height measure bistable (frame pumping 5231<->4735 per keystroke), and fed the caret reveal wrong out-of-view verdicts that actively scrolled up. Live-diagnosed via docH/scrollY/caret traces. The reveal keeps one hardening from that hunt: an out-of-view verdict is re-checked against layout settled up to the caret before scrolling, so genuine jumps (search hits) land on the correct target. Steady-state cost of the head walk is an enumeration over already-laid fragments (~1ms at 139k chars); the big hot-path wins (parse splice, census, table image cache) are layout-neutral and unaffected. Co-Authored-By: Claude Fable 5 --- .../NativeTextView+FrameAndOverscroll.swift | 83 +++++++++++++++---- 1 file changed, 65 insertions(+), 18 deletions(-) diff --git a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift index 6e7bd830..8bdb6818 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift @@ -40,6 +40,11 @@ extension NativeTextView { let baseHeightChanged = abs(measured - baseContentHeight) > 0.5 let overscrollChanged = abs(resolvedOverscroll - activeBottomOverscroll) > 0.5 +#if DEBUG + if baseHeightChanged { + print("📐 recalc[\(debugTag)] base \(Int(baseContentHeight))→\(Int(measured)) over \(Int(activeBottomOverscroll))→\(Int(resolvedOverscroll)) fullLayout=\(pendingFullLayoutMeasure)") + } +#endif // Height settled → stop forcing full layout (until the next switch/resize). if !(baseHeightChanged || overscrollChanged) { pendingFullLayoutMeasure = false } guard baseHeightChanged || overscrollChanged else { return } @@ -140,6 +145,14 @@ extension NativeTextView { } var rawHeight = max(segmentMaxY, fragmentMaxY) +#if DEBUG + // Height-jitter diagnosis: which measurement component moves between + // keystrokes. lastFragY moving with constant content = fragments ABOVE + // shifted (estimated heights above the viewport changed). + let dbgFrag = Int(fragmentMaxY), dbgSeg = Int(segmentMaxY) + let dbgLastY = Int(lastFragmentFrame.minY) + print("📐 measure frag=\(dbgFrag) seg=\(dbgSeg) lastFragY=\(dbgLastY) full=\(forceFullLayout)") +#endif // With a trailing "\n", the last line is TextKit's extra line fragment. // Its metrics follow the final newline's attributes — not the body style a @@ -209,6 +222,15 @@ extension NativeTextView { guard abs(targetSize.width - frame.size.width) > 0.5 || abs(targetSize.height - frame.size.height) > 0.5 else { return } +#if DEBUG + // A frame SHRINK below the current scroll extent forces AppKit to clamp + // the scroll offset — visible as content jumping upward. + let dbgScrollY = enclosingScrollView?.contentView.bounds.origin.y ?? -1 + let dbgViewH = enclosingScrollView?.contentView.bounds.height ?? -1 + let clampRisk = targetSize.height < frame.size.height + && dbgScrollY + dbgViewH > targetSize.height + print("📐 frame h=\(Int(frame.size.height))→\(Int(targetSize.height)) scrollY=\(Int(dbgScrollY)) viewH=\(Int(dbgViewH))\(clampRisk ? " ⚠️CLAMP" : "")") +#endif isApplyingManagedFrameSize = true super.setFrameSize(targetSize) isApplyingManagedFrameSize = false @@ -334,27 +356,51 @@ extension NativeTextView { // Reveal the ACTUAL caret location's segment (not the stepped-back revealOffset), so a // freshly soft-wrapped last line at the document end still scrolls into view; fall back to // the stepped-back offset only when the true end-of-document has no segment of its own. - for segOffset in [min(range.location, docLength), revealOffset] { - guard let segLoc = tlm.textContentManager?.location(tlm.documentRange.location, offsetBy: segOffset) else { continue } - var found = false - tlm.enumerateTextSegments(in: NSTextRange(location: segLoc), type: .standard, options: []) { _, segFrame, _, _ in - if segFrame.height > 0 { revealRect = segFrame; found = true } // caret rect = its line, not the block - return false + func caretSegmentRect(fallback: CGRect) -> CGRect { + var rect = fallback + for segOffset in [min(range.location, docLength), revealOffset] { + guard let segLoc = tlm.textContentManager?.location(tlm.documentRange.location, offsetBy: segOffset) else { continue } + var found = false + tlm.enumerateTextSegments(in: NSTextRange(location: segLoc), type: .standard, options: []) { _, segFrame, _, _ in + if segFrame.height > 0 { rect = segFrame; found = true } // caret rect = its line, not the block + return false + } + if found { break } } - if found { break } + return rect } - let frame = revealRect.offsetBy(dx: 0, dy: self.frame.origin.y) + revealRect = caretSegmentRect(fallback: revealRect) + var frame = revealRect.offsetBy(dx: 0, dy: self.frame.origin.y) let visibleTop = cv.bounds.origin.y + insetsTop let visibleBottom = cv.bounds.origin.y + cv.bounds.height let margin: CGFloat = 24 + if frame.minY < visibleTop || frame.maxY > visibleBottom { + // The caret's Y is the sum of every fragment height above it. + // While invalidated fragments above are estimate-only (a table + // image estimates as one text line, ~250pt short), the caret + // computes far too high and typing "reveals" upward — content + // visibly jumps. Settle layout up to the caret before trusting + // an out-of-view verdict: spurious ones dissolve, and genuine + // jumps get the CORRECT target. Costs nothing when the caret + // is visible (the common per-keystroke case never gets here). + if let end = tlm.textContentManager?.location(tlm.documentRange.location, offsetBy: min(range.location + 1, docLength)), + let settleRange = NSTextRange(location: tlm.documentRange.location, end: end) { + tlm.ensureLayout(for: settleRange) + } + revealRect = caretSegmentRect(fallback: revealRect) + frame = revealRect.offsetBy(dx: 0, dy: self.frame.origin.y) + } let targetY: CGFloat if frame.minY < visibleTop { targetY = frame.minY - insetsTop - margin } else if frame.maxY > visibleBottom { targetY = frame.maxY - cv.bounds.height + margin } else { - return false // already visible + return false // already visible (or a spurious verdict, corrected) } +#if DEBUG + print("🎯 reveal scroll y=\(Int(cv.bounds.origin.y))→\(Int(targetY)) caret=\(Int(frame.minY))..\(Int(frame.maxY)) vis=\(Int(visibleTop))..\(Int(visibleBottom)) loc=\(range.location)") +#endif cv.scroll(to: NSPoint(x: cv.bounds.origin.x, y: targetY)) scrollView.reflectScrolledClipView(cv) (scrollView as? ClampedScrollView)?.clampToInsets() @@ -398,25 +444,26 @@ extension NativeTextView { } /// Force TextKit 2 to lay out all fragments within the current visible rect. + /// Walks from the DOCUMENT HEAD, not the viewport: every fragment's Y is + /// the sum of the heights above it, so anything merely estimated above the + /// viewport shifts the visible content when it settles. Keeping everything + /// up to the viewport settled on every keystroke is the invariant that + /// makes the visible geometry (and the height measure) stable — a + /// viewport-scoped walk (tried as a perf win) caused content shifts, + /// spurious caret reveals, and a bistable frame height. Steady-state cost + /// is an enumeration over already-laid-out fragments. func ensureVisibleLayout() { guard let tlm = textLayoutManager else { return } let visBot = visibleRect.maxY - // Start at the viewport instead of the document head: walking from the - // start forces layout of every fragment above the viewport, making a - // keystroke cost O(caret position in document). - let start = tlm.textViewportLayoutController.viewportRange?.location - ?? tlm.documentRange.location var walked = 0 - tlm.enumerateTextLayoutFragments(from: start, options: [.ensuresLayout]) { fragment in + tlm.enumerateTextLayoutFragments(from: tlm.documentRange.location, options: [.ensuresLayout]) { fragment in walked += 1 return fragment.layoutFragmentFrame.minY <= visBot } PerfTrace.note { - let vpStart = tlm.textViewportLayoutController.viewportRange - .map { tlm.offset(from: tlm.documentRange.location, to: $0.location) } ?? -1 let docH = String(format: "%.0f", self.frame.height) let scrollY = String(format: "%.0f", self.visibleRect.minY) - return "ensureVisibleLayout walked=\(walked) frags | docH=\(docH) scrollY=\(scrollY) vpStart=\(vpStart)" + return "ensureVisibleLayout walked=\(walked) frags | docH=\(docH) scrollY=\(scrollY)" } } } From f7f02b7f274217c6b0a24225b04dd8e7424e7d60 Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Sat, 11 Jul 2026 21:13:42 +0200 Subject: [PATCH 12/39] chore: TEMP flicker/shift diagnostics (scroll watcher, restyle triggers) scrollJump watcher, FULL-RESTYLE/REBUILD/BIG-APPLY prints, reveal print. Debug-only; remove with PerfTrace after sign-off. Co-Authored-By: Claude Fable 5 --- .../Styling/TextStylingService.swift | 7 +++++ .../TextView/ClampedScrollView.swift | 29 +++++++++++++++++++ .../NativeTextViewCoordinator+Restyling.swift | 3 ++ ...tiveTextViewCoordinator+TextDelegate.swift | 5 ++++ 4 files changed, 44 insertions(+) diff --git a/Sources/MarkdownEngine/Styling/TextStylingService.swift b/Sources/MarkdownEngine/Styling/TextStylingService.swift index 1795a624..c563bccb 100644 --- a/Sources/MarkdownEngine/Styling/TextStylingService.swift +++ b/Sources/MarkdownEngine/Styling/TextStylingService.swift @@ -93,6 +93,13 @@ struct TextStylingService { } textView.textStorage?.beginEditing() +#if DEBUG + // Flicker diagnosis: how much of the document does this pass rewrite? + let appliedSpan = paragraphs.reduce(0) { $0 + $1.length } + if paragraphs.count > 10 || appliedSpan > 2500 { + print("🎨 BIG-APPLY paragraphs=\(paragraphs.count) span=\(appliedSpan) of \(textView.textStorage?.length ?? -1)") + } +#endif for disabledRange in spellingDisabledRanges { textView.textStorage?.addAttribute(.spellingState, value: 0, range: disabledRange) } diff --git a/Sources/MarkdownEngine/TextView/ClampedScrollView.swift b/Sources/MarkdownEngine/TextView/ClampedScrollView.swift index f28c8984..10bc96c2 100644 --- a/Sources/MarkdownEngine/TextView/ClampedScrollView.swift +++ b/Sources/MarkdownEngine/TextView/ClampedScrollView.swift @@ -13,6 +13,35 @@ final class ClampedScrollView: NSScrollView { /// own height to SwiftUI and the enclosing scroll view owns paging. var fitsContent: Bool = false +#if DEBUG + // Table-shift diagnosis: name every code path that moves the scroll by a + // jump-sized amount. Wheel/gesture scrolling is continuous (small deltas); + // a >40pt single hop during typing is the bug's signature. + private var lastLoggedScrollY: CGFloat = .nan + private var scrollWatchToken: NSObjectProtocol? + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + guard window != nil, scrollWatchToken == nil else { return } + contentView.postsBoundsChangedNotifications = true + scrollWatchToken = NotificationCenter.default.addObserver( + forName: NSView.boundsDidChangeNotification, object: contentView, queue: nil + ) { [weak self] _ in + guard let self else { return } + let y = self.contentView.bounds.origin.y + defer { self.lastLoggedScrollY = y } + guard !self.lastLoggedScrollY.isNaN, abs(y - self.lastLoggedScrollY) > 40 else { return } + let stack = Thread.callStackSymbols[2...8] + .compactMap { line -> String? in + guard line.contains("MarkdownEngine") || line.contains("Nodes") else { return nil } + return line.split(separator: " ").dropFirst(3).first.map(String.init) + } + .prefix(4) + .joined(separator: " ← ") + print("📜 scrollJump y=\(Int(self.lastLoggedScrollY))→\(Int(y)) via \(stack.isEmpty ? "system/AppKit" : stack)") + } + } +#endif + /// Saved at the start of every live-resize (including spurious one-click resizes triggered by edge-cursor clicks) so the position is restored when the resize ends. Without this, NSScrollView's default top-anchor-during-resize would jolt a bottom-anchored user back up by hundreds of points on a single edge click. private var scrollYBeforeLiveResize: CGFloat? diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift index 0c1a326b..cf615941 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift @@ -19,6 +19,9 @@ extension NativeTextViewCoordinator { from text: String, invalidateLayout: Bool = false ) { +#if DEBUG + print("🏗️ REBUILD len=\((text as NSString).length) invalidateLayout=\(invalidateLayout)") +#endif // Storage is raw Markdown; only wiki links transform on display. // In raw source mode display IS storage — no transform, no metadata. let services = configuration.services diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift index 60805966..c9f9678d 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift @@ -195,6 +195,11 @@ extension NativeTextViewCoordinator { lengthDelta: lengthDelta, trusted: singleTrackedEdit) } let codeBlockStructureChanged = backtickCount != previousBacktickCount +#if DEBUG + if codeBlockStructureChanged { + print("🔄 FULL-RESTYLE trigger: backtickCount \(previousBacktickCount)→\(backtickCount) (editedRange=\(editedRange), delta=\(lengthDelta), trusted=\(singleTrackedEdit))") + } +#endif previousBacktickCount = backtickCount let parsed = PerfTrace.measure("parse") { From 8c8090de5ab0be8fc7baea152330d4139a24e2bd Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Sat, 11 Jul 2026 22:09:06 +0200 Subject: [PATCH 13/39] perf(restyle): scope token passes to the edit, memoize per-keystroke derivations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live profiling of the residual restyle cost (2-2.5ms at 139k, 6-9ms in table docs) found the work was AROUND the already-scoped styler, not in it: - The five NSImage passes (block/inline LaTeX, image links/embeds, tables) walked EVERY document token per keystroke even though attribute application clips to the candidate paragraphs. StylingContext now carries the scope bounds and each pass skips tokens outside them. Table-doc styleTables: 3.6-9ms -> 0.35-0.9ms (out-of-scope tables skip the render lookup and anchor build entirely). - DocumentAST.parse's scoped-block filter became a sorted sweep over candidates instead of scanning every candidate per block (it went quadratic in formula-rich docs). - Table parse + content-hash memoized (tableMeta) instead of recomputed for every table every keystroke. - ParsedDocument gained a version stamp (bumped only on FRESH parses); computeActiveTokenIndices is memoized on (version, selection, suppressed) — it ran 3x/keystroke on identical inputs, now ~0ms. - updateCodeBlockSelection takes the indexed code blocks from the classification pass (no per-call full-token filter) and dedupes identical emits. Key uses the FULL active-token set: a caret move into a standalone block above a code block toggles that block's height and shifts the code block — same version/scroll/width — so keying on only the code intersection would leave the copy-button overlay stale (adversarial review, confirmed 2/2). - editedTableParagraphs filters the classified table tokens with an early exit; TextStylingService.normalize dedupes in one O(n) pass. Measured (Debug): 139k doc ~4.5 -> ~3ms/keystroke; table doc styleTables ~9 -> <1ms. All 206 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../MarkdownEngine/Parser/MarkdownAST.swift | 20 +++++++- .../Styling/MarkdownStyler+Images.swift | 2 + .../Styling/MarkdownStyler+Latex.swift | 2 + .../Styling/MarkdownStyler+Tables.swift | 49 +++++++++++++++++-- .../Styling/MarkdownStyler.swift | 27 +++++++++- .../Styling/TextStylingService.swift | 10 ++-- ...NativeTextViewCoordinator+CodeBlocks.swift | 34 +++++++++++-- .../NativeTextViewCoordinator+Restyling.swift | 46 +++++++++++++---- ...tiveTextViewCoordinator+TextDelegate.swift | 31 ++++++------ .../NativeTextViewCoordinator.swift | 19 +++++++ 10 files changed, 200 insertions(+), 40 deletions(-) diff --git a/Sources/MarkdownEngine/Parser/MarkdownAST.swift b/Sources/MarkdownEngine/Parser/MarkdownAST.swift index 0b777d8b..3b8e6e13 100644 --- a/Sources/MarkdownEngine/Parser/MarkdownAST.swift +++ b/Sources/MarkdownEngine/Parser/MarkdownAST.swift @@ -57,7 +57,25 @@ enum DocumentAST { let ns = text as NSString let blocks = BlockParser.parse(text) // Scoped mode: skip building BlockNodes for blocks outside the edit. - let relevant = scopedRanges == nil ? blocks : blocks.filter { inScope($0.range, scopedRanges) } + // Blocks tile the document in order, so one sweep over sorted candidate + // ranges replaces scanning every candidate per block (which went + // quadratic in formula-rich documents with dozens of candidates). + let relevant: [Block] + if let scopedRanges { + let sorted = scopedRanges + .filter { $0.location != NSNotFound && $0.length > 0 } + .sorted { $0.location < $1.location } + var out: [Block] = [] + var ci = 0 + for block in blocks { + while ci < sorted.count, NSMaxRange(sorted[ci]) <= block.range.location { ci += 1 } + guard ci < sorted.count else { break } + if sorted[ci].location < NSMaxRange(block.range) { out.append(block) } + } + relevant = out + } else { + relevant = blocks + } return relevant.map { node(for: $0, ns: ns, scopedRanges: scopedRanges) } } diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+Images.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+Images.swift index 08e74764..2db32ced 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler+Images.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+Images.swift @@ -21,6 +21,7 @@ extension MarkdownStyler { static func styleImageLinks(_ ctx: StylingContext) -> [StyledRange] { var attrs: [StyledRange] = [] for (idx, token) in ctx.tokens.enumerated() where token.kind == .imageLink { + if ctx.outsideScope(token.range) { continue } // clipped at application anyway if MarkdownDetection.isInsideCodeBlock(range: token.range, codeTokens: ctx.codeTokens) { continue } // The URL lives between markerRanges[2] ('(') and markerRanges[3] (')'). @@ -115,6 +116,7 @@ extension MarkdownStyler { static func styleImageEmbeds(_ ctx: StylingContext) -> [StyledRange] { var attrs: [StyledRange] = [] for (idx, token) in ctx.tokens.enumerated() where token.kind == .imageEmbed { + if ctx.outsideScope(token.range) { continue } // clipped at application anyway if MarkdownDetection.isInsideCodeBlock(range: token.range, codeTokens: ctx.codeTokens) { continue } let isActive = ctx.activeTokenIndices.contains(idx) diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+Latex.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+Latex.swift index 4c89d22d..1ea3c435 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler+Latex.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+Latex.swift @@ -18,6 +18,7 @@ extension MarkdownStyler { var attrs: [StyledRange] = [] let blockLatexTokens = ctx.tokens.enumerated().filter { $0.element.kind == .blockLatex } for (idx, token) in blockLatexTokens { + if ctx.outsideScope(token.range) { continue } // clipped at application anyway if MarkdownDetection.isInsideCodeBlock(range: token.range, codeTokens: ctx.codeTokens) { continue } let isActive = ctx.activeTokenIndices.contains(idx) let rawLatexContent = ctx.nsText.substring(with: token.contentRange) @@ -71,6 +72,7 @@ extension MarkdownStyler { // Quote lines mute their text via foregroundColor, which the LaTeX *image* ignores — render it in mutedText instead so it matches the grey. let blockquoteRanges = ctx.tokens.filter { $0.kind == .blockquote }.map(\.range) for (idx, token) in ctx.tokens.enumerated() where token.kind == .inlineLatex { + if ctx.outsideScope(token.range) { continue } // clipped at application anyway if MarkdownDetection.isInsideCodeBlock(range: token.range, codeTokens: ctx.codeTokens) { continue } if tableRanges.contains(where: { tableRange in token.range.location >= tableRange.location diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift index b03d2473..fd22dc80 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift @@ -94,6 +94,36 @@ extension MarkdownStyler { return prefix } + /// Parse + content-hash for a table source, memoized: both are pure in + /// the source text but were recomputed for every table on every keystroke + /// (the non-render share of styleTables). FIFO-capped like the block + /// token memo. + private static let tableMetaLock = NSLock() + private static var tableMetaCache: [String: (parsed: ParsedTable?, hash: Int)] = [:] + private static var tableMetaOrder: [String] = [] + + static func tableMeta(for source: String) -> (parsed: ParsedTable?, hash: Int) { + tableMetaLock.lock() + if let cached = tableMetaCache[source] { + tableMetaLock.unlock() + return cached + } + tableMetaLock.unlock() + + let computed = (parseTableSource(source), stableTableContentHash(for: source)) + + tableMetaLock.lock() + if tableMetaCache[source] == nil { + tableMetaCache[source] = computed + tableMetaOrder.append(source) + if tableMetaOrder.count > 512 { + tableMetaCache[tableMetaOrder.removeFirst()] = nil + } + } + tableMetaLock.unlock() + return computed + } + /// Returns the rendered image for `source`, from cache when possible. /// `rendered` is true only when a fresh render actually happened. static func tableImage( @@ -132,12 +162,13 @@ extension MarkdownStyler { attrs.append((token.range, [.spellingState: 0])) let source = ctx.nsText.substring(with: token.range) - guard let parsed = parseTableSource(source) else { continue } + let meta = tableMeta(for: source) + guard let parsed = meta.parsed else { continue } - // Advance occurrence index even for active tables so inactive duplicates stay stable. - let contentHash = stableTableContentHash(for: source) - let occurrenceIndex = occurrenceByContentHash[contentHash, default: 0] - occurrenceByContentHash[contentHash] = occurrenceIndex + 1 + // Advance occurrence index even for active/out-of-scope tables so + // inactive duplicates keep stable sourceIDs. + let occurrenceIndex = occurrenceByContentHash[meta.hash, default: 0] + occurrenceByContentHash[meta.hash] = occurrenceIndex + 1 let isActive = ctx.activeTokenIndices.contains(idx) if isActive { @@ -158,6 +189,14 @@ extension MarkdownStyler { continue } + // Outside the restyle scope the anchor attrs would be clipped away + // at application time — skip the render lookup and anchor build. + // (Occurrence bookkeeping above already ran, keeping IDs stable.) + if ctx.outsideScope(token.range) { + tableTrace.append("@\(token.range.location):skip") + continue + } + // See renderTable: resolve table colors under the text view's real appearance. let renderAppearance = ctx.layoutBridge?.firstTextContainer?.textView?.effectiveAppearance ?? NSApp.effectiveAppearance diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler.swift index 9ac29299..38e5470a 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler.swift @@ -30,8 +30,26 @@ extension MarkdownStyler { let latexMarkerFont: NSFont let configuration: MarkdownEditorConfiguration let wikiLinkIDProvider: (NSRange) -> String? + /// Union bounds of the restyle's paragraph scope; nil = whole document + /// (initial load). Attribute application clips per paragraph anyway, + /// so the NSImage passes can skip tokens wholly outside these bounds + /// instead of walking every token in the document per keystroke. + var scopeBounds: (lo: Int, hi: Int)? = nil var services: MarkdownEditorServices { configuration.services } + + /// True when `range` lies entirely outside the restyle scope — its + /// attributes would be clipped away at application time. + func outsideScope(_ range: NSRange) -> Bool { + guard let scopeBounds else { return false } + return NSMaxRange(range) <= scopeBounds.lo || range.location >= scopeBounds.hi + } + + /// True when iteration (over location-sorted tokens) is past the scope. + func pastScope(_ range: NSRange) -> Bool { + guard let scopeBounds else { return false } + return range.location >= scopeBounds.hi + } } } @@ -55,6 +73,12 @@ enum MarkdownStyler { ) -> [StyledRange] { let tokens = precomputedTokens ?? MarkdownTokenizer.parseTokensViaAST(in: text) let nsText = text as NSString + let scopeBounds: (lo: Int, hi: Int)? = scopedRanges.flatMap { ranges in + let valid = ranges.filter { $0.location != NSNotFound && $0.length > 0 } + guard let lo = valid.map(\.location).min(), + let hi = valid.map({ NSMaxRange($0) }).max() else { return nil } + return (lo, hi) + } let codeTokens = tokens.filter { $0.kind == .codeBlock || $0.kind == .inlineCode } let baseFont = NSFont(name: fontName, size: fontSize) ?? NSFont.systemFont(ofSize: fontSize) let baseDefaultLineHeight = ceil( @@ -75,7 +99,8 @@ enum MarkdownStyler { latexMarkerFont: NSFont(name: fontName, size: hiddenMarkerSize) ?? NSFont.systemFont(ofSize: hiddenMarkerSize), configuration: configuration, - wikiLinkIDProvider: wikiLinkIDProvider + wikiLinkIDProvider: wikiLinkIDProvider, + scopeBounds: scopeBounds ) var result: [StyledRange] = [] diff --git a/Sources/MarkdownEngine/Styling/TextStylingService.swift b/Sources/MarkdownEngine/Styling/TextStylingService.swift index c563bccb..6b90e83d 100644 --- a/Sources/MarkdownEngine/Styling/TextStylingService.swift +++ b/Sources/MarkdownEngine/Styling/TextStylingService.swift @@ -124,12 +124,14 @@ struct TextStylingService { } private static func normalize(_ candidates: [NSRange]) -> [NSRange] { + // Exact-duplicate drop in one pass (was O(n²) via contains); order and + // overlapping-but-unequal ranges are preserved exactly as before. + var seen = Set() + seen.reserveCapacity(candidates.count) var result: [NSRange] = [] for candidate in candidates where candidate.location != NSNotFound && candidate.length > 0 { - if result.contains(where: { $0.location == candidate.location && $0.length == candidate.length }) { - continue - } - result.append(candidate) + let key = candidate.location &* 1_000_003 &+ candidate.length + if seen.insert(key).inserted { result.append(candidate) } } return result } diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+CodeBlocks.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+CodeBlocks.swift index 756c2de9..4f63cd30 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+CodeBlocks.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+CodeBlocks.swift @@ -13,16 +13,16 @@ import AppKit extension NativeTextViewCoordinator { - func updateCodeBlockSelection(textView: NSTextView, tokens: [MarkdownToken]? = nil) { + func updateCodeBlockSelection(textView: NSTextView, parsed: ParsedDocument? = nil) { guard let textContainer = textView.textContainer else { onCodeBlockSelectionChange?([]) return } - if let tokens = tokens { - cachedCodeBlockTokens = tokens.enumerated() - .filter { $0.element.kind == .codeBlock } - .map { (index: $0.offset, token: $0.element) } + if let parsed { + // Indexed pairs come from the parse's single classification pass — + // no per-call full-token filter. + cachedCodeBlockTokens = parsed.codeBlockTokensWithIndices } else if cachedCodeBlockTokens.isEmpty { onCodeBlockSelectionChange?([]) return @@ -31,6 +31,30 @@ extension NativeTextViewCoordinator { let nsText = textView.string as NSString let scrollOffset = textView.enclosingScrollView?.contentView.bounds.origin ?? .zero + // Identical inputs → identical selections. The delegate path calls + // this twice per keystroke (selection change + textDidChange) and on + // every caret move; skip the substring/language/viewRect work when + // nothing relevant changed. Calls without `parsed` (document switch, + // scroll hooks) always recompute. + // + // The key must include the FULL active-token set, not just its code + // intersection: a caret move INTO a standalone block (block-LaTeX / + // table) toggles that block between its rendered image and raw source + // — a real height change that shifts a code block below it to a new Y + // — while leaving version, scroll, width, and the code∩active set + // unchanged. Keying on the whole active set makes any such toggle + // (the only same-version event that moves layout) recompute. Text + // edits are covered by the bumped version; the twice-per-keystroke + // redundancy still dedupes because both calls share one active set. + if let parsed { + let key = (parsed.version, scrollOffset.y, textContainer.containerSize.width, + activeTokenIndices) + if let last = lastCodeSelKey, last == key { return } + lastCodeSelKey = key + } else { + lastCodeSelKey = nil + } + // One-shot full-document layout per document; fixes stale Y from TextKit 2's lazy layout without per-update cost. if !didEnsureLayoutForCurrentDocument, let tlm = textView.textLayoutManager { tlm.ensureLayout(for: tlm.documentRange) diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift index cf615941..802544e6 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift @@ -72,12 +72,13 @@ extension NativeTextViewCoordinator { // Base attributes only — the source stays verbatim and unstyled. activeTokenIndices = [] } else { - let tokens = parsedDocument(for: displayText).tokens + let parsed = parsedDocument(for: displayText) + let tokens = parsed.tokens // Hide caret from styling when read-only, else clicks reveal raw token syntax. let caretLocation = textView.isEditable ? textView.selectedRange().location : -1 - activeTokenIndices = MarkdownDetection.computeActiveTokenIndices( - selectionRange: textView.selectedRange(), - tokens: tokens, + activeTokenIndices = activeTokenIndices( + parsed: parsed, + selection: textView.selectedRange(), in: nsDisplay, suppressed: !textView.isEditable ) @@ -189,16 +190,21 @@ extension NativeTextViewCoordinator { var blockLatexTokens: [MarkdownToken] = [] var wikiLinkTokens: [MarkdownToken] = [] var imageEmbedTokens: [MarkdownToken] = [] + var tableTokens: [MarkdownToken] = [] + var codeBlockTokensWithIndices: [(index: Int, token: MarkdownToken)] = [] codeTokens.reserveCapacity(tokens.count / 2) latexTokens.reserveCapacity(tokens.count / 4) blockLatexTokens.reserveCapacity(tokens.count / 4) wikiLinkTokens.reserveCapacity(tokens.count / 4) - for token in tokens { + for (index, token) in tokens.enumerated() { switch token.kind { case .codeBlock, .inlineCode: codeTokens.append(token) + if token.kind == .codeBlock { + codeBlockTokensWithIndices.append((index, token)) + } case .inlineLatex: latexTokens.append(token) case .blockLatex: @@ -207,18 +213,24 @@ extension NativeTextViewCoordinator { wikiLinkTokens.append(token) case .imageEmbed: imageEmbedTokens.append(token) + case .table: + tableTokens.append(token) default: break } } + parsedDocumentVersion &+= 1 let parsed = ParsedDocument( tokens: tokens, codeTokens: codeTokens, latexTokens: latexTokens, blockLatexTokens: blockLatexTokens, wikiLinkTokens: wikiLinkTokens, - imageEmbedTokens: imageEmbedTokens + imageEmbedTokens: imageEmbedTokens, + tableTokens: tableTokens, + codeBlockTokensWithIndices: codeBlockTokensWithIndices, + version: parsedDocumentVersion ) cachedParsedText = text cachedParsedLength = length @@ -227,6 +239,22 @@ extension NativeTextViewCoordinator { return parsed } + + /// Memoized computeActiveTokenIndices — a pure function of + /// (parsed.version, selection, suppressed) that otherwise runs up to + /// three times per keystroke on identical inputs (pre-edit ask, + /// selection change, textDidChange). + func activeTokenIndices(parsed: ParsedDocument, selection: NSRange, in text: NSString, suppressed: Bool) -> Set { + if let memo = activeTokenMemo, memo.version == parsed.version, + memo.selection == selection, memo.suppressed == suppressed { + return memo.result + } + let result = MarkdownDetection.computeActiveTokenIndices( + selectionRange: selection, tokens: parsed.tokens, in: text, suppressed: suppressed) + activeTokenMemo = (parsed.version, selection, suppressed, result) + return result + } + func paragraphRanges( in text: NSString, intersecting editedRange: NSRange @@ -282,9 +310,9 @@ extension NativeTextViewCoordinator { let parsed = parsedDocument(for: textView.string) let tokens = parsed.tokens let nsText = textView.string as NSString - activeTokenIndices = MarkdownDetection.computeActiveTokenIndices( - selectionRange: textView.selectedRange(), - tokens: tokens, + activeTokenIndices = activeTokenIndices( + parsed: parsed, + selection: textView.selectedRange(), in: nsText, suppressed: !textView.isEditable ) diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift index c9f9678d..65acbf98 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift @@ -215,12 +215,7 @@ extension NativeTextViewCoordinator { pendingPreEditActiveTokenIndices = nil activeTokenIndices = PerfTrace.measure("activeTok") { - MarkdownDetection.computeActiveTokenIndices( - selectionRange: safeSelRange, - tokens: tokens, - in: fullText, - suppressed: !tv.isEditable - ) + activeTokenIndices(parsed: parsed, selection: safeSelRange, in: fullText, suppressed: !tv.isEditable) } filterImageEmbedActiveTokens(parsed: parsed, text: fullText, selectionLocation: safeSelRange.location) updateAutocorrectSettings( @@ -245,9 +240,15 @@ extension NativeTextViewCoordinator { // that merges into an existing table), the styler re-emits the anchor // against the FULL block — restyling only the edited rows would clip // that anchor away and the table goes blank until a full restyle. - let editedTableParagraphs = tokens - .filter { $0.kind == .table && NSIntersectionRange($0.range, safeEditedRange).length > 0 } - .map { fullText.paragraphRange(for: $0.range) } + // Location-sorted classified tables: early-exit past the edit instead + // of a full-token filter per keystroke. + var editedTableParagraphs: [NSRange] = [] + for token in parsed.tableTokens { + if token.range.location > NSMaxRange(safeEditedRange) { break } + if NSIntersectionRange(token.range, safeEditedRange).length > 0 { + editedTableParagraphs.append(fullText.paragraphRange(for: token.range)) + } + } effectiveParagraphCandidates.append(contentsOf: editedTableParagraphs) if !editedTableParagraphs.isEmpty { PerfTrace.note { "📐 TABLE-RESTYLE blocks=\(editedTableParagraphs.map { "\($0.location)+\($0.length)" }.joined(separator: ",")) editedRange=\(safeEditedRange.location),\(safeEditedRange.length)" } @@ -260,7 +261,7 @@ extension NativeTextViewCoordinator { )) PerfTrace.measure("restyle") { restyleTextView(tv, paragraphCandidates: effectiveParagraphCandidates, tokens: tokens) } - PerfTrace.measure("codeSel") { updateCodeBlockSelection(textView: tv, tokens: tokens) } + PerfTrace.measure("codeSel") { updateCodeBlockSelection(textView: tv, parsed: parsed) } if wtActive { previousActiveTokenIndices = activeTokenIndices PerfTrace.end() @@ -313,7 +314,7 @@ extension NativeTextViewCoordinator { let nsText = tv.string as NSString let prevActive = activeTokenIndices - activeTokenIndices = MarkdownDetection.computeActiveTokenIndices(selectionRange: selRange, tokens: tokens, in: nsText, suppressed: !tv.isEditable) + activeTokenIndices = activeTokenIndices(parsed: parsed, selection: selRange, in: nsText, suppressed: !tv.isEditable) filterImageEmbedActiveTokens(parsed: parsed, text: nsText, selectionLocation: selRange.location) // Snap-back: when the caret LEFT a wiki/image token, re-sync its displayed name to the live target name. @@ -520,7 +521,7 @@ extension NativeTextViewCoordinator { // Skip during a pending edit — viewRect is stale until textDidChange's restyle runs; otherwise the overlay flashes to the old Y before settling. if !shouldSkipSelectionRestyle { - updateCodeBlockSelection(textView: tv, tokens: tokens) + updateCodeBlockSelection(textView: tv, parsed: parsed) } } @@ -587,9 +588,9 @@ extension NativeTextViewCoordinator { return true } let parsed = parsedDocument(for: textView.string) - pendingPreEditActiveTokenIndices = MarkdownDetection.computeActiveTokenIndices( - selectionRange: textView.selectedRange(), - tokens: parsed.tokens, + pendingPreEditActiveTokenIndices = activeTokenIndices( + parsed: parsed, + selection: textView.selectedRange(), in: textView.string as NSString, suppressed: !textView.isEditable ) diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift index 4dff49b0..e98ebb80 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift @@ -98,6 +98,12 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate { /// Incremental parse state for this editor (buffer + blocks + tokens /// evolve together under the edit descriptor). let parseState = DocumentParseState() + /// Monotonic stamp for fresh ParsedDocument builds (see ParsedDocument.version). + var parsedDocumentVersion: UInt64 = 0 + /// Single-slot memo for computeActiveTokenIndices — it runs up to three + /// times per keystroke on identical inputs (pre-edit ask, selection + /// change, textDidChange). Pure function of (version, selection, suppressed). + var activeTokenMemo: (version: UInt64, selection: NSRange, suppressed: Bool, result: Set)? /// Display-text length after the previous textDidChange — yields the edit's /// length delta without retaining the previous text. @@ -120,6 +126,10 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate { var needsRestyleAfterDrag = false var cachedCodeBlockTokens: [(index: Int, token: MarkdownToken)] = [] + /// Dedupe key of the last emitted code-block selections — identical + /// (parse version, scroll, width, active-code set) means identical output, + /// so the second per-keystroke invocation can skip the geometry work. + var lastCodeSelKey: (UInt64, CGFloat, CGFloat, Set)? var cachedParsedText: String? var cachedParsedDocument: ParsedDocument? /// Monotonic edit counter: bumped whenever the text storage can have @@ -173,6 +183,15 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate { let blockLatexTokens: [MarkdownToken] let wikiLinkTokens: [MarkdownToken] let imageEmbedTokens: [MarkdownToken] + let tableTokens: [MarkdownToken] + /// Code-block tokens with their index into `tokens` (active-token + /// checks need the original index) — collected in the same single + /// classification pass instead of a per-call full-token filter. + let codeBlockTokensWithIndices: [(index: Int, token: MarkdownToken)] + /// Bumped only when a FRESH parse builds this document — cache-hit + /// returns share the version, so (version, selection, suppressed) is + /// an exact memo key for pure derivations like active-token indices. + let version: UInt64 } enum InlineTokenContext { From 39e45a132854f0f05356e37e031400dc555be0da Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Sat, 11 Jul 2026 23:26:20 +0200 Subject: [PATCH 14/39] perf(parse): O(1) cache hit by skipping the redundant generation bump; strip session diagnostics textDidChange bumped parseGeneration a second time per keystroke (after shouldChangeTextIn's bump), which forced parsedDocument off its O(1) generation hit onto the O(doc) byte-compare VERIFY (~0.7ms at 139k). For a trusted length-changing edit the length alone already invalidated the pre-edit cache and the selection-change re-parsed the post-edit text at the current generation, so the bump is skipped and the hit is O(1). Same-length and untracked edits still bump so the byte-compare keeps catching same-length content changes; undo/IME/bypass paths unaffected (adversarial review: only an over-strict DEBUG assert was flagged, removed). Measured (Debug): 139k doc parse 0.7 -> 0.0ms, total ~3 -> ~2.3ms/keystroke (short-file ~1.1ms). Also removes the temporary height/flicker/parse-timing diagnostics added during the investigation; PerfTrace itself stays. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../MarkdownEngine/Parser/BlockParser.swift | 8 +-- .../Parser/DocumentParseState.swift | 11 ---- .../Styling/MarkdownStyler+Tables.swift | 14 ++--- .../Styling/TextStylingService.swift | 7 --- .../TextView/ClampedScrollView.swift | 29 --------- .../NativeTextViewCoordinator+Restyling.swift | 16 +---- ...tiveTextViewCoordinator+TextDelegate.swift | 20 +++--- .../NativeTextView+FrameAndOverscroll.swift | 63 +++++-------------- 8 files changed, 33 insertions(+), 135 deletions(-) diff --git a/Sources/MarkdownEngine/Parser/BlockParser.swift b/Sources/MarkdownEngine/Parser/BlockParser.swift index a313f053..6ac9928d 100644 --- a/Sources/MarkdownEngine/Parser/BlockParser.swift +++ b/Sources/MarkdownEngine/Parser/BlockParser.swift @@ -77,24 +77,18 @@ enum BlockParser { let prevBlocks = cachedBlocks cacheLock.unlock() - let t0 = DispatchTime.now().uptimeNanoseconds if let prevChars, let prevBlocks { // Identical text → memcmp hit (the scan below would walk O(doc)). - if equalBuffers(prevChars, newChars) { - PerfTrace.note { "🧱 BlockParser.static EQUAL \(String(format: "%.2f", Double(DispatchTime.now().uptimeNanoseconds - t0) / 1_000_000))ms" } - return prevBlocks - } + if equalBuffers(prevChars, newChars) { return prevBlocks } if let diff = scanDiff(old: prevChars, new: newChars), let (incr, _) = incrementalParse(oldChars: prevChars, oldBlocks: prevBlocks, newChars: newChars, newNS: textNS, diff: diff) { cacheLock.lock(); cachedChars = newChars; cachedBlocks = incr; cacheLock.unlock() - PerfTrace.note { "🧱 BlockParser.static INCR \(String(format: "%.2f", Double(DispatchTime.now().uptimeNanoseconds - t0) / 1_000_000))ms" } return incr } } let blocks = computeBlocks(text) cacheLock.lock(); cachedChars = newChars; cachedBlocks = blocks; cacheLock.unlock() - PerfTrace.note { "🧱 BlockParser.static FULL \(String(format: "%.2f", Double(DispatchTime.now().uptimeNanoseconds - t0) / 1_000_000))ms" } return blocks } diff --git a/Sources/MarkdownEngine/Parser/DocumentParseState.swift b/Sources/MarkdownEngine/Parser/DocumentParseState.swift index 948d7ed6..45def7ad 100644 --- a/Sources/MarkdownEngine/Parser/DocumentParseState.swift +++ b/Sources/MarkdownEngine/Parser/DocumentParseState.swift @@ -44,7 +44,6 @@ final class DocumentParseState { /// O(edit + touched blocks + suffix shift); without one, a single shared /// O(doc) diff scan replaces the two independent scans of the static path. func tokens(for text: String, edit: ParseEditDescriptor?) -> [MarkdownToken] { - let tStart = DispatchTime.now().uptimeNanoseconds let ns = text as NSString let newLen = ns.length @@ -96,8 +95,6 @@ final class DocumentParseState { } } - let tBlocks = DispatchTime.now().uptimeNanoseconds - // 2. Blocks: window splice on the shared diff, full reparse fallback. var newBlocks: [Block]? if wasValid, let diff { @@ -107,7 +104,6 @@ final class DocumentParseState { )?.blocks } let resolvedBlocks = newBlocks ?? BlockParser.computeBlocks(text) - let tTokens = DispatchTime.now().uptimeNanoseconds // 3. Tokens: prefix/suffix reuse on the same diff, full fallback. var newTokens: [MarkdownToken]? @@ -118,13 +114,6 @@ final class DocumentParseState { )?.tokens } let resolvedTokens = newTokens ?? MarkdownTokenizer.fullTokens(blocks: resolvedBlocks, ns: ns) - let tEnd = DispatchTime.now().uptimeNanoseconds - PerfTrace.note { - let path = !wasValid ? "INVALID" : (edit != nil ? "SPLICE" : "SCAN") - let blocksPath = newBlocks != nil ? "incr" : "FULL" - let tokensPath = newTokens != nil ? "incr" : "FULL" - return "🧩 parseState \(path) blocks=\(blocksPath):\(String(format: "%.2f", Double(tTokens - tBlocks) / 1_000_000))ms tokens=\(tokensPath):\(String(format: "%.2f", Double(tEnd - tTokens) / 1_000_000))ms buffer=\(String(format: "%.2f", Double(tBlocks - tStart) / 1_000_000))ms" - } lock.lock() chars = newChars diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift index fd22dc80..4be33c50 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift @@ -154,7 +154,6 @@ extension MarkdownStyler { var occurrenceByContentHash: [Int: Int] = [:] var tableCount = 0 var renderedCount = 0 - var tableTrace: [String] = [] // per-table: loc/state/height (Debug diagnosis) let tablesT0 = DispatchTime.now().uptimeNanoseconds for (idx, token) in ctx.tokens.enumerated() where token.kind == .table { tableCount += 1 @@ -172,7 +171,6 @@ extension MarkdownStyler { let isActive = ctx.activeTokenIndices.contains(idx) if isActive { - tableTrace.append("@\(token.range.location):ACTIVE-RAW") // Caret inside the table — show editable source, pipes muted like other syntax. let muted = ctx.configuration.theme.mutedText let body = ctx.configuration.theme.bodyText @@ -190,12 +188,9 @@ extension MarkdownStyler { } // Outside the restyle scope the anchor attrs would be clipped away - // at application time — skip the render lookup and anchor build. - // (Occurrence bookkeeping above already ran, keeping IDs stable.) - if ctx.outsideScope(token.range) { - tableTrace.append("@\(token.range.location):skip") - continue - } + // at application time — skip the render lookup and anchor build + // (occurrence bookkeeping above already ran, keeping IDs stable). + if ctx.outsideScope(token.range) { continue } // See renderTable: resolve table colors under the text view's real appearance. let renderAppearance = ctx.layoutBridge?.firstTextContainer?.textView?.effectiveAppearance @@ -207,7 +202,6 @@ extension MarkdownStyler { appearance: renderAppearance ) if rendered { renderedCount += 1 } - tableTrace.append("@\(token.range.location):img h=\(Int(image.size.height))\(rendered ? " FRESH" : "")") let imageBounds = CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height) // Wide tables → scrollable mode (NSScrollView overlay); narrow → collapsed. let containerWidth = effectiveContainerWidth(for: ctx) @@ -238,7 +232,7 @@ extension MarkdownStyler { } if tableCount > 0 { let ms = Double(DispatchTime.now().uptimeNanoseconds - tablesT0) / 1_000_000 - PerfTrace.note { "styleTables scanned=\(tableCount) tables, re-rendered=\(renderedCount) NSImage in \(String(format: "%.2f", ms))ms | \(tableTrace.joined(separator: " "))" } + PerfTrace.note { "styleTables scanned=\(tableCount) tables, re-rendered=\(renderedCount) NSImage in \(String(format: "%.2f", ms))ms" } } return attrs } diff --git a/Sources/MarkdownEngine/Styling/TextStylingService.swift b/Sources/MarkdownEngine/Styling/TextStylingService.swift index 6b90e83d..65e238fb 100644 --- a/Sources/MarkdownEngine/Styling/TextStylingService.swift +++ b/Sources/MarkdownEngine/Styling/TextStylingService.swift @@ -93,13 +93,6 @@ struct TextStylingService { } textView.textStorage?.beginEditing() -#if DEBUG - // Flicker diagnosis: how much of the document does this pass rewrite? - let appliedSpan = paragraphs.reduce(0) { $0 + $1.length } - if paragraphs.count > 10 || appliedSpan > 2500 { - print("🎨 BIG-APPLY paragraphs=\(paragraphs.count) span=\(appliedSpan) of \(textView.textStorage?.length ?? -1)") - } -#endif for disabledRange in spellingDisabledRanges { textView.textStorage?.addAttribute(.spellingState, value: 0, range: disabledRange) } diff --git a/Sources/MarkdownEngine/TextView/ClampedScrollView.swift b/Sources/MarkdownEngine/TextView/ClampedScrollView.swift index 10bc96c2..f28c8984 100644 --- a/Sources/MarkdownEngine/TextView/ClampedScrollView.swift +++ b/Sources/MarkdownEngine/TextView/ClampedScrollView.swift @@ -13,35 +13,6 @@ final class ClampedScrollView: NSScrollView { /// own height to SwiftUI and the enclosing scroll view owns paging. var fitsContent: Bool = false -#if DEBUG - // Table-shift diagnosis: name every code path that moves the scroll by a - // jump-sized amount. Wheel/gesture scrolling is continuous (small deltas); - // a >40pt single hop during typing is the bug's signature. - private var lastLoggedScrollY: CGFloat = .nan - private var scrollWatchToken: NSObjectProtocol? - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - guard window != nil, scrollWatchToken == nil else { return } - contentView.postsBoundsChangedNotifications = true - scrollWatchToken = NotificationCenter.default.addObserver( - forName: NSView.boundsDidChangeNotification, object: contentView, queue: nil - ) { [weak self] _ in - guard let self else { return } - let y = self.contentView.bounds.origin.y - defer { self.lastLoggedScrollY = y } - guard !self.lastLoggedScrollY.isNaN, abs(y - self.lastLoggedScrollY) > 40 else { return } - let stack = Thread.callStackSymbols[2...8] - .compactMap { line -> String? in - guard line.contains("MarkdownEngine") || line.contains("Nodes") else { return nil } - return line.split(separator: " ").dropFirst(3).first.map(String.init) - } - .prefix(4) - .joined(separator: " ← ") - print("📜 scrollJump y=\(Int(self.lastLoggedScrollY))→\(Int(y)) via \(stack.isEmpty ? "system/AppKit" : stack)") - } - } -#endif - /// Saved at the start of every live-resize (including spurious one-click resizes triggered by edge-cursor clicks) so the position is restored when the resize ends. Without this, NSScrollView's default top-anchor-during-resize would jolt a bottom-anchored user back up by hundreds of points on a single edge click. private var scrollYBeforeLiveResize: CGFloat? diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift index 802544e6..b48516c2 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift @@ -19,9 +19,6 @@ extension NativeTextViewCoordinator { from text: String, invalidateLayout: Bool = false ) { -#if DEBUG - print("🏗️ REBUILD len=\((text as NSString).length) invalidateLayout=\(invalidateLayout)") -#endif // Storage is raw Markdown; only wiki links transform on display. // In raw source mode display IS storage — no transform, no metadata. let services = configuration.services @@ -164,27 +161,20 @@ extension NativeTextViewCoordinator { } func parsedDocument(for text: String, edit: ParseEditDescriptor? = nil) -> ParsedDocument { - let t0 = DispatchTime.now().uptimeNanoseconds let length = (text as NSString).length if let cachedParsedDocument, cachedParsedLength == length { // O(1) hit: nothing has edited the storage since the cached parse. - if cachedParseGeneration == parseGeneration { - PerfTrace.note { "📊 parsedDoc GEN-HIT" } - return cachedParsedDocument - } + if cachedParseGeneration == parseGeneration { return cachedParsedDocument } // Generation moved but the text may still be identical (e.g. an - // attribute-only pass): verify once, then it's O(1) again. - // NSString.isEqual is a byte compare; the bridged Swift `==` walked - // the 139k text character-wise at ~6ms per keystroke. + // attribute-only pass): confirm via NSString.isEqual (a byte + // compare — the bridged Swift `==` walked 139k chars per keystroke). if let cachedParsedText, (cachedParsedText as NSString).isEqual(to: text) { cachedParseGeneration = parseGeneration - PerfTrace.note { "📊 parsedDoc VERIFY-HIT \(String(format: "%.2f", Double(DispatchTime.now().uptimeNanoseconds - t0) / 1_000_000))ms" } return cachedParsedDocument } } let tokens = parseState.tokens(for: text, edit: edit) - PerfTrace.note { "📊 parsedDoc MISS edit=\(edit != nil) stateTokens=\(String(format: "%.2f", Double(DispatchTime.now().uptimeNanoseconds - t0) / 1_000_000))ms" } var codeTokens: [MarkdownToken] = [] var latexTokens: [MarkdownToken] = [] var blockLatexTokens: [MarkdownToken] = [] diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift index 65acbf98..64931345 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift @@ -111,7 +111,6 @@ extension NativeTextViewCoordinator { let safeSelRange = NSRange(location: safeLocation, length: 0) previousCaretLocation = safeSelRange.location PerfTrace.begin(docLength: fullLength) - parseGeneration &+= 1 // Edit descriptor, hoisted above the wiki sync so both it and the // paragraph scoping below share it. @@ -125,6 +124,17 @@ extension NativeTextViewCoordinator { let lengthDelta = previousDisplayLength >= 0 ? fullLength - previousDisplayLength : Int.min previousDisplayLength = fullLength + // Parse-cache generation. shouldChangeTextIn already bumped for this + // mutation and the selection-change re-parsed the post-edit text at + // that generation; bumping again would force parsedDocument onto its + // O(doc) byte-compare VERIFY. A trusted length-CHANGING edit already + // invalidated the pre-edit cache by length, so keep the generation and + // hit O(1). Same-length/untracked edits still bump — the byte-compare + // then catches a same-length content change the length check misses. + if !(singleTrackedEdit && lengthDelta != 0 && lengthDelta != Int.min) { + parseGeneration &+= 1 + } + if !wtActive { let storageState = PerfTrace.measure("wiki") { WikiLinkService.updatedStorageState( @@ -195,11 +205,6 @@ extension NativeTextViewCoordinator { lengthDelta: lengthDelta, trusted: singleTrackedEdit) } let codeBlockStructureChanged = backtickCount != previousBacktickCount -#if DEBUG - if codeBlockStructureChanged { - print("🔄 FULL-RESTYLE trigger: backtickCount \(previousBacktickCount)→\(backtickCount) (editedRange=\(editedRange), delta=\(lengthDelta), trusted=\(singleTrackedEdit))") - } -#endif previousBacktickCount = backtickCount let parsed = PerfTrace.measure("parse") { @@ -250,9 +255,6 @@ extension NativeTextViewCoordinator { } } effectiveParagraphCandidates.append(contentsOf: editedTableParagraphs) - if !editedTableParagraphs.isEmpty { - PerfTrace.note { "📐 TABLE-RESTYLE blocks=\(editedTableParagraphs.map { "\($0.location)+\($0.length)" }.joined(separator: ",")) editedRange=\(safeEditedRange.location),\(safeEditedRange.length)" } - } effectiveParagraphCandidates.append(contentsOf: tokenRestyleParagraphs( in: fullText, tokens: tokens, diff --git a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift index 8bdb6818..9f369e87 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift @@ -40,11 +40,6 @@ extension NativeTextView { let baseHeightChanged = abs(measured - baseContentHeight) > 0.5 let overscrollChanged = abs(resolvedOverscroll - activeBottomOverscroll) > 0.5 -#if DEBUG - if baseHeightChanged { - print("📐 recalc[\(debugTag)] base \(Int(baseContentHeight))→\(Int(measured)) over \(Int(activeBottomOverscroll))→\(Int(resolvedOverscroll)) fullLayout=\(pendingFullLayoutMeasure)") - } -#endif // Height settled → stop forcing full layout (until the next switch/resize). if !(baseHeightChanged || overscrollChanged) { pendingFullLayoutMeasure = false } guard baseHeightChanged || overscrollChanged else { return } @@ -145,14 +140,6 @@ extension NativeTextView { } var rawHeight = max(segmentMaxY, fragmentMaxY) -#if DEBUG - // Height-jitter diagnosis: which measurement component moves between - // keystrokes. lastFragY moving with constant content = fragments ABOVE - // shifted (estimated heights above the viewport changed). - let dbgFrag = Int(fragmentMaxY), dbgSeg = Int(segmentMaxY) - let dbgLastY = Int(lastFragmentFrame.minY) - print("📐 measure frag=\(dbgFrag) seg=\(dbgSeg) lastFragY=\(dbgLastY) full=\(forceFullLayout)") -#endif // With a trailing "\n", the last line is TextKit's extra line fragment. // Its metrics follow the final newline's attributes — not the body style a @@ -222,15 +209,6 @@ extension NativeTextView { guard abs(targetSize.width - frame.size.width) > 0.5 || abs(targetSize.height - frame.size.height) > 0.5 else { return } -#if DEBUG - // A frame SHRINK below the current scroll extent forces AppKit to clamp - // the scroll offset — visible as content jumping upward. - let dbgScrollY = enclosingScrollView?.contentView.bounds.origin.y ?? -1 - let dbgViewH = enclosingScrollView?.contentView.bounds.height ?? -1 - let clampRisk = targetSize.height < frame.size.height - && dbgScrollY + dbgViewH > targetSize.height - print("📐 frame h=\(Int(frame.size.height))→\(Int(targetSize.height)) scrollY=\(Int(dbgScrollY)) viewH=\(Int(dbgViewH))\(clampRisk ? " ⚠️CLAMP" : "")") -#endif isApplyingManagedFrameSize = true super.setFrameSize(targetSize) isApplyingManagedFrameSize = false @@ -375,14 +353,13 @@ extension NativeTextView { let visibleBottom = cv.bounds.origin.y + cv.bounds.height let margin: CGFloat = 24 if frame.minY < visibleTop || frame.maxY > visibleBottom { - // The caret's Y is the sum of every fragment height above it. - // While invalidated fragments above are estimate-only (a table - // image estimates as one text line, ~250pt short), the caret - // computes far too high and typing "reveals" upward — content - // visibly jumps. Settle layout up to the caret before trusting - // an out-of-view verdict: spurious ones dissolve, and genuine - // jumps get the CORRECT target. Costs nothing when the caret - // is visible (the common per-keystroke case never gets here). + // The caret's Y is the sum of the fragment heights above it; + // while any of those are estimate-only (a table image estimates + // as one text line, ~250pt short), the caret reads far too high + // and typing "reveals" upward. Settle layout up to the caret + // before trusting an out-of-view verdict — spurious ones then + // dissolve, genuine jumps get the correct target. Free when the + // caret is already visible (the common per-keystroke case). if let end = tlm.textContentManager?.location(tlm.documentRange.location, offsetBy: min(range.location + 1, docLength)), let settleRange = NSTextRange(location: tlm.documentRange.location, end: end) { tlm.ensureLayout(for: settleRange) @@ -398,9 +375,6 @@ extension NativeTextView { } else { return false // already visible (or a spurious verdict, corrected) } -#if DEBUG - print("🎯 reveal scroll y=\(Int(cv.bounds.origin.y))→\(Int(targetY)) caret=\(Int(frame.minY))..\(Int(frame.maxY)) vis=\(Int(visibleTop))..\(Int(visibleBottom)) loc=\(range.location)") -#endif cv.scroll(to: NSPoint(x: cv.bounds.origin.x, y: targetY)) scrollView.reflectScrolledClipView(cv) (scrollView as? ClampedScrollView)?.clampToInsets() @@ -444,26 +418,17 @@ extension NativeTextView { } /// Force TextKit 2 to lay out all fragments within the current visible rect. - /// Walks from the DOCUMENT HEAD, not the viewport: every fragment's Y is - /// the sum of the heights above it, so anything merely estimated above the - /// viewport shifts the visible content when it settles. Keeping everything - /// up to the viewport settled on every keystroke is the invariant that - /// makes the visible geometry (and the height measure) stable — a - /// viewport-scoped walk (tried as a perf win) caused content shifts, - /// spurious caret reveals, and a bistable frame height. Steady-state cost - /// is an enumeration over already-laid-out fragments. + /// Walks from the document head, not the viewport: a fragment's Y is the + /// sum of the heights above it, so leaving anything above merely estimated + /// shifts the visible content when it later settles. A viewport-scoped walk + /// (tried as a perf win) caused content shifts, spurious caret reveals, and + /// a bistable frame height; steady-state cost here is an enumeration over + /// already-laid-out fragments. func ensureVisibleLayout() { guard let tlm = textLayoutManager else { return } let visBot = visibleRect.maxY - var walked = 0 tlm.enumerateTextLayoutFragments(from: tlm.documentRange.location, options: [.ensuresLayout]) { fragment in - walked += 1 - return fragment.layoutFragmentFrame.minY <= visBot - } - PerfTrace.note { - let docH = String(format: "%.0f", self.frame.height) - let scrollY = String(format: "%.0f", self.visibleRect.minY) - return "ensureVisibleLayout walked=\(walked) frags | docH=\(docH) scrollY=\(scrollY)" + fragment.layoutFragmentFrame.minY <= visBot } } } From 8866b6e42739cde6a151a7182a8cacd87ebef62d Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Sun, 12 Jul 2026 12:22:11 +0200 Subject: [PATCH 15/39] perf(restyle): scope NSImage styler passes via per-kind indexed tokens Feed the styler's five NSImage passes (block/inline LaTeX, image embeds, image links, tables) a pre-classified per-kind indexed-token bundle (ClassifiedStyleTokens) built once in the parse classification, so each pass binary-searches its own small array to the restyle scope instead of walking every document token via enumerate+filter every keystroke. - MarkdownStyler.StylingContext: IndexedToken, ClassifiedStyleTokens, scoped() binary search over the location-sorted per-kind arrays. - Latex/Images passes iterate ctx.scoped(ctx.Indexed); styleTables iterates the classified table array (still a full occurrence scan). - Raise table metadata/image cache caps above a document's table count so a table-dense doc stops thrashing (Belady-pessimal FIFO) each keystroke. - Viewport-cull updateCodeBlockSelection to the visible range. - Scope the textDidChange latex/image restyle candidates to the edit. Retains the per-keystroke PerfTrace timing splits (styleAttributes / restyle split / styleTables substring+meta) as WIP diagnostics on this branch. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Styling/HeadingHelpers.swift | 6 +- .../Styling/MarkdownStyler+Images.swift | 6 +- .../Styling/MarkdownStyler+Latex.swift | 21 ++++--- .../Styling/MarkdownStyler+Tables.swift | 25 ++++++-- .../Styling/MarkdownStyler.swift | 60 ++++++++++++++++++- .../Styling/TextStylingService.swift | 11 ++++ ...NativeTextViewCoordinator+CodeBlocks.swift | 12 ++++ .../NativeTextViewCoordinator+Restyling.swift | 23 ++++++- ...tiveTextViewCoordinator+TextDelegate.swift | 23 +++++-- .../NativeTextViewCoordinator.swift | 4 ++ 10 files changed, 160 insertions(+), 31 deletions(-) diff --git a/Sources/MarkdownEngine/Styling/HeadingHelpers.swift b/Sources/MarkdownEngine/Styling/HeadingHelpers.swift index 86234f59..49bb7218 100644 --- a/Sources/MarkdownEngine/Styling/HeadingHelpers.swift +++ b/Sources/MarkdownEngine/Styling/HeadingHelpers.swift @@ -11,13 +11,15 @@ import AppKit enum HeadingHelpers { /// Use heading context to scale LaTeX font size consistently with surrounding text. + /// `headings` is the document's heading tokens, built once per styling pass — + /// scanning all tokens per LaTeX token here was O(#latex × #tokens). static func latexFontSize( for token: MarkdownToken, - tokens: [MarkdownToken], + headings: [MarkdownToken], baseFont: NSFont, configuration: HeadingStyle = .default ) -> CGFloat { - if let headingToken = tokens.first(where: { $0.kind == .heading && NSLocationInRange(token.contentRange.location, $0.contentRange) }) { + if let headingToken = headings.first(where: { NSLocationInRange(token.contentRange.location, $0.contentRange) }) { let level = headingToken.markerRanges.first?.length ?? 1 return baseFont.pointSize * configuration.fontMultiplier(for: level) } diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+Images.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+Images.swift index 2db32ced..dad93d1d 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler+Images.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+Images.swift @@ -20,8 +20,7 @@ extension MarkdownStyler { /// at which point we fall back to dimming the markdown source). static func styleImageLinks(_ ctx: StylingContext) -> [StyledRange] { var attrs: [StyledRange] = [] - for (idx, token) in ctx.tokens.enumerated() where token.kind == .imageLink { - if ctx.outsideScope(token.range) { continue } // clipped at application anyway + for (idx, token) in ctx.scoped(ctx.imageLinkIndexed) { if MarkdownDetection.isInsideCodeBlock(range: token.range, codeTokens: ctx.codeTokens) { continue } // The URL lives between markerRanges[2] ('(') and markerRanges[3] (')'). @@ -115,8 +114,7 @@ extension MarkdownStyler { static func styleImageEmbeds(_ ctx: StylingContext) -> [StyledRange] { var attrs: [StyledRange] = [] - for (idx, token) in ctx.tokens.enumerated() where token.kind == .imageEmbed { - if ctx.outsideScope(token.range) { continue } // clipped at application anyway + for (idx, token) in ctx.scoped(ctx.imageEmbedIndexed) { if MarkdownDetection.isInsideCodeBlock(range: token.range, codeTokens: ctx.codeTokens) { continue } let isActive = ctx.activeTokenIndices.contains(idx) diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+Latex.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+Latex.swift index 1ea3c435..90789fa7 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler+Latex.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+Latex.swift @@ -16,9 +16,7 @@ extension MarkdownStyler { static func styleBlockLatex(_ ctx: StylingContext) -> [StyledRange] { var attrs: [StyledRange] = [] - let blockLatexTokens = ctx.tokens.enumerated().filter { $0.element.kind == .blockLatex } - for (idx, token) in blockLatexTokens { - if ctx.outsideScope(token.range) { continue } // clipped at application anyway + for (idx, token) in ctx.scoped(ctx.blockLatexIndexed) { if MarkdownDetection.isInsideCodeBlock(range: token.range, codeTokens: ctx.codeTokens) { continue } let isActive = ctx.activeTokenIndices.contains(idx) let rawLatexContent = ctx.nsText.substring(with: token.contentRange) @@ -28,7 +26,7 @@ extension MarkdownStyler { guard token.standaloneParagraphRange(in: ctx.nsText) != nil else { continue } - let latexFontSize = HeadingHelpers.latexFontSize(for: token, tokens: ctx.tokens, baseFont: ctx.baseFont) + let latexFontSize = HeadingHelpers.latexFontSize(for: token, headings: [], baseFont: ctx.baseFont) // block $$ is never inside a heading if isActive { appendSecondaryMarkers(for: token, to: &attrs, theme: ctx.configuration.theme) @@ -68,11 +66,16 @@ extension MarkdownStyler { // draws that tiny inline image on the collapsed 1pt source line under // the table — visible as a stray dot. Skip inline LaTeX inside a // table; the table image already covers it. - let tableRanges = ctx.tokens.filter { $0.kind == .table }.map(\.range) + let scopedLatex = ctx.scoped(ctx.inlineLatexIndexed) + guard !scopedLatex.isEmpty else { return attrs } + // Containers that ENCLOSE an in-scope formula must overlap the scope, so + // scope-slicing these is exact; built once, not per formula. + let tableRanges = ctx.scoped(ctx.tableIndexed).map { $0.token.range } // Quote lines mute their text via foregroundColor, which the LaTeX *image* ignores — render it in mutedText instead so it matches the grey. - let blockquoteRanges = ctx.tokens.filter { $0.kind == .blockquote }.map(\.range) - for (idx, token) in ctx.tokens.enumerated() where token.kind == .inlineLatex { - if ctx.outsideScope(token.range) { continue } // clipped at application anyway + let blockquoteRanges = MarkdownStyler.StylingContext.indexed(ctx.tokens, .blockquote).map { $0.token.range } + // Built once, not re-scanned per formula (latexFontSize was O(#latex × #tokens)). + let headings = ctx.scoped(MarkdownStyler.StylingContext.indexed(ctx.tokens, .heading)).map { $0.token } + for (idx, token) in scopedLatex { if MarkdownDetection.isInsideCodeBlock(range: token.range, codeTokens: ctx.codeTokens) { continue } if tableRanges.contains(where: { tableRange in token.range.location >= tableRange.location @@ -83,7 +86,7 @@ extension MarkdownStyler { let isActive = ctx.activeTokenIndices.contains(idx) let latexContent = ctx.nsText.substring(with: token.contentRange) - let latexFontSize = HeadingHelpers.latexFontSize(for: token, tokens: ctx.tokens, baseFont: ctx.baseFont) + let latexFontSize = HeadingHelpers.latexFontSize(for: token, headings: headings, baseFont: ctx.baseFont) if isActive { for markerRange in token.markerRanges { diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift index 4be33c50..6ec9e3c8 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift @@ -30,7 +30,10 @@ extension MarkdownStyler { /// instead of re-rendering every inactive table on every keystroke. private static let tableImageCache: NSCache = { let cache = NSCache() - cache.countLimit = 128 + // Must exceed a document's unique-table count or a full restyle (load / + // theme / font change) re-renders every table (same thrash class as the + // metadata cap). NSCache still auto-evicts under memory pressure. + cache.countLimit = 2048 return cache }() @@ -96,8 +99,11 @@ extension MarkdownStyler { /// Parse + content-hash for a table source, memoized: both are pure in /// the source text but were recomputed for every table on every keystroke - /// (the non-render share of styleTables). FIFO-capped like the block - /// token memo. + /// (the non-render share of styleTables). FIFO-capped like the block token + /// memo — the cap MUST exceed a document's table count, else cyclic access + /// over the full table set is Bélády-pessimal under FIFO (~100% miss) and + /// every table re-parses+re-hashes every keystroke. + private static let tableMetaCap = 8192 private static let tableMetaLock = NSLock() private static var tableMetaCache: [String: (parsed: ParsedTable?, hash: Int)] = [:] private static var tableMetaOrder: [String] = [] @@ -116,7 +122,7 @@ extension MarkdownStyler { if tableMetaCache[source] == nil { tableMetaCache[source] = computed tableMetaOrder.append(source) - if tableMetaOrder.count > 512 { + if tableMetaOrder.count > tableMetaCap { tableMetaCache[tableMetaOrder.removeFirst()] = nil } } @@ -155,13 +161,19 @@ extension MarkdownStyler { var tableCount = 0 var renderedCount = 0 let tablesT0 = DispatchTime.now().uptimeNanoseconds - for (idx, token) in ctx.tokens.enumerated() where token.kind == .table { + // Iterate the pre-classified table array (not all document tokens); all + // tables are visited because the occurrence counter needs the full, + // document-order set for stable duplicate-table sourceIDs. + var metaNanos: UInt64 = 0 + for (idx, token) in ctx.tableIndexed { tableCount += 1 // Tokenizer already drops tables overlapping fenced code, so no re-check here. attrs.append((token.range, [.spellingState: 0])) + let metaT0 = DispatchTime.now().uptimeNanoseconds let source = ctx.nsText.substring(with: token.range) let meta = tableMeta(for: source) + metaNanos &+= DispatchTime.now().uptimeNanoseconds - metaT0 guard let parsed = meta.parsed else { continue } // Advance occurrence index even for active/out-of-scope tables so @@ -232,7 +244,8 @@ extension MarkdownStyler { } if tableCount > 0 { let ms = Double(DispatchTime.now().uptimeNanoseconds - tablesT0) / 1_000_000 - PerfTrace.note { "styleTables scanned=\(tableCount) tables, re-rendered=\(renderedCount) NSImage in \(String(format: "%.2f", ms))ms" } + let metaMs = Double(metaNanos) / 1_000_000 + PerfTrace.note { "styleTables scanned=\(tableCount) tables, re-rendered=\(renderedCount) NSImage in \(String(format: "%.2f", ms))ms (substring+meta=\(String(format: "%.2f", metaMs))ms)" } } return attrs } diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler.swift index 38e5470a..b6099c94 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler.swift @@ -18,6 +18,21 @@ import Foundation // MARK: - Styling Context extension MarkdownStyler { + typealias IndexedToken = (index: Int, token: MarkdownToken) + + /// Per-kind token arrays (each with the token's index into the full array, + /// for activeTokenIndices), built ONCE in the parse classification and + /// reused across keystrokes. Lets the NSImage passes iterate a small, + /// scope-sliced array instead of walking every document token per pass. + struct ClassifiedStyleTokens { + let inlineLatex: [IndexedToken] + let blockLatex: [IndexedToken] + let imageEmbed: [IndexedToken] + let imageLink: [IndexedToken] + let table: [IndexedToken] + let code: [MarkdownToken] // codeBlock + inlineCode, for isInsideCodeBlock checks + } + struct StylingContext { let nsText: NSString let tokens: [MarkdownToken] @@ -35,9 +50,43 @@ extension MarkdownStyler { /// so the NSImage passes can skip tokens wholly outside these bounds /// instead of walking every token in the document per keystroke. var scopeBounds: (lo: Int, hi: Int)? = nil + /// Pre-classified per-kind token arrays; nil for direct callers (tests), + /// which fall back to classifying `tokens` on demand. + var classified: ClassifiedStyleTokens? = nil var services: MarkdownEditorServices { configuration.services } + // Per-kind indexed arrays: the cached classification, or a one-off + // classification of `tokens` when a direct caller passed none. + var inlineLatexIndexed: [IndexedToken] { classified?.inlineLatex ?? Self.indexed(tokens, .inlineLatex) } + var blockLatexIndexed: [IndexedToken] { classified?.blockLatex ?? Self.indexed(tokens, .blockLatex) } + var imageEmbedIndexed: [IndexedToken] { classified?.imageEmbed ?? Self.indexed(tokens, .imageEmbed) } + var imageLinkIndexed: [IndexedToken] { classified?.imageLink ?? Self.indexed(tokens, .imageLink) } + var tableIndexed: [IndexedToken] { classified?.table ?? Self.indexed(tokens, .table) } + + static func indexed(_ tokens: [MarkdownToken], _ kind: MarkdownTokenKind) -> [IndexedToken] { + tokens.enumerated().compactMap { $0.element.kind == kind ? ($0.offset, $0.element) : nil } + } + + /// The slice of a location-sorted, non-overlapping per-kind array that + /// intersects the restyle scope — binary-searched so out-of-scope + /// tokens are never even visited. Whole array when scope is nil. + func scoped(_ arr: [IndexedToken]) -> ArraySlice { + guard let bounds = scopeBounds else { return arr[...] } + var lo = 0, hi = arr.count + while lo < hi { // first NSMaxRange > bounds.lo + let m = (lo + hi) / 2 + if NSMaxRange(arr[m].token.range) > bounds.lo { hi = m } else { lo = m + 1 } + } + let start = lo + hi = arr.count + while lo < hi { // first location >= bounds.hi + let m = (lo + hi) / 2 + if arr[m].token.range.location >= bounds.hi { hi = m } else { lo = m + 1 } + } + return arr[start.. Bool { @@ -68,6 +117,7 @@ enum MarkdownStyler { activeTokenIndices: Set, wikiLinkIDProvider: @escaping (NSRange) -> String? = { _ in nil }, precomputedTokens: [MarkdownToken]? = nil, + classified: ClassifiedStyleTokens? = nil, scopedRanges: [NSRange]? = nil, configuration: MarkdownEditorConfiguration = .default ) -> [StyledRange] { @@ -79,7 +129,7 @@ enum MarkdownStyler { let hi = valid.map({ NSMaxRange($0) }).max() else { return nil } return (lo, hi) } - let codeTokens = tokens.filter { $0.kind == .codeBlock || $0.kind == .inlineCode } + let codeTokens = classified?.code ?? tokens.filter { $0.kind == .codeBlock || $0.kind == .inlineCode } let baseFont = NSFont(name: fontName, size: fontSize) ?? NSFont.systemFont(ofSize: fontSize) let baseDefaultLineHeight = ceil( layoutBridge?.defaultLineHeight(for: baseFont) @@ -100,22 +150,28 @@ enum MarkdownStyler { ?? NSFont.systemFont(ofSize: hiddenMarkerSize), configuration: configuration, wikiLinkIDProvider: wikiLinkIDProvider, - scopeBounds: scopeBounds + scopeBounds: scopeBounds, + classified: classified ) var result: [StyledRange] = [] // AST-native styler handles everything but NSImage rendering (incl. the composition fixes). + let astT0 = DispatchTime.now().uptimeNanoseconds result += MarkdownASTStyler.styleAttributes( text: text, fontName: fontName, fontSize: fontSize, caretLocation: caretLocation, wikiLinkIDProvider: wikiLinkIDProvider, scopedRanges: scopedRanges, configuration: configuration ) + let astMs = Double(DispatchTime.now().uptimeNanoseconds - astT0) / 1_000_000 // NSImage rendering reuses the existing, proven machinery. + let imgT0 = DispatchTime.now().uptimeNanoseconds result += styleBlockLatex(ctx) result += styleInlineLatex(ctx) result += styleImageEmbeds(ctx) result += styleImageLinks(ctx) + let imgMs = Double(DispatchTime.now().uptimeNanoseconds - imgT0) / 1_000_000 result += styleTables(ctx) + PerfTrace.note { " styleAttributes: ast=\(String(format: "%.2f", astMs))ms latex+img4=\(String(format: "%.2f", imgMs))ms styledRanges=\(result.count)" } return result } } diff --git a/Sources/MarkdownEngine/Styling/TextStylingService.swift b/Sources/MarkdownEngine/Styling/TextStylingService.swift index 65e238fb..f61050b4 100644 --- a/Sources/MarkdownEngine/Styling/TextStylingService.swift +++ b/Sources/MarkdownEngine/Styling/TextStylingService.swift @@ -55,6 +55,7 @@ struct TextStylingService { activeTokenIndices: Set, wikiLinkIDProvider: @escaping (NSRange) -> String?, precomputedTokens: [MarkdownToken]? = nil, + classified: MarkdownStyler.ClassifiedStyleTokens? = nil, configuration: MarkdownEditorConfiguration = .default ) { let paragraphs = normalize(paragraphCandidates) @@ -70,6 +71,7 @@ struct TextStylingService { return } + let styleT0 = DispatchTime.now().uptimeNanoseconds let styledRanges = MarkdownStyler.styleAttributes( text: textView.string, fontName: baseFont.fontName, @@ -79,10 +81,13 @@ struct TextStylingService { activeTokenIndices: activeTokenIndices, wikiLinkIDProvider: wikiLinkIDProvider, precomputedTokens: precomputedTokens, + classified: classified, scopedRanges: paragraphs, configuration: configuration ) + let styleMs = Double(DispatchTime.now().uptimeNanoseconds - styleT0) / 1_000_000 + let spellT0 = DispatchTime.now().uptimeNanoseconds let spellingDisabledRanges = styledRanges.compactMap { (range, attrs) -> NSRange? in attrs[.spellingState] as? Int == 0 ? range : nil } @@ -96,6 +101,8 @@ struct TextStylingService { for disabledRange in spellingDisabledRanges { textView.textStorage?.addAttribute(.spellingState, value: 0, range: disabledRange) } + let spellMs = Double(DispatchTime.now().uptimeNanoseconds - spellT0) / 1_000_000 + let attrT0 = DispatchTime.now().uptimeNanoseconds for paragraph in paragraphs { textView.textStorage?.setAttributes([ .font: baseFont, @@ -111,9 +118,13 @@ struct TextStylingService { } } textView.textStorage?.endEditing() + let attrMs = Double(DispatchTime.now().uptimeNanoseconds - attrT0) / 1_000_000 // No ensureLayout here: + let evlT0 = DispatchTime.now().uptimeNanoseconds textView.setNeedsDisplay(textView.visibleRect) (textView as? NativeTextView)?.ensureVisibleLayout() + let evlMs = Double(DispatchTime.now().uptimeNanoseconds - evlT0) / 1_000_000 + PerfTrace.note { " restyle split: styleAttrs=\(String(format: "%.2f", styleMs))ms spell=\(String(format: "%.2f", spellMs))ms attrApply(paras=\(paragraphs.count))=\(String(format: "%.2f", attrMs))ms ensureVisLayout=\(String(format: "%.2f", evlMs))ms" } } private static func normalize(_ candidates: [NSRange]) -> [NSRange] { diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+CodeBlocks.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+CodeBlocks.swift index 4f63cd30..360c0c18 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+CodeBlocks.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+CodeBlocks.swift @@ -61,8 +61,20 @@ extension NativeTextViewCoordinator { didEnsureLayoutForCurrentDocument = true } + // Only on-screen code blocks have a visible copy button. Computing a + // viewRect (and copying the code) for every block in the document is + // O(doc) and dominates large files; cull to the laid-out viewport range + // — scroll hooks recompute as blocks come into view. + let visibleRange: NSRange? = { + guard let tlm = textView.textLayoutManager, + let vp = tlm.textViewportLayoutController.viewportRange else { return nil } + let start = tlm.offset(from: tlm.documentRange.location, to: vp.location) + return NSRange(location: start, length: tlm.offset(from: vp.location, to: vp.endLocation)) + }() + let selections: [CodeBlockSelection] = cachedCodeBlockTokens.compactMap { originalIndex, token in guard !activeTokenIndices.contains(originalIndex) else { return nil } + if let visibleRange, NSIntersectionRange(token.range, visibleRange).length == 0 { return nil } guard var boundingRect = textView.viewRect(forCharacterRange: token.range, using: layoutBridge) else { return nil } boundingRect.origin.x = textView.frame.origin.x + textView.textContainerOrigin.x - scrollOffset.x diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift index b48516c2..e4a20726 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift @@ -93,6 +93,7 @@ extension NativeTextViewCoordinator { // wikiLinkMetadata was just refreshed by makeDisplayState above, so ranges match here. wikiLinkIDProvider: { [weak self] range in self?.wikiLinkID(for: range) }, precomputedTokens: tokens, + classified: parsed.classified, configuration: configuration ) for (range, attrs) in ranges { @@ -127,7 +128,8 @@ extension NativeTextViewCoordinator { func restyleTextView( _ textView: NSTextView, paragraphCandidates: [NSRange], - tokens: [MarkdownToken]? = nil + tokens: [MarkdownToken]? = nil, + classified: MarkdownStyler.ClassifiedStyleTokens? = nil ) { // Raw mode: no restyling; typing keeps base attrs via the typing shim. guard !configuration.rawSourceMode else { return } @@ -150,6 +152,7 @@ extension NativeTextViewCoordinator { self?.wikiLinkID(for: range) }, precomputedTokens: tokens, + classified: classified, configuration: configuration ) // Reconcile wide-table overlays after layout settles. @@ -182,6 +185,11 @@ extension NativeTextViewCoordinator { var imageEmbedTokens: [MarkdownToken] = [] var tableTokens: [MarkdownToken] = [] var codeBlockTokensWithIndices: [(index: Int, token: MarkdownToken)] = [] + var inlineLatexIdx: [(index: Int, token: MarkdownToken)] = [] + var blockLatexIdx: [(index: Int, token: MarkdownToken)] = [] + var imageEmbedIdx: [(index: Int, token: MarkdownToken)] = [] + var imageLinkIdx: [(index: Int, token: MarkdownToken)] = [] + var tableIdx: [(index: Int, token: MarkdownToken)] = [] codeTokens.reserveCapacity(tokens.count / 2) latexTokens.reserveCapacity(tokens.count / 4) @@ -197,14 +205,20 @@ extension NativeTextViewCoordinator { } case .inlineLatex: latexTokens.append(token) + inlineLatexIdx.append((index, token)) case .blockLatex: blockLatexTokens.append(token) + blockLatexIdx.append((index, token)) case .wikiLink: wikiLinkTokens.append(token) case .imageEmbed: imageEmbedTokens.append(token) + imageEmbedIdx.append((index, token)) + case .imageLink: + imageLinkIdx.append((index, token)) case .table: tableTokens.append(token) + tableIdx.append((index, token)) default: break } @@ -220,6 +234,10 @@ extension NativeTextViewCoordinator { imageEmbedTokens: imageEmbedTokens, tableTokens: tableTokens, codeBlockTokensWithIndices: codeBlockTokensWithIndices, + classified: MarkdownStyler.ClassifiedStyleTokens( + inlineLatex: inlineLatexIdx, blockLatex: blockLatexIdx, + imageEmbed: imageEmbedIdx, imageLink: imageLinkIdx, + table: tableIdx, code: codeTokens), version: parsedDocumentVersion ) cachedParsedText = text @@ -229,7 +247,6 @@ extension NativeTextViewCoordinator { return parsed } - /// Memoized computeActiveTokenIndices — a pure function of /// (parsed.version, selection, suppressed) that otherwise runs up to /// three times per keystroke on identical inputs (pre-edit ask, @@ -306,7 +323,7 @@ extension NativeTextViewCoordinator { in: nsText, suppressed: !textView.isEditable ) - restyleTextView(textView, paragraphCandidates: paragraphs, tokens: tokens) + restyleTextView(textView, paragraphCandidates: paragraphs, tokens: tokens, classified: parsed.classified) } func applyInlineReplacement(_ request: InlineReplacementRequest, to textView: NSTextView) { diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift index 64931345..a3b99aa5 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift @@ -235,9 +235,22 @@ extension NativeTextViewCoordinator { if codeBlockStructureChanged { effectiveParagraphCandidates = [NSRange(location: 0, length: fullText.length)] } - // Always restyle paragraphs containing latex/imageEmbed tokens to avoid stale raw text. - let latexParagraphs = PerfTrace.measure("latexMap") { - (latexTokens + blockLatexTokens + parsed.imageEmbedTokens).map { fullText.paragraphRange(for: $0.range) } + // Restyle only latex/imageEmbed paragraphs the EDIT touches (mirrors the + // table loop below); the caret entering/leaving a formula, which flips + // rendered↔raw, is covered by tokenRestyleParagraphs. Blanket-restyling + // every such paragraph made scopeBounds span the whole document, which + // defeated the per-pass scope culling in the styler. + let latexParagraphs = PerfTrace.measure("latexMap") { () -> [NSRange] in + var out: [NSRange] = [] + for group in [latexTokens, blockLatexTokens, parsed.imageEmbedTokens] { + for token in group { + if token.range.location > NSMaxRange(safeEditedRange) { break } + if NSIntersectionRange(token.range, safeEditedRange).length > 0 { + out.append(fullText.paragraphRange(for: token.range)) + } + } + } + return out } effectiveParagraphCandidates.append(contentsOf: latexParagraphs) // A table renders as ONE image anchored on the block's FIRST paragraph. @@ -262,7 +275,7 @@ extension NativeTextViewCoordinator { previousActiveTokenIndices: preEditActiveTokenIndices )) - PerfTrace.measure("restyle") { restyleTextView(tv, paragraphCandidates: effectiveParagraphCandidates, tokens: tokens) } + PerfTrace.measure("restyle") { restyleTextView(tv, paragraphCandidates: effectiveParagraphCandidates, tokens: tokens, classified: parsed.classified) } PerfTrace.measure("codeSel") { updateCodeBlockSelection(textView: tv, parsed: parsed) } if wtActive { previousActiveTokenIndices = activeTokenIndices @@ -424,7 +437,7 @@ extension NativeTextViewCoordinator { needsRestyleAfterDrag = true } else if tokensChanged || taskSyntaxChanged || hrLineChanged || bulletSyntaxChanged || needsRestyleAfterDrag { needsRestyleAfterDrag = false - restyleTextView(tv, paragraphCandidates: paragraphCandidates, tokens: tokens) + restyleTextView(tv, paragraphCandidates: paragraphCandidates, tokens: tokens, classified: parsed.classified) } // Auto-select content when clicking (mouse) into a rendered (previously inactive) latex or image embed diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift index e98ebb80..408855ea 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift @@ -188,6 +188,10 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate { /// checks need the original index) — collected in the same single /// classification pass instead of a per-call full-token filter. let codeBlockTokensWithIndices: [(index: Int, token: MarkdownToken)] + /// Per-kind indexed token arrays for the styler's NSImage passes, built + /// in the same single classification pass so the passes iterate small + /// scope-sliced arrays instead of walking every document token. + let classified: MarkdownStyler.ClassifiedStyleTokens /// Bumped only when a FRESH parse builds this document — cache-hit /// returns share the version, so (version, selection, suppressed) is /// an exact memo key for pure derivations like active-token indices. From 51c284935778fd8de3bbf9d4b35bc6b39f00f2cf Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Sun, 12 Jul 2026 13:44:41 +0200 Subject: [PATCH 16/39] perf(diag): open the PERF frame at shouldChangeTextIn; gate the 1/64 full-rebuild verifiers behind MD_PERF_VERIFY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The printed per-keystroke totals were misleading in two ways: - The keystroke's real work starts in shouldChangeTextIn (pre-edit parse, smart-input interceptors — every space/Enter walks this path) and the first post-edit parse runs in the mid-edit selection change; both ran BEFORE PerfTrace.begin, so textDidChange's 'parse' span showed only the O(1) cache hit. The frame now opens in shouldChangeTextIn, begin() continues an already-open recent frame, and preParse/smartInput/ selStates/selParse/selRestyle spans attach to it. - Three sampled every-64th-keystroke verifier asserts (wiki splice, backtick census, spliced parse buffer) each ran full O(doc) rebuilds synchronously in the keystroke turn — periodic spikes in the very numbers under investigation. They are now opt-in via MD_PERF_VERIFY=1. Co-Authored-By: Claude Fable 5 --- .../Diagnostics/PerfTrace.swift | 18 ++++- .../Parser/DocumentParseState.swift | 4 +- ...tiveTextViewCoordinator+TextDelegate.swift | 68 +++++++++++-------- 3 files changed, 60 insertions(+), 30 deletions(-) diff --git a/Sources/MarkdownEngine/Diagnostics/PerfTrace.swift b/Sources/MarkdownEngine/Diagnostics/PerfTrace.swift index c7d032eb..ed90babc 100644 --- a/Sources/MarkdownEngine/Diagnostics/PerfTrace.swift +++ b/Sources/MarkdownEngine/Diagnostics/PerfTrace.swift @@ -19,8 +19,14 @@ import Foundation enum PerfTrace { #if DEBUG static var enabled = ProcessInfo.processInfo.environment["MD_PERF"] != "0" + /// Opt-in for the sampled full-rebuild verifier asserts (wiki splice, + /// backtick census, parse buffer). They run 3× O(doc) work synchronously + /// on every 64th keystroke — periodic spikes that pollute the PERF + /// numbers — so they stay off unless explicitly requested. + static let verifyEnabled = ProcessInfo.processInfo.environment["MD_PERF_VERIFY"] == "1" #else static let enabled = false + static let verifyEnabled = false #endif // All call sites run on the main thread (the coordinator + text view are @@ -36,13 +42,21 @@ enum PerfTrace { } /// Open a per-keystroke frame. Every `measure`/`note` until `end()` attaches to it. + /// A frame already opened this keystroke is CONTINUED, not reset: + /// shouldChangeTextIn opens the frame (so the pre-edit parse and the + /// smart-input interceptors are counted — they used to run before the + /// frame and were invisible), the mid-edit selection change and + /// textDidChange attach to it. A frame left open by an edit that never + /// reached textDidChange is considered stale after 1s and reset. static func begin(docLength len: Int) { guard enabled else { return } - active = true + let now = DispatchTime.now().uptimeNanoseconds docLength = len + if active, Double(now - frameStart) / 1_000_000 < 1_000 { return } + active = true phases.removeAll(keepingCapacity: true) notes.removeAll(keepingCapacity: true) - frameStart = DispatchTime.now().uptimeNanoseconds + frameStart = now } /// Time one sequential top-level phase of the current frame. diff --git a/Sources/MarkdownEngine/Parser/DocumentParseState.swift b/Sources/MarkdownEngine/Parser/DocumentParseState.swift index 45def7ad..dbaec051 100644 --- a/Sources/MarkdownEngine/Parser/DocumentParseState.swift +++ b/Sources/MarkdownEngine/Parser/DocumentParseState.swift @@ -76,8 +76,10 @@ final class DocumentParseState { changeEndNew: changeEndNew, delta: edit.delta) #if DEBUG // Sampled safety net: the spliced buffer must equal the storage. + // Opt-in (MD_PERF_VERIFY=1) — the fresh O(doc) extraction spikes + // every 64th keystroke and pollutes the PERF numbers. verifyCounter &+= 1 - if verifyCounter % 64 == 0 { + if PerfTrace.verifyEnabled, verifyCounter % 64 == 0 { var fresh = [unichar](repeating: 0, count: newLen) if newLen > 0 { ns.getCharacters(&fresh, range: NSRange(location: 0, length: newLen)) } assert(fresh == newChars, "spliced parse buffer diverged from the text storage") diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift index a3b99aa5..8f948a7c 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift @@ -153,9 +153,10 @@ extension NativeTextViewCoordinator { self.lastComputedStorage = storageState.storage #if DEBUG // Sampled safety net: every 64th keystroke, prove the splice equals - // a full rebuild. Remove together with PerfTrace after sign-off. + // a full rebuild. Opt-in (MD_PERF_VERIFY=1) — the O(doc) rebuild + // spikes pollute the PERF numbers. Remove with PerfTrace after sign-off. wikiVerifyCounter &+= 1 - if wikiVerifyCounter % 64 == 0 { + if PerfTrace.verifyEnabled, wikiVerifyCounter % 64 == 0 { let reference = WikiLinkService.makeStorageState( from: docString, existingMetadata: wikiLinkMetadata, @@ -309,7 +310,7 @@ extension NativeTextViewCoordinator { onInlineSelectionChange?(nil) return } - updateSelectionStates(tv) + PerfTrace.measure("selStates") { updateSelectionStates(tv) } let selLoc = selRange.location // Selection change fires BEFORE textDidChange mid-edit: hand the @@ -321,7 +322,10 @@ extension NativeTextViewCoordinator { let delta = (tv.string as NSString).length - previousDisplayLength return ParseEditDescriptor(editedRange: pending, delta: delta) }() - let parsed = parsedDocument(for: tv.string, edit: selectionEdit) + // The keystroke's FIRST post-edit parse happens here, not in + // textDidChange (whose "parse" span then O(1)-hits) — measure it so + // the printed frame stops understating the real parse cost. + let parsed = PerfTrace.measure("selParse") { parsedDocument(for: tv.string, edit: selectionEdit) } let tokens = parsed.tokens let codeTokens = parsed.codeTokens let latexTokens = parsed.latexTokens @@ -437,7 +441,9 @@ extension NativeTextViewCoordinator { needsRestyleAfterDrag = true } else if tokensChanged || taskSyntaxChanged || hrLineChanged || bulletSyntaxChanged || needsRestyleAfterDrag { needsRestyleAfterDrag = false - restyleTextView(tv, paragraphCandidates: paragraphCandidates, tokens: tokens, classified: parsed.classified) + PerfTrace.measure("selRestyle") { + restyleTextView(tv, paragraphCandidates: paragraphCandidates, tokens: tokens, classified: parsed.classified) + } } // Auto-select content when clicking (mouse) into a rendered (previously inactive) latex or image embed @@ -562,8 +568,10 @@ extension NativeTextViewCoordinator { let newWindow = MarkdownDetection.backtickWindowCount(in: fullText, around: editedRange) let count = previousBacktickCount - base.oldCount + newWindow #if DEBUG + // Opt-in (MD_PERF_VERIFY=1): the full scan is the O(doc) cost this + // census exists to avoid — as a default-on sample it skews the numbers. backtickVerifyCounter &+= 1 - if backtickVerifyCounter % 64 == 0 { + if PerfTrace.verifyEnabled, backtickVerifyCounter % 64 == 0 { assert(count == MarkdownDetection.tripleBacktickCount(in: fullText), "incremental backtick census diverged from the full scan") } @@ -572,6 +580,11 @@ extension NativeTextViewCoordinator { } public func textView(_ textView: NSTextView, shouldChangeTextIn affectedCharRange: NSRange, replacementString: String?) -> Bool { + let preNS = textView.string as NSString + // Open the keystroke's PERF frame HERE: the pre-edit parse and the + // smart-input interceptors below used to run before the frame existed + // and were invisible in the printed totals. + PerfTrace.begin(docLength: preNS.length) parseGeneration &+= 1 // Refresh the descriptor for EVERY proposed edit — including programmatic // ones. A smart-input interceptor that suppresses a keystroke and performs @@ -581,7 +594,6 @@ extension NativeTextViewCoordinator { pendingEditedRange = NSRange(location: affectedCharRange.location, length: replacementString?.utf16.count ?? 0) pendingEditCount += 1 // Pre-edit backtick window baseline for the incremental census. - let preNS = textView.string as NSString if affectedCharRange.location >= 0, NSMaxRange(affectedCharRange) <= preNS.length { pendingBacktickWindow = (affectedCharRange.location, affectedCharRange.length, MarkdownDetection.backtickWindowCount(in: preNS, around: affectedCharRange)) @@ -602,34 +614,36 @@ extension NativeTextViewCoordinator { pendingPreEditActiveTokenIndices = nil return true } - let parsed = parsedDocument(for: textView.string) + let parsed = PerfTrace.measure("preParse") { parsedDocument(for: textView.string) } pendingPreEditActiveTokenIndices = activeTokenIndices( parsed: parsed, selection: textView.selectedRange(), - in: textView.string as NSString, + in: preNS, suppressed: !textView.isEditable ) - // Block LaTeX auto-wrap: insert newlines to keep $$ on its own line - if MarkdownInputHandler.handleBlockLatexAutoWrap( - textView: textView, - affectedCharRange: affectedCharRange, - replacementString: replacementString, - blockLatexTokens: parsed.blockLatexTokens - ) { - return false - } + return PerfTrace.measure("smartInput") { + // Block LaTeX auto-wrap: insert newlines to keep $$ on its own line + if MarkdownInputHandler.handleBlockLatexAutoWrap( + textView: textView, + affectedCharRange: affectedCharRange, + replacementString: replacementString, + blockLatexTokens: parsed.blockLatexTokens + ) { + return false + } - if MarkdownInputHandler.handleImageEmbedAutoWrap( - textView: textView, - affectedCharRange: affectedCharRange, - replacementString: replacementString, - imageEmbedTokens: parsed.imageEmbedTokens - ) { - return false - } + if MarkdownInputHandler.handleImageEmbedAutoWrap( + textView: textView, + affectedCharRange: affectedCharRange, + replacementString: replacementString, + imageEmbedTokens: parsed.imageEmbedTokens + ) { + return false + } - return MarkdownInputHandler.handleListInsertion(textView: textView, affectedCharRange: affectedCharRange, replacementString: replacementString) + return MarkdownInputHandler.handleListInsertion(textView: textView, affectedCharRange: affectedCharRange, replacementString: replacementString) + } } public func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { From 63f10c3480001faba0846be4bd60a9001cac7be5 Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Sun, 12 Jul 2026 13:47:50 +0200 Subject: [PATCH 17/39] perf(input): answer 'is the caret in code?' from the keystroke's parse, not an O(doc) scan per space/Enter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every space/Enter/Tab/>/[/(/{ fell through the list handler's fast path into `textView.string.contains("`")` — a full bridge copy + scan of the document — and, once any backtick existed anywhere, a full tokenizer pass via MarkdownDetection.isInsideCodeBlock(location:in:). All of it BEFORE PerfTrace.begin, so it never showed up in the printed totals. The coordinator already parses the pre-edit document in shouldChangeTextIn; it now threads parsed.codeTokens down (MarkdownInputHandler → MarkdownLists) and the handler binary answer costs O(#code tokens). Direct callers without a parse keep the old derivation (isInsideCodeBlock: nil fallback). Also: the Enter-at-fence-opener completion counted ``` occurrences via components(separatedBy:) — an O(doc) substring-array allocation — and a bridged .contains for the closing check; both are now allocation-free NSString range(of:) scans. Behavior locked by ListHandlerCodeContextTests (7 characterization tests through the production delegate path). Co-Authored-By: Claude Fable 5 --- .../Input/MarkdownInputHandler.swift | 11 +- .../Input/MarkdownListHandler.swift | 34 +++-- ...tiveTextViewCoordinator+TextDelegate.swift | 3 +- .../ListHandlerCodeContextTests.swift | 119 ++++++++++++++++++ 4 files changed, 156 insertions(+), 11 deletions(-) create mode 100644 Tests/MarkdownEngineTests/ListHandlerCodeContextTests.swift diff --git a/Sources/MarkdownEngine/Input/MarkdownInputHandler.swift b/Sources/MarkdownEngine/Input/MarkdownInputHandler.swift index 9d16a74d..1d5283d0 100644 --- a/Sources/MarkdownEngine/Input/MarkdownInputHandler.swift +++ b/Sources/MarkdownEngine/Input/MarkdownInputHandler.swift @@ -11,8 +11,15 @@ import AppKit enum MarkdownInputHandler { - static func handleListInsertion(textView: NSTextView, affectedCharRange: NSRange, replacementString: String?) -> Bool { - return MarkdownLists.handleInsertion(textView: textView, affectedCharRange: affectedCharRange, replacementString: replacementString) + /// `codeTokens` (codeBlock + inlineCode, from the keystroke's existing + /// parse) answers "is the caret in code?" without the O(doc) document + /// scan the handler otherwise runs on every space/Enter/Tab. + static func handleListInsertion(textView: NSTextView, affectedCharRange: NSRange, replacementString: String?, codeTokens: [MarkdownToken]? = nil) -> Bool { + let isInsideCodeBlock = codeTokens.map { + MarkdownDetection.isInsideCodeBlock(location: affectedCharRange.location, codeTokens: $0) + } + return MarkdownLists.handleInsertion(textView: textView, affectedCharRange: affectedCharRange, + replacementString: replacementString, isInsideCodeBlock: isInsideCodeBlock) } // MARK: - Block LaTeX Auto-Wrap diff --git a/Sources/MarkdownEngine/Input/MarkdownListHandler.swift b/Sources/MarkdownEngine/Input/MarkdownListHandler.swift index 479e2086..ca58e05c 100644 --- a/Sources/MarkdownEngine/Input/MarkdownListHandler.swift +++ b/Sources/MarkdownEngine/Input/MarkdownListHandler.swift @@ -87,10 +87,14 @@ struct MarkdownLists { // MARK: - Input Handling - static func handleInsertion(textView: NSTextView, affectedCharRange: NSRange, replacementString: String?) -> Bool { + /// `isInsideCodeBlock` is the caller's pre-parsed answer for + /// `affectedCharRange.location` (the coordinator derives it from the + /// keystroke's existing parse). `nil` — direct callers without a parse — + /// falls back to deriving it here, which walks the whole document. + static func handleInsertion(textView: NSTextView, affectedCharRange: NSRange, replacementString: String?, isInsideCodeBlock: Bool? = nil) -> Bool { guard let replacementString = replacementString else { return true } - // Fast path: skip the expensive isInsideCodeBlock scan for ordinary typing. + // Fast path: plain characters never trigger list/pair/arrow handling. if replacementString.count == 1, let ch = replacementString.first, ch != ">" && ch != "[" && ch != "(" && ch != "{" && @@ -109,9 +113,11 @@ struct MarkdownLists { return false } - let isInCodeBlock = textView.string.contains("`") - ? MarkdownDetection.isInsideCodeBlock(location: affectedCharRange.location, in: textView.string) - : false + let isInCodeBlock = isInsideCodeBlock ?? ( + textView.string.contains("`") + ? MarkdownDetection.isInsideCodeBlock(location: affectedCharRange.location, in: textView.string) + : false + ) if replacementString == ">" && affectedCharRange.length == 0 && !isInCodeBlock { let insertionLocation = affectedCharRange.location @@ -204,12 +210,24 @@ struct MarkdownLists { // Horizontal rules render via the styler; source stays literal `---` so files round-trip. if currentLine.range(of: "^```\\w*$", options: .regularExpression) != nil { - let textBeforeLine = nsText.substring(to: currentLineRange.location) - let openingCount = textBeforeLine.components(separatedBy: "```").count - 1 + // Non-overlapping ``` count before the line (what + // components(separatedBy:).count-1 computed, without + // materializing an O(doc) substring array). + var openingCount = 0 + var searchLocation = 0 + while searchLocation < currentLineRange.location { + let found = nsText.range(of: "```", options: [], + range: NSRange(location: searchLocation, + length: currentLineRange.location - searchLocation)) + if found.location == NSNotFound { break } + openingCount += 1 + searchLocation = NSMaxRange(found) + } let afterLineStart = currentLineRange.location + currentLineRange.length let hasClosingAfter: Bool = { guard afterLineStart < nsText.length else { return false } - return nsText.substring(from: afterLineStart).contains("```") + let after = NSRange(location: afterLineStart, length: nsText.length - afterLineStart) + return nsText.range(of: "```", options: [], range: after).location != NSNotFound }() let lineEnd = currentLineRange.location + max(0, currentLineRange.length - 1) let cursorAtLineEnd = affectedCharRange.location >= lineEnd diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift index 8f948a7c..70d428d9 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift @@ -642,7 +642,8 @@ extension NativeTextViewCoordinator { return false } - return MarkdownInputHandler.handleListInsertion(textView: textView, affectedCharRange: affectedCharRange, replacementString: replacementString) + return MarkdownInputHandler.handleListInsertion(textView: textView, affectedCharRange: affectedCharRange, + replacementString: replacementString, codeTokens: parsed.codeTokens) } } diff --git a/Tests/MarkdownEngineTests/ListHandlerCodeContextTests.swift b/Tests/MarkdownEngineTests/ListHandlerCodeContextTests.swift new file mode 100644 index 00000000..a9f93931 --- /dev/null +++ b/Tests/MarkdownEngineTests/ListHandlerCodeContextTests.swift @@ -0,0 +1,119 @@ +// +// ListHandlerCodeContextTests.swift +// MarkdownEngine +// +// Created by Luca Chen on 12.07.26. +// +// Characterization for the smart-input list handler's code-block awareness: +// list continuation / Tab indent / "->" substitution must stay inert inside +// fenced code, and fence completion must close an unmatched ``` opener. +// Locks the behavior across the O(doc)-scan removal (the handler used to +// re-derive "am I in code?" via a full-document contains("`") + tokenizer +// pass on EVERY space/Enter/Tab). +// + +import AppKit +import SwiftUI +import Testing +@testable import MarkdownEngine + +@MainActor +@Suite("List handler code-block context") +struct ListHandlerCodeContextTests { + + /// Editor wired like production: coordinator as delegate, sync state seeded. + private func makeEditor(text: String) -> (NativeTextView, NativeTextViewCoordinator) { + _ = NSApplication.shared + let textView = NativeTextView(frame: NSRect(x: 0, y: 0, width: 800, height: 600)) + textView.isEditable = true + textView.configuration = .default + let coordinator = NativeTextViewCoordinator( + text: .constant(text), + fontName: "SF Pro Text", + fontSize: 14, + isWikiLinkActive: .constant(false), + onLinkClick: nil, + onInlineSelectionChange: nil + ) + coordinator.textView = textView + textView.delegate = coordinator + textView.string = text + coordinator.lastSyncedText = text + coordinator.lastComputedStorage = text + coordinator.previousDisplayLength = (text as NSString).length + return (textView, coordinator) + } + + @Test func enterContinuesListOutsideCode() { + let (tv, _) = makeEditor(text: "- hello") + tv.setSelectedRange(NSRange(location: 7, length: 0)) + + tv.insertText("\n", replacementRange: NSRange(location: 7, length: 0)) + + #expect(tv.string == "- hello\n- ") + } + + @Test func enterDoesNotContinueListInsideFencedCode() { + let text = "```\n- hello\n```" + let (tv, _) = makeEditor(text: text) + // Caret at the end of "- hello" (inside the fence). + tv.setSelectedRange(NSRange(location: 11, length: 0)) + + tv.insertText("\n", replacementRange: NSRange(location: 11, length: 0)) + + #expect(tv.string == "```\n- hello\n\n```") + } + + @Test func tabIndentsListItemOutsideCode() { + let (tv, _) = makeEditor(text: "- hello") + tv.setSelectedRange(NSRange(location: 7, length: 0)) + + tv.insertText("\t", replacementRange: NSRange(location: 7, length: 0)) + + #expect(tv.string == "\t- hello") + } + + @Test func tabStaysLiteralInsideFencedCode() { + let text = "```\n- hello\n```" + let (tv, _) = makeEditor(text: text) + tv.setSelectedRange(NSRange(location: 11, length: 0)) + + tv.insertText("\t", replacementRange: NSRange(location: 11, length: 0)) + + #expect(tv.string == "```\n- hello\t\n```") + } + + @Test func arrowSubstitutionStaysLiteralInsideFencedCode() { + let text = "```\nabc - def\n```" + let (tv, _) = makeEditor(text: text) + // Right after the "-" inside the fence. + tv.setSelectedRange(NSRange(location: 9, length: 0)) + + tv.insertText(">", replacementRange: NSRange(location: 9, length: 0)) + + #expect(tv.string == "```\nabc -> def\n```") + } + + @Test func enterAtUnmatchedFenceOpenerInsertsClosingFence() { + let (tv, _) = makeEditor(text: "```swift") + tv.setSelectedRange(NSRange(location: 8, length: 0)) + + tv.insertText("\n", replacementRange: NSRange(location: 8, length: 0)) + + #expect(tv.string == "```swift\n\n```") + } + + @Test func enterAtClosingFenceJustBreaksLine() { + // Caret at the end of the CLOSING fence: one ``` precedes the line + // (odd → it's a closer, not an opener), so no completion fires and + // the newline stays literal. + let text = "```\ncode\n```" + let (tv, _) = makeEditor(text: text) + let end = (text as NSString).length + tv.setSelectedRange(NSRange(location: end, length: 0)) + + tv.insertText("\n", replacementRange: NSRange(location: end, length: 0)) + + #expect(tv.string == "```\ncode\n```\n") + } +} From cf9680b451e8babe21fac5d2f9252924455ec079 Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Sun, 12 Jul 2026 13:51:17 +0200 Subject: [PATCH 18/39] perf(hotpath): parse pre-edit BEFORE the generation bump; one string bridge per delegate event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two O(doc)-per-keystroke costs in the delegate callbacks, both memcpy-class but unconditional: - shouldChangeTextIn bumped parseGeneration first and parsed the pre-edit text after, so the parse always missed the O(1) generation check and fell onto the O(doc) NSString.isEqual byte-compare. The text at that point is still pre-edit — identical to what the previous cycle cached — so parsing BEFORE the bump O(1)-hits. The bump (and the descriptor refresh) still runs for every proposed edit, including programmatic/WT/raw ones. - shouldChangeTextIn and textViewDidChangeSelection re-read tv.string at ~15 separate sites per keystroke; each read is an O(doc) copy of the mutable backing store (the 40c8508 hoist only covered textDidChange). Both now bridge once and pass the hoisted string/NSString through (updateSelectionStates takes it as a parameter; the wiki snap-back branch keeps its explicit re-reads because it mutates the text). Co-Authored-By: Claude Fable 5 --- ...tiveTextViewCoordinator+TextDelegate.swift | 67 +++++++++++++------ 1 file changed, 45 insertions(+), 22 deletions(-) diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift index 70d428d9..d12fb896 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift @@ -301,16 +301,22 @@ extension NativeTextViewCoordinator { if isWritingToolsActive { return } let selRange = tv.selectedRange() let currentEventType = NSApp.currentEvent?.type + // ONE bridge of the document text — this handler fires on every + // keystroke (mid-edit) and every caret move, and used to re-copy + // `tv.string` (O(doc) each) at a dozen separate sites below. The + // snap-back branch mutates the text and re-reads explicitly. + let docText = tv.string + let nsText = docText as NSString // Mouse-/Wake-Fokus auf Link: kein Preview, erst Navigation. Gilt für alle Nicht-Key-Events. if currentEventType != .keyDown, - selRange.location < (tv.string as NSString).length, + selRange.location < nsText.length, tv.textStorage?.attribute(.link, at: selRange.location, effectiveRange: nil) != nil { isImageEmbedActive = false isWikiLinkActive = false onInlineSelectionChange?(nil) return } - PerfTrace.measure("selStates") { updateSelectionStates(tv) } + PerfTrace.measure("selStates") { updateSelectionStates(tv, nsText: nsText) } let selLoc = selRange.location // Selection change fires BEFORE textDidChange mid-edit: hand the @@ -319,18 +325,17 @@ extension NativeTextViewCoordinator { let selectionEdit: ParseEditDescriptor? = { guard let pending = pendingEditedRange, pendingEditCount == 1, previousDisplayLength >= 0 else { return nil } - let delta = (tv.string as NSString).length - previousDisplayLength + let delta = nsText.length - previousDisplayLength return ParseEditDescriptor(editedRange: pending, delta: delta) }() // The keystroke's FIRST post-edit parse happens here, not in // textDidChange (whose "parse" span then O(1)-hits) — measure it so // the printed frame stops understating the real parse cost. - let parsed = PerfTrace.measure("selParse") { parsedDocument(for: tv.string, edit: selectionEdit) } + let parsed = PerfTrace.measure("selParse") { parsedDocument(for: docText, edit: selectionEdit) } let tokens = parsed.tokens let codeTokens = parsed.codeTokens let latexTokens = parsed.latexTokens let blockLatexTokens = parsed.blockLatexTokens - let nsText = tv.string as NSString let prevActive = activeTokenIndices activeTokenIndices = activeTokenIndices(parsed: parsed, selection: selRange, in: nsText, suppressed: !tv.isEditable) @@ -410,9 +415,9 @@ extension NativeTextViewCoordinator { // cursor-out (after editing the brackets) leaves the line stuck on // raw chars. let prevTaskSyntax = previousCaretLocation.flatMap { - MarkdownStyler.taskSyntaxRange(at: $0, in: tv.string) + MarkdownStyler.taskSyntaxRange(at: $0, in: docText) } - let currentTaskSyntax = MarkdownStyler.taskSyntaxRange(at: selLoc, in: tv.string) + let currentTaskSyntax = MarkdownStyler.taskSyntaxRange(at: selLoc, in: docText) let taskSyntaxChanged = prevTaskSyntax?.location != currentTaskSyntax?.location || prevTaskSyntax?.length != currentTaskSyntax?.length // Caret crossings in/out of a thematic-break (HR) line also need a @@ -421,16 +426,16 @@ extension NativeTextViewCoordinator { // `---` / `***` / `___` line. Without this, clicking on a rendered // HR wouldn't reveal the source dashes for editing. let prevHRLine = previousCaretLocation.flatMap { - MarkdownStyler.hrLineRange(at: $0, in: tv.string) + MarkdownStyler.hrLineRange(at: $0, in: docText) } - let currentHRLine = MarkdownStyler.hrLineRange(at: selLoc, in: tv.string) + let currentHRLine = MarkdownStyler.hrLineRange(at: selLoc, in: docText) let hrLineChanged = prevHRLine?.location != currentHRLine?.location || prevHRLine?.length != currentHRLine?.length // Bullet markers: caret in/out of `- ` syntax flips glyph ↔ raw. let prevBulletSyntax = previousCaretLocation.flatMap { - MarkdownStyler.bulletSyntaxRange(at: $0, in: tv.string) + MarkdownStyler.bulletSyntaxRange(at: $0, in: docText) } - let currentBulletSyntax = MarkdownStyler.bulletSyntaxRange(at: selLoc, in: tv.string) + let currentBulletSyntax = MarkdownStyler.bulletSyntaxRange(at: selLoc, in: docText) let bulletSyntaxChanged = prevBulletSyntax?.location != currentBulletSyntax?.location || prevBulletSyntax?.length != currentBulletSyntax?.length // Mid-drag restyle is suppressed (revealing markers shifts the layout → drag hit-test lands short, dropping trailing chars) and replayed on release. @@ -466,7 +471,8 @@ extension NativeTextViewCoordinator { } } - let nsString = tv.string as NSString + // Text unchanged past this point (the snap-back branch returned above); + // only the selection may have moved. let selLocation = tv.selectedRange().location let inlineContext = inlineTokenContext( at: selLocation, @@ -497,14 +503,14 @@ extension NativeTextViewCoordinator { // no longer lives in the editor text — it sits in the `.wikiLinkID` side-channel. let placeholder: String if case .imageEmbed(let token) = inlineContext { - let embedName = nsString.substring(with: token.contentRange) + let embedName = nsText.substring(with: token.contentRange) if let suffix = wikiLinkID(for: token.range), !suffix.isEmpty { placeholder = "![[\(embedName)|\(suffix)]]" } else { placeholder = "![[\(embedName)]]" } } else { - placeholder = nsString.substring(with: displayRange) + placeholder = nsText.substring(with: displayRange) } let storageRange = inlineContext.selectionKind == .wikiLink ? storageRange(containingDisplayLocation: selLocation) ?? storageRange(forDisplayRange: displayRange) @@ -580,11 +586,30 @@ extension NativeTextViewCoordinator { } public func textView(_ textView: NSTextView, shouldChangeTextIn affectedCharRange: NSRange, replacementString: String?) -> Bool { - let preNS = textView.string as NSString + // ONE bridge of the pre-edit text — every `textView.string` read is an + // O(doc) copy of the mutable backing store; this function used to take + // four of them per keystroke. + let preText = textView.string + let preNS = preText as NSString // Open the keystroke's PERF frame HERE: the pre-edit parse and the // smart-input interceptors below used to run before the frame existed // and were invisible in the printed totals. PerfTrace.begin(docLength: preNS.length) + + // Pre-edit parse for the interactive path, BEFORE the generation bump: + // the text is still pre-edit, so this O(1)-hits the cache the previous + // cycle left behind. Bumping first forced parsedDocument onto its + // O(doc) byte-compare verify on every ordinary keystroke. + let outOfBounds = affectedCharRange.location > preNS.length + || affectedCharRange.location + affectedCharRange.length > preNS.length + let isUndoRedo = textView.undoManager?.isUndoing == true + || textView.undoManager?.isRedoing == true + let interactive = !isProgrammaticEdit && !isWritingToolsActive + && !configuration.rawSourceMode && !outOfBounds && !isUndoRedo + let preEditParsed = interactive + ? PerfTrace.measure("preParse") { parsedDocument(for: preText) } + : nil + parseGeneration &+= 1 // Refresh the descriptor for EVERY proposed edit — including programmatic // ones. A smart-input interceptor that suppresses a keystroke and performs @@ -604,17 +629,15 @@ extension NativeTextViewCoordinator { if isWritingToolsActive { return true } // Raw mode: plain-text editing — no smart Markdown input. if configuration.rawSourceMode { return true } - let currentLen = (textView.string as NSString).length - let maxR = affectedCharRange.location + affectedCharRange.length - if affectedCharRange.location > currentLen || maxR > currentLen { + if outOfBounds { pendingPreEditActiveTokenIndices = nil return false } - if textView.undoManager?.isUndoing == true || textView.undoManager?.isRedoing == true { + if isUndoRedo { pendingPreEditActiveTokenIndices = nil return true } - let parsed = PerfTrace.measure("preParse") { parsedDocument(for: textView.string) } + guard let parsed = preEditParsed else { return true } pendingPreEditActiveTokenIndices = activeTokenIndices( parsed: parsed, selection: textView.selectedRange(), @@ -740,8 +763,8 @@ extension NativeTextViewCoordinator { return (containerX - f.minX) / f.width } - func updateSelectionStates(_ tv: NSTextView) { - let nsText = tv.string as NSString + func updateSelectionStates(_ tv: NSTextView, nsText: NSString? = nil) { + let nsText = nsText ?? (tv.string as NSString) let selRange = tv.selectedRange() let bus = configuration.services.bus let center = NotificationCenter.default From a3e0d52205245d7d2b1c1db35b3d861d3a3f33a1 Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Sun, 12 Jul 2026 13:55:10 +0200 Subject: [PATCH 19/39] perf(restyle): scope caret-move formula candidates; binary-search the edit-scoped token lookups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit textViewDidChangeSelection mapped EVERY latex/blockLatex/imageEmbed token in the document to its paragraph on every selection change — including the mid-keystroke one whose restyle is skipped anyway — and fed them all into each caret-move restyle, widening scopeBounds to the whole document and defeating the styler's per-pass culling (O(#formulas) per caret move; the formula-rich perf docs hit this hard). - Candidates are now built only when the restyle actually runs. - The formula map covers only tokens inside the caret/previous-caret paragraphs (rendered↔raw flips of entered/left tokens were already handled by tokenRestyleParagraphs). - The edit-scoped token lookups in textDidChange (latexMap, edited tables) and the styler's scope culling now share one binary-searched slice helper (MarkdownStyler.scopedSlice) instead of walking each per-kind array linearly from the document head. Co-Authored-By: Claude Fable 5 --- .../Styling/MarkdownStyler.swift | 33 +++++---- ...tiveTextViewCoordinator+TextDelegate.swift | 72 ++++++++++--------- 2 files changed, 61 insertions(+), 44 deletions(-) diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler.swift index b6099c94..add6f2ef 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler.swift @@ -73,18 +73,7 @@ extension MarkdownStyler { /// tokens are never even visited. Whole array when scope is nil. func scoped(_ arr: [IndexedToken]) -> ArraySlice { guard let bounds = scopeBounds else { return arr[...] } - var lo = 0, hi = arr.count - while lo < hi { // first NSMaxRange > bounds.lo - let m = (lo + hi) / 2 - if NSMaxRange(arr[m].token.range) > bounds.lo { hi = m } else { lo = m + 1 } - } - let start = lo - hi = arr.count - while lo < hi { // first location >= bounds.hi - let m = (lo + hi) / 2 - if arr[m].token.range.location >= bounds.hi { hi = m } else { lo = m + 1 } - } - return arr[start..= scopeBounds.hi } } + + /// Binary-searched slice of a location-sorted, non-overlapping per-kind + /// array whose tokens intersect `[lo, hi)` — tokens outside are never + /// visited. Shared by the styler's scope culling and the coordinator's + /// edit-scoped candidate collection (which used to walk each array + /// linearly from the document head to the edit). + static func scopedSlice(_ arr: [IndexedToken], lo bound: Int, hi upper: Int) -> ArraySlice { + var lo = 0, hi = arr.count + while lo < hi { // first NSMaxRange > bound + let m = (lo + hi) / 2 + if NSMaxRange(arr[m].token.range) > bound { hi = m } else { lo = m + 1 } + } + let start = lo + hi = arr.count + while lo < hi { // first location >= upper + let m = (lo + hi) / 2 + if arr[m].token.range.location >= upper { hi = m } else { lo = m + 1 } + } + return arr[start.. [NSRange] in var out: [NSRange] = [] - for group in [latexTokens, blockLatexTokens, parsed.imageEmbedTokens] { - for token in group { - if token.range.location > NSMaxRange(safeEditedRange) { break } - if NSIntersectionRange(token.range, safeEditedRange).length > 0 { - out.append(fullText.paragraphRange(for: token.range)) - } + // Binary-searched slices — the old loops walked each array from + // the document head to the edit on every keystroke. + for group in [parsed.classified.inlineLatex, parsed.classified.blockLatex, parsed.classified.imageEmbed] { + for (_, token) in MarkdownStyler.scopedSlice(group, lo: safeEditedRange.location, hi: NSMaxRange(safeEditedRange)) + where NSIntersectionRange(token.range, safeEditedRange).length > 0 { + out.append(fullText.paragraphRange(for: token.range)) } } return out @@ -259,14 +259,11 @@ extension NativeTextViewCoordinator { // that merges into an existing table), the styler re-emits the anchor // against the FULL block — restyling only the edited rows would clip // that anchor away and the table goes blank until a full restyle. - // Location-sorted classified tables: early-exit past the edit instead - // of a full-token filter per keystroke. + // Location-sorted classified tables, binary-searched to the edit. var editedTableParagraphs: [NSRange] = [] - for token in parsed.tableTokens { - if token.range.location > NSMaxRange(safeEditedRange) { break } - if NSIntersectionRange(token.range, safeEditedRange).length > 0 { - editedTableParagraphs.append(fullText.paragraphRange(for: token.range)) - } + for (_, token) in MarkdownStyler.scopedSlice(parsed.classified.table, lo: safeEditedRange.location, hi: NSMaxRange(safeEditedRange)) + where NSIntersectionRange(token.range, safeEditedRange).length > 0 { + editedTableParagraphs.append(fullText.paragraphRange(for: token.range)) } effectiveParagraphCandidates.append(contentsOf: editedTableParagraphs) effectiveParagraphCandidates.append(contentsOf: tokenRestyleParagraphs( @@ -387,25 +384,6 @@ extension NativeTextViewCoordinator { let caretLoc = selRange.location let paragraphRange = nsText.paragraphRange(for: NSRange(location: caretLoc, length: 0)) - var paragraphCandidates: [NSRange] = [paragraphRange] - if paragraphRange.length == 0 && caretLoc > 0 { - paragraphCandidates.append(nsText.paragraphRange(for: NSRange(location: max(0, caretLoc - 1), length: 0))) - } - if let prevLoc = previousCaretLocation, prevLoc != caretLoc { - let safePrev = min(prevLoc, nsText.length) - let prevPara = nsText.paragraphRange(for: NSRange(location: safePrev, length: 0)) - paragraphCandidates.append(prevPara) - } - // Also restyle paragraphs containing latex/imageEmbed tokens to refresh rendering. - let latexParagraphs = (latexTokens + blockLatexTokens + parsed.imageEmbedTokens).map { nsText.paragraphRange(for: $0.range) } - paragraphCandidates.append(contentsOf: latexParagraphs) - paragraphCandidates.append(contentsOf: tokenRestyleParagraphs( - in: nsText, - tokens: tokens, - currentActiveTokenIndices: activeTokenIndices, - previousActiveTokenIndices: previousActiveTokenIndices - )) - let shouldSkipSelectionRestyle = pendingEditedRange != nil let tokensChanged = activeTokenIndices != prevActive // Caret crossings in/out of `- [ ]` syntax need a restyle too: task @@ -446,6 +424,36 @@ extension NativeTextViewCoordinator { needsRestyleAfterDrag = true } else if tokensChanged || taskSyntaxChanged || hrLineChanged || bulletSyntaxChanged || needsRestyleAfterDrag { needsRestyleAfterDrag = false + // Candidates are built ONLY when a restyle actually runs — this + // used to happen unconditionally on every selection change, + // including the mid-keystroke one that skips the restyle above. + var paragraphCandidates: [NSRange] = [paragraphRange] + if paragraphRange.length == 0 && caretLoc > 0 { + paragraphCandidates.append(nsText.paragraphRange(for: NSRange(location: max(0, caretLoc - 1), length: 0))) + } + if let prevLoc = previousCaretLocation, prevLoc != caretLoc { + let safePrev = min(prevLoc, nsText.length) + paragraphCandidates.append(nsText.paragraphRange(for: NSRange(location: safePrev, length: 0))) + } + // Latex/imageEmbed tokens only inside the caret/previous-caret + // paragraphs (binary-searched); the rendered↔raw flip of a token + // the caret entered or left is covered by tokenRestyleParagraphs. + // The old blanket map over EVERY formula in the document widened + // scopeBounds to the whole document on every caret-move restyle, + // defeating the styler's per-pass culling — O(#formulas) each. + let scopeLo = paragraphCandidates.map(\.location).min() ?? 0 + let scopeHi = paragraphCandidates.map { NSMaxRange($0) }.max() ?? 0 + for group in [parsed.classified.inlineLatex, parsed.classified.blockLatex, parsed.classified.imageEmbed] { + for (_, token) in MarkdownStyler.scopedSlice(group, lo: scopeLo, hi: scopeHi) { + paragraphCandidates.append(nsText.paragraphRange(for: token.range)) + } + } + paragraphCandidates.append(contentsOf: tokenRestyleParagraphs( + in: nsText, + tokens: tokens, + currentActiveTokenIndices: activeTokenIndices, + previousActiveTokenIndices: previousActiveTokenIndices + )) PerfTrace.measure("selRestyle") { restyleTextView(tv, paragraphCandidates: paragraphCandidates, tokens: tokens, classified: parsed.classified) } From 288c5902c0f4c64d10feb834b8bf3b497ca46994 Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Sun, 12 Jul 2026 13:58:46 +0200 Subject: [PATCH 20/39] =?UTF-8?q?perf(restyle):=20hand=20the=20keystroke's?= =?UTF-8?q?=20block=20list=20to=20the=20styler=20=E2=80=94=20no=20per-rest?= =?UTF-8?q?yle=20buffer=20re-extraction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every restyle ran MarkdownASTStyler → DocumentAST.parse → BlockParser.parse with no buffer, so even a cache HIT first allocated a doc-length [unichar], getCharacters-copied the whole document, and memcmp'd it against the seeded cache — O(doc) per keystroke that grew with file size and showed up inside the 'restyle' span. ParsedDocument now carries the block list its tokens were derived from (DocumentParseState.currentBlocks), and the restyle hands it down as precomputedBlocks (restyleTextView → TextStylingService → MarkdownStyler → MarkdownASTStyler → DocumentAST.parse), skipping BlockParser.parse entirely. Direct callers without a parse (initial load, appearance change, tests) keep the derive-it-here path. restyleParagraphs also bridged tv.string twice per call; hoisted to one. Co-Authored-By: Claude Fable 5 --- .../Parser/DocumentParseState.swift | 7 +++ .../MarkdownEngine/Parser/MarkdownAST.swift | 7 ++- .../Styling/MarkdownASTStyler.swift | 3 +- .../Styling/MarkdownStyler.swift | 4 +- .../Styling/TextStylingService.swift | 2 + .../NativeTextViewCoordinator+Restyling.swift | 13 +++-- ...tiveTextViewCoordinator+TextDelegate.swift | 5 +- .../NativeTextViewCoordinator.swift | 4 ++ .../PrecomputedBlocksTests.swift | 51 +++++++++++++++++++ 9 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 Tests/MarkdownEngineTests/PrecomputedBlocksTests.swift diff --git a/Sources/MarkdownEngine/Parser/DocumentParseState.swift b/Sources/MarkdownEngine/Parser/DocumentParseState.swift index dbaec051..ddf32053 100644 --- a/Sources/MarkdownEngine/Parser/DocumentParseState.swift +++ b/Sources/MarkdownEngine/Parser/DocumentParseState.swift @@ -31,6 +31,13 @@ final class DocumentParseState { private var verifyCounter: UInt = 0 #endif + /// The block list matching the most recent `tokens(for:edit:)` call — + /// handed to the restyle so DocumentAST.parse skips the block parser. + var currentBlocks: [Block] { + lock.lock(); defer { lock.unlock() } + return blocks + } + /// Drop all state (document switch / full rebuild) — the next parse /// re-extracts and re-parses from scratch. func invalidate() { diff --git a/Sources/MarkdownEngine/Parser/MarkdownAST.swift b/Sources/MarkdownEngine/Parser/MarkdownAST.swift index 3b8e6e13..e32aa93d 100644 --- a/Sources/MarkdownEngine/Parser/MarkdownAST.swift +++ b/Sources/MarkdownEngine/Parser/MarkdownAST.swift @@ -53,9 +53,12 @@ enum DocumentAST { private static let tab: unichar = 0x09 /// Build the document AST; `scopedRanges` parses inlines only for intersecting blocks. - static func parse(_ text: String, scopedRanges: [NSRange]? = nil) -> [BlockNode] { + /// `precomputedBlocks` (the keystroke's own parse state, handed down by the + /// restyle) skips BlockParser.parse — whose cache "hit" still re-extracts + /// and memcmps the full document buffer — entirely. + static func parse(_ text: String, scopedRanges: [NSRange]? = nil, precomputedBlocks: [Block]? = nil) -> [BlockNode] { let ns = text as NSString - let blocks = BlockParser.parse(text) + let blocks = precomputedBlocks ?? BlockParser.parse(text) // Scoped mode: skip building BlockNodes for blocks outside the edit. // Blocks tile the document in order, so one sweep over sorted candidate // ranges replaces scanning every candidate per block (which went diff --git a/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift b/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift index 51a53942..6c9e91fa 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift @@ -29,6 +29,7 @@ enum MarkdownASTStyler { caretLocation: Int = -1, wikiLinkIDProvider: @escaping (NSRange) -> String? = { _ in nil }, scopedRanges: [NSRange]? = nil, + precomputedBlocks: [Block]? = nil, configuration: MarkdownEditorConfiguration = .default ) -> [StyledRange] { let baseFont = NSFont(name: fontName, size: fontSize) ?? .systemFont(ofSize: fontSize) @@ -64,7 +65,7 @@ enum MarkdownASTStyler { wikiLinkID: wikiLinkIDProvider, scopedRanges: scopedRanges ) - let blocks = DocumentAST.parse(text, scopedRanges: scopedRanges) + let blocks = DocumentAST.parse(text, scopedRanges: scopedRanges, precomputedBlocks: precomputedBlocks) var attrs: [StyledRange] = [] for block in blocks where ctx.inScope(block.range) { styleBlock(block, font: baseFont, ctx: ctx, into: &attrs) diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler.swift index add6f2ef..c40c0f2a 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler.swift @@ -127,6 +127,7 @@ enum MarkdownStyler { wikiLinkIDProvider: @escaping (NSRange) -> String? = { _ in nil }, precomputedTokens: [MarkdownToken]? = nil, classified: ClassifiedStyleTokens? = nil, + precomputedBlocks: [Block]? = nil, scopedRanges: [NSRange]? = nil, configuration: MarkdownEditorConfiguration = .default ) -> [StyledRange] { @@ -169,7 +170,8 @@ enum MarkdownStyler { result += MarkdownASTStyler.styleAttributes( text: text, fontName: fontName, fontSize: fontSize, caretLocation: caretLocation, wikiLinkIDProvider: wikiLinkIDProvider, - scopedRanges: scopedRanges, configuration: configuration + scopedRanges: scopedRanges, precomputedBlocks: precomputedBlocks, + configuration: configuration ) let astMs = Double(DispatchTime.now().uptimeNanoseconds - astT0) / 1_000_000 // NSImage rendering reuses the existing, proven machinery. diff --git a/Sources/MarkdownEngine/Styling/TextStylingService.swift b/Sources/MarkdownEngine/Styling/TextStylingService.swift index f61050b4..cef42300 100644 --- a/Sources/MarkdownEngine/Styling/TextStylingService.swift +++ b/Sources/MarkdownEngine/Styling/TextStylingService.swift @@ -56,6 +56,7 @@ struct TextStylingService { wikiLinkIDProvider: @escaping (NSRange) -> String?, precomputedTokens: [MarkdownToken]? = nil, classified: MarkdownStyler.ClassifiedStyleTokens? = nil, + precomputedBlocks: [Block]? = nil, configuration: MarkdownEditorConfiguration = .default ) { let paragraphs = normalize(paragraphCandidates) @@ -82,6 +83,7 @@ struct TextStylingService { wikiLinkIDProvider: wikiLinkIDProvider, precomputedTokens: precomputedTokens, classified: classified, + precomputedBlocks: precomputedBlocks, scopedRanges: paragraphs, configuration: configuration ) diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift index e4a20726..7d9de565 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift @@ -129,7 +129,8 @@ extension NativeTextViewCoordinator { _ textView: NSTextView, paragraphCandidates: [NSRange], tokens: [MarkdownToken]? = nil, - classified: MarkdownStyler.ClassifiedStyleTokens? = nil + classified: MarkdownStyler.ClassifiedStyleTokens? = nil, + blocks: [Block]? = nil ) { // Raw mode: no restyling; typing keeps base attrs via the typing shim. guard !configuration.rawSourceMode else { return } @@ -153,6 +154,7 @@ extension NativeTextViewCoordinator { }, precomputedTokens: tokens, classified: classified, + precomputedBlocks: blocks, configuration: configuration ) // Reconcile wide-table overlays after layout settles. @@ -227,6 +229,7 @@ extension NativeTextViewCoordinator { parsedDocumentVersion &+= 1 let parsed = ParsedDocument( tokens: tokens, + blocks: parseState.currentBlocks, codeTokens: codeTokens, latexTokens: latexTokens, blockLatexTokens: blockLatexTokens, @@ -314,16 +317,18 @@ extension NativeTextViewCoordinator { } func restyleParagraphs(_ paragraphs: [NSRange], in textView: NSTextView) { - let parsed = parsedDocument(for: textView.string) + let docText = textView.string // one O(doc) bridge, reused below + let parsed = parsedDocument(for: docText) let tokens = parsed.tokens - let nsText = textView.string as NSString + let nsText = docText as NSString activeTokenIndices = activeTokenIndices( parsed: parsed, selection: textView.selectedRange(), in: nsText, suppressed: !textView.isEditable ) - restyleTextView(textView, paragraphCandidates: paragraphs, tokens: tokens, classified: parsed.classified) + restyleTextView(textView, paragraphCandidates: paragraphs, tokens: tokens, + classified: parsed.classified, blocks: parsed.blocks) } func applyInlineReplacement(_ request: InlineReplacementRequest, to textView: NSTextView) { diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift index c4c8d6a4..b9377853 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift @@ -273,7 +273,7 @@ extension NativeTextViewCoordinator { previousActiveTokenIndices: preEditActiveTokenIndices )) - PerfTrace.measure("restyle") { restyleTextView(tv, paragraphCandidates: effectiveParagraphCandidates, tokens: tokens, classified: parsed.classified) } + PerfTrace.measure("restyle") { restyleTextView(tv, paragraphCandidates: effectiveParagraphCandidates, tokens: tokens, classified: parsed.classified, blocks: parsed.blocks) } PerfTrace.measure("codeSel") { updateCodeBlockSelection(textView: tv, parsed: parsed) } if wtActive { previousActiveTokenIndices = activeTokenIndices @@ -455,7 +455,8 @@ extension NativeTextViewCoordinator { previousActiveTokenIndices: previousActiveTokenIndices )) PerfTrace.measure("selRestyle") { - restyleTextView(tv, paragraphCandidates: paragraphCandidates, tokens: tokens, classified: parsed.classified) + restyleTextView(tv, paragraphCandidates: paragraphCandidates, tokens: tokens, + classified: parsed.classified, blocks: parsed.blocks) } } diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift index 408855ea..4c898b84 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift @@ -178,6 +178,10 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate { struct ParsedDocument { let tokens: [MarkdownToken] + /// The block list the tokens were derived from — handed to the restyle + /// so DocumentAST.parse consumes it instead of re-deriving blocks + /// (full buffer re-extraction + memcmp per keystroke). + let blocks: [Block] let codeTokens: [MarkdownToken] let latexTokens: [MarkdownToken] let blockLatexTokens: [MarkdownToken] diff --git a/Tests/MarkdownEngineTests/PrecomputedBlocksTests.swift b/Tests/MarkdownEngineTests/PrecomputedBlocksTests.swift new file mode 100644 index 00000000..5d89878a --- /dev/null +++ b/Tests/MarkdownEngineTests/PrecomputedBlocksTests.swift @@ -0,0 +1,51 @@ +// +// PrecomputedBlocksTests.swift +// MarkdownEngine +// +// Created by Luca Chen on 12.07.26. +// +// The per-keystroke restyle hands its already-computed block list down to +// DocumentAST.parse — the styler must consume it verbatim instead of +// re-deriving blocks (which re-extracted + memcmp'd the full document +// buffer on every keystroke, even on cache hits). +// + +import Foundation +import Testing +@testable import MarkdownEngine + +@Suite("Precomputed blocks bypass the block parser") +struct PrecomputedBlocksTests { + + @Test func precomputedBlocksAreConsumedVerbatim() { + let text = "alpha\n\nbeta" + // Deliberately WRONG for this text: one paragraph covering only "alpha". + // If the styler re-parsed, it would see two paragraphs + a blank. + let bogus = [Block(kind: .paragraph, range: NSRange(location: 0, length: 6))] + + let ast = DocumentAST.parse(text, precomputedBlocks: bogus) + + #expect(ast.count == 1) + #expect(ast.first?.range == NSRange(location: 0, length: 6)) + } + + @Test func withoutPrecomputedBlocksTheParserRuns() { + let text = "alpha\n\nbeta" + + let ast = DocumentAST.parse(text) + + // Real structure: paragraph, blank, paragraph. + #expect(ast.count == 3) + } + + @Test func parsedDocumentCarriesTheKeystrokesBlocks() { + let state = DocumentParseState() + let text = "alpha\n\nbeta" + _ = state.tokens(for: text, edit: nil) + + let blocks = state.currentBlocks + + #expect(blocks.count == 3) + #expect(blocks.map(\.range).last == NSRange(location: 7, length: 4)) + } +} From 8ce3e1268897131e466c0a06a1c10bd0ed9a206e Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Sun, 12 Jul 2026 14:01:09 +0200 Subject: [PATCH 21/39] perf(tables): skip substring+hash and the unclipped spellingState write for out-of-scope unique tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit styleTables visited EVERY table in the document per keystroke: a full source substring, a dictionary lookup keyed by that string (hashing all its bytes even on a hit), and a (token.range, .spellingState: 0) emission — which TextStylingService applies UNCLIPPED, so every keystroke wrote attributes over every table in the document (attribute edits can also invalidate TextKit-2 layout fragments, coupling this into the ensureVisibleLayout head-walk). Equal content implies equal source length, so a table whose length is unique in the document can never affect a duplicate's occurrence index. Inactive, out-of-scope, unique-length tables — in a typical document, all of them while typing prose — are now skipped before any per-table work. Same-length tables stay on the full path to keep duplicate sourceIDs stable; the PERF note reports skipped=N. Locked by TableScopeSkipTests (skip, in-scope emission, duplicate bookkeeping). Co-Authored-By: Claude Fable 5 --- .../Styling/MarkdownStyler+Tables.swift | 28 +++++- .../TableScopeSkipTests.swift | 98 +++++++++++++++++++ 2 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 Tests/MarkdownEngineTests/TableScopeSkipTests.swift diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift index 6ec9e3c8..c525a638 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift @@ -161,12 +161,30 @@ extension MarkdownStyler { var tableCount = 0 var renderedCount = 0 let tablesT0 = DispatchTime.now().uptimeNanoseconds - // Iterate the pre-classified table array (not all document tokens); all - // tables are visited because the occurrence counter needs the full, - // document-order set for stable duplicate-table sourceIDs. + // Iterate the pre-classified table array (not all document tokens). + // The occurrence counter needs every table that could DUPLICATE + // another (stable duplicate-table sourceIDs) — but equal content + // implies equal source length, so a table whose length is unique in + // the document can never affect another table's occurrence index. + let tableIndexed = ctx.tableIndexed + var lengthCounts: [Int: Int] = [:] + lengthCounts.reserveCapacity(tableIndexed.count) + for (_, token) in tableIndexed { lengthCounts[token.range.length, default: 0] += 1 } + var skippedCount = 0 var metaNanos: UInt64 = 0 - for (idx, token) in ctx.tableIndexed { + for (idx, token) in tableIndexed { tableCount += 1 + // Inactive + out-of-scope: attribute application clips everything + // away anyway. Unique length ⇒ no duplicate can depend on this + // table's hash — skip the substring + parse/hash AND the + // .spellingState write (which is applied UNCLIPPED and used to + // touch every table in the document on every keystroke). + if !ctx.activeTokenIndices.contains(idx), + ctx.outsideScope(token.range), + lengthCounts[token.range.length] == 1 { + skippedCount += 1 + continue + } // Tokenizer already drops tables overlapping fenced code, so no re-check here. attrs.append((token.range, [.spellingState: 0])) @@ -245,7 +263,7 @@ extension MarkdownStyler { if tableCount > 0 { let ms = Double(DispatchTime.now().uptimeNanoseconds - tablesT0) / 1_000_000 let metaMs = Double(metaNanos) / 1_000_000 - PerfTrace.note { "styleTables scanned=\(tableCount) tables, re-rendered=\(renderedCount) NSImage in \(String(format: "%.2f", ms))ms (substring+meta=\(String(format: "%.2f", metaMs))ms)" } + PerfTrace.note { "styleTables scanned=\(tableCount) tables (skipped=\(skippedCount)), re-rendered=\(renderedCount) NSImage in \(String(format: "%.2f", ms))ms (substring+meta=\(String(format: "%.2f", metaMs))ms)" } } return attrs } diff --git a/Tests/MarkdownEngineTests/TableScopeSkipTests.swift b/Tests/MarkdownEngineTests/TableScopeSkipTests.swift new file mode 100644 index 00000000..424795b6 --- /dev/null +++ b/Tests/MarkdownEngineTests/TableScopeSkipTests.swift @@ -0,0 +1,98 @@ +// +// TableScopeSkipTests.swift +// MarkdownEngine +// +// Created by Luca Chen on 12.07.26. +// +// styleTables used to substring + parse/hash EVERY table in the document on +// every keystroke (and write .spellingState over each one, unclipped by the +// restyle scope). Out-of-scope inactive tables whose length is unique — no +// other table can share their content, so no duplicate's occurrence index +// depends on their hash — are skipped entirely now. +// + +import AppKit +import Foundation +import Testing +@testable import MarkdownEngine + +@Suite("styleTables scope skip") +struct TableScopeSkipTests { + + private func makeContext( + text: String, + scopeBounds: (lo: Int, hi: Int)?, + activeTokenIndices: Set = [] + ) -> MarkdownStyler.StylingContext { + _ = NSApplication.shared // styleTables reads NSApp.effectiveAppearance + let tokens = MarkdownTokenizer.parseTokensViaAST(in: text) + let font = NSFont.systemFont(ofSize: 15) + var ctx = MarkdownStyler.StylingContext( + nsText: text as NSString, + tokens: tokens, + codeTokens: [], + activeTokenIndices: activeTokenIndices, + baseFont: font, + layoutBridge: nil, + baseDefaultLineHeight: 18, + codeBackgroundColor: .windowBackgroundColor, + latexMarkerFont: font, + configuration: .default, + wikiLinkIDProvider: { _ in nil } + ) + ctx.scopeBounds = scopeBounds + return ctx + } + + private func tableRanges(in text: String) -> [NSRange] { + MarkdownTokenizer.parseTokensViaAST(in: text) + .filter { $0.kind == .table } + .map(\.range) + } + + @Test func outOfScopeUniqueTableEmitsNothing() throws { + let text = "| alpha | beta |\n|---|---|\n| 1 | 2 |\n\nplain paragraph text here" + let tableRange = try #require(tableRanges(in: text).first) + // Scope: only the trailing paragraph, far past the table. + let scopeLo = NSMaxRange(tableRange) + 2 + let ctx = makeContext(text: text, scopeBounds: (lo: scopeLo, hi: (text as NSString).length)) + + let attrs = MarkdownStyler.styleTables(ctx) + + let touchingTable = attrs.filter { NSIntersectionRange($0.range, tableRange).length > 0 } + #expect(touchingTable.isEmpty) + } + + @Test func inScopeTableStillEmitsItsAttributes() throws { + let text = "| alpha | beta |\n|---|---|\n| 1 | 2 |\n\nplain paragraph text here" + let tableRange = try #require(tableRanges(in: text).first) + let ctx = makeContext(text: text, scopeBounds: (lo: 0, hi: NSMaxRange(tableRange))) + + let attrs = MarkdownStyler.styleTables(ctx) + + let touchingTable = attrs.filter { NSIntersectionRange($0.range, tableRange).length > 0 } + #expect(!touchingTable.isEmpty) + #expect(touchingTable.contains { $0.attributes[.spellingState] as? Int == 0 }) + } + + @Test func duplicateTablesKeepStableOccurrenceBookkeeping() throws { + // Two IDENTICAL tables; the second is in scope, the first is not. + // The first must still be hashed (same length ⇒ potential duplicate), + // so the second's occurrence index stays 1 — observable through its + // spellingState emission staying on the slow path. + let table = "| dup | dup |\n|---|---|\n| x | y |" + let text = table + "\n\nmiddle words\n\n" + table + let ranges = tableRanges(in: text) + #expect(ranges.count == 2) + let second = try #require(ranges.last) + let ctx = makeContext(text: text, scopeBounds: (lo: second.location, hi: NSMaxRange(second))) + + let attrs = MarkdownStyler.styleTables(ctx) + + // The out-of-scope FIRST duplicate still runs meta (its spellingState + // emission is the marker for "not skipped"). + let first = try #require(ranges.first) + let touchingFirst = attrs.filter { NSIntersectionRange($0.range, first).length > 0 } + #expect(touchingFirst.contains { $0.attributes[.spellingState] as? Int == 0 }) + } +} From d6632f198faf436fe66ea676d0103a63088315c7 Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Sun, 12 Jul 2026 14:04:48 +0200 Subject: [PATCH 22/39] perf(input): keep interceptor edits on the trusted fast paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A smart-input interceptor (Enter-in-list, auto-pair, Tab indent, '->' substitution, $$-wrap, [[-completion, fence auto-close) suppresses the typed key and performs one programmatic edit. That left pendingEditCount at 2, so textDidChange distrusted the descriptor and fell back to a full O(doc) backtick rescan plus a descriptorless O(doc) diff parse — on some of the most common keystrokes there are. The suppressed keystroke never applies, so performEdit / insertTextProgrammatically now drop its pending count before the programmatic edit registers; shouldChangeTextIn refreshes the descriptor for that edit (60faf0b), which describes the applied transition exactly, and the cycle stays a trusted single tracked edit. InterceptorTrustTests pin the trust flag per interceptor (via a Debug-only debugLastEditWasTrusted diagnostic); the pre-existing InterceptorStorageSyncTests prove storage correctness on the now-trusted path. Co-Authored-By: Claude Fable 5 --- .../Input/MarkdownInputHandler.swift | 4 + .../Input/MarkdownListHandler.swift | 10 +- ...tiveTextViewCoordinator+TextDelegate.swift | 3 + .../NativeTextViewCoordinator.swift | 5 + .../InterceptorTrustTests.swift | 100 ++++++++++++++++++ 5 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 Tests/MarkdownEngineTests/InterceptorTrustTests.swift diff --git a/Sources/MarkdownEngine/Input/MarkdownInputHandler.swift b/Sources/MarkdownEngine/Input/MarkdownInputHandler.swift index 1d5283d0..ce75af03 100644 --- a/Sources/MarkdownEngine/Input/MarkdownInputHandler.swift +++ b/Sources/MarkdownEngine/Input/MarkdownInputHandler.swift @@ -27,6 +27,10 @@ enum MarkdownInputHandler { private static func insertTextProgrammatically(_ textView: NSTextView, text: String, at range: NSRange, cursorAfter: Int) { if let coord = textView.delegate as? NativeTextViewWrapper.Coordinator { coord.isProgrammaticEdit = true + // Replaces a suppressed keystroke that never applied — reset its + // pending count so this edit registers as the cycle's single + // tracked edit and textDidChange keeps the trusted fast paths. + coord.pendingEditCount = 0 } textView.insertText(text, replacementRange: range) if let coord = textView.delegate as? NativeTextViewWrapper.Coordinator { diff --git a/Sources/MarkdownEngine/Input/MarkdownListHandler.swift b/Sources/MarkdownEngine/Input/MarkdownListHandler.swift index ca58e05c..80d2dc65 100644 --- a/Sources/MarkdownEngine/Input/MarkdownListHandler.swift +++ b/Sources/MarkdownEngine/Input/MarkdownListHandler.swift @@ -17,7 +17,15 @@ struct MarkdownLists { let len = min(range.length, max(0, maxLen)) let safeRange = NSRange(location: loc, length: len) - if let coord = textView.delegate as? NativeTextViewWrapper.Coordinator { coord.isProgrammaticEdit = true } + if let coord = textView.delegate as? NativeTextViewWrapper.Coordinator { + coord.isProgrammaticEdit = true + // This edit REPLACES a suppressed keystroke that never applied. + // Dropping its pending count lets the shouldChangeText below + // re-register as the cycle's single tracked edit, so textDidChange + // keeps the trusted fast paths (the descriptor is refreshed for + // every proposed edit and describes THIS transition exactly). + coord.pendingEditCount = 0 + } defer { if let coord = textView.delegate as? NativeTextViewWrapper.Coordinator { coord.isProgrammaticEdit = false } } diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift index b9377853..239a0362 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift @@ -121,6 +121,9 @@ extension NativeTextViewCoordinator { // substitutions, IME commits, WT batches) distrusts the fast paths. let singleTrackedEdit = pendingEditCount == 1 pendingEditCount = 0 +#if DEBUG + debugLastEditWasTrusted = singleTrackedEdit +#endif let lengthDelta = previousDisplayLength >= 0 ? fullLength - previousDisplayLength : Int.min previousDisplayLength = fullLength diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift index 4c898b84..fcfcdfd5 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift @@ -120,6 +120,11 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate { /// means the hoisted editedRange/lengthDelta describe a single tracked /// edit and incremental fast paths may trust them. var pendingEditCount = 0 +#if DEBUG + /// Diagnostic: whether the last completed textDidChange ran with a + /// trusted single-edit descriptor (fast paths). Read by tests. + var debugLastEditWasTrusted: Bool? = nil +#endif var pendingPreEditActiveTokenIndices: Set? = nil var previousCaretLocation: Int? = nil /// Drag-select suppressed a restyle; replayed on the next non-drag selection change. diff --git a/Tests/MarkdownEngineTests/InterceptorTrustTests.swift b/Tests/MarkdownEngineTests/InterceptorTrustTests.swift new file mode 100644 index 00000000..ff4d2c67 --- /dev/null +++ b/Tests/MarkdownEngineTests/InterceptorTrustTests.swift @@ -0,0 +1,100 @@ +// +// InterceptorTrustTests.swift +// MarkdownEngine +// +// Created by Luca Chen on 12.07.26. +// +// A smart-input interceptor suppresses the typed key and performs ONE +// programmatic edit whose descriptor is refreshed in shouldChangeTextIn +// (60faf0b). That descriptor describes the applied transition exactly, so +// the keystroke must stay TRUSTED — the old pendingEditCount==2 distrust +// forced a full O(doc) backtick rescan + descriptorless diff parse on +// every Enter-in-list, auto-pair, Tab-indent, and "->" substitution. +// + +import AppKit +import SwiftUI +import Testing +@testable import MarkdownEngine + +@MainActor +@Suite("Interceptor edits keep the fast paths trusted") +struct InterceptorTrustTests { + + private func makeEditor(text: String) -> (NativeTextView, NativeTextViewCoordinator) { + _ = NSApplication.shared + let textView = NativeTextView(frame: NSRect(x: 0, y: 0, width: 800, height: 600)) + textView.isEditable = true + textView.configuration = .default + let coordinator = NativeTextViewCoordinator( + text: .constant(text), + fontName: "SF Pro Text", + fontSize: 14, + isWikiLinkActive: .constant(false), + onLinkClick: nil, + onInlineSelectionChange: nil + ) + coordinator.textView = textView + textView.delegate = coordinator + textView.string = text + coordinator.lastSyncedText = text + coordinator.lastComputedStorage = text + coordinator.previousDisplayLength = (text as NSString).length + return (textView, coordinator) + } + + @Test func plainTypingIsTrusted() { + let (tv, coord) = makeEditor(text: "hello") + tv.setSelectedRange(NSRange(location: 5, length: 0)) + + tv.insertText("x", replacementRange: NSRange(location: 5, length: 0)) + + #expect(tv.string == "hellox") + #expect(coord.debugLastEditWasTrusted == true) + } + + @Test func arrowSubstitutionStaysTrusted() { + let (tv, coord) = makeEditor(text: "abc - def") + tv.setSelectedRange(NSRange(location: 5, length: 0)) + + tv.insertText(">", replacementRange: NSRange(location: 5, length: 0)) + + #expect(tv.string == "abc → def") + #expect(coord.lastComputedStorage == "abc → def") + #expect(coord.debugLastEditWasTrusted == true) + } + + @Test func tabIndentStaysTrusted() { + let (tv, coord) = makeEditor(text: "- hello") + tv.setSelectedRange(NSRange(location: 7, length: 0)) + + tv.insertText("\t", replacementRange: NSRange(location: 7, length: 0)) + + #expect(tv.string == "\t- hello") + #expect(coord.lastComputedStorage == "\t- hello") + #expect(coord.debugLastEditWasTrusted == true) + } + + @Test func listEnterContinuationStaysTrusted() { + let (tv, coord) = makeEditor(text: "- hello") + tv.setSelectedRange(NSRange(location: 7, length: 0)) + + tv.insertText("\n", replacementRange: NSRange(location: 7, length: 0)) + + #expect(tv.string == "- hello\n- ") + #expect(coord.lastComputedStorage == "- hello\n- ") + #expect(coord.debugLastEditWasTrusted == true) + } + + @Test func autoPairStaysTrusted() { + let (tv, coord) = makeEditor(text: "abc") + tv.setSelectedRange(NSRange(location: 3, length: 0)) + + tv.insertText("(", replacementRange: NSRange(location: 3, length: 0)) + + #expect(tv.string == "abc()") + #expect(coord.lastComputedStorage == "abc()") + #expect(coord.debugLastEditWasTrusted == true) + } + +} From a4793482ecdaf025b405a730b9f741c361c6e757 Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Sun, 12 Jul 2026 14:08:22 +0200 Subject: [PATCH 23/39] perf(parse): splice interior fence/$$ edits incrementally instead of full-reparsing every keystroke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing INSIDE a fenced code or $$ block was the biggest remaining per-keystroke cliff: incrementalParse unconditionally bailed whenever the ±1-block window contained a fencedCode/blockLatex block, so every keystroke in a code block ran computeBlocks over the whole document plus a fullTokens pass over every block. The bail is redundant with the existing guards: the ±3 delimiter check (old AND new buffer) already bails on any edit that creates, destroys, or touches a ```/$$ pairing, and an edit that un-closes a block by adding trailing chars on its closer line makes the reparsed block reach the window end — the trailing guard bails there. Interior edits reparse the whole opaque block inside the window, which is sound because windows are whole blocks. Pinned by FenceInteriorIncrementalTests: direct splice equivalence for fence + $$ interiors, the un-closing edge, and a 4-seed fence-heavy differential fuzz (300 random edits each) against the from-scratch parse, on top of the existing ParseIncrementalEquivalenceTests suite. Co-Authored-By: Claude Fable 5 --- .../MarkdownEngine/Parser/BlockParser.swift | 12 +- .../FenceInteriorIncrementalTests.swift | 139 ++++++++++++++++++ 2 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 Tests/MarkdownEngineTests/FenceInteriorIncrementalTests.swift diff --git a/Sources/MarkdownEngine/Parser/BlockParser.swift b/Sources/MarkdownEngine/Parser/BlockParser.swift index 6ac9928d..fdc4c295 100644 --- a/Sources/MarkdownEngine/Parser/BlockParser.swift +++ b/Sources/MarkdownEngine/Parser/BlockParser.swift @@ -162,10 +162,14 @@ enum BlockParser { let winFirst = max(0, min(firstIdx, lastIdx) - 1) let winLast = min(oldBlocks.count - 1, max(firstIdx, lastIdx) + 1) - // 3. Bail on opaque multi-line blocks — fences / block LaTeX can ripple. - for b in oldBlocks[winFirst...winLast] where b.kind == .fencedCode || b.kind == .blockLatex { - return nil - } + // 3. Opaque multi-line blocks (fences / block LaTeX) in the window are + // fine for INTERIOR edits: the window contains each block wholly, the + // ±3 delimiter guard above already bailed on any edit that creates, + // destroys, or touches a ``` / $$ pairing, and an edit that UN-closes + // a block (trailing chars on its closer line) makes the reparsed block + // reach the window end — caught by the trailing guard below. Typing + // inside a code block used to fall back to a full O(doc) reparse on + // every keystroke because of an unconditional bail here. // 4. Window → new-text range (window start is before the edit → unchanged). let winStart = oldBlocks[winFirst].range.location diff --git a/Tests/MarkdownEngineTests/FenceInteriorIncrementalTests.swift b/Tests/MarkdownEngineTests/FenceInteriorIncrementalTests.swift new file mode 100644 index 00000000..6b9aec08 --- /dev/null +++ b/Tests/MarkdownEngineTests/FenceInteriorIncrementalTests.swift @@ -0,0 +1,139 @@ +// +// FenceInteriorIncrementalTests.swift +// MarkdownEngine +// +// Created by Luca Chen on 12.07.26. +// +// Typing INSIDE a fenced code / $$ block used to bail the incremental block +// parse (full O(doc) computeBlocks + fullTokens on every keystroke — the +// single biggest per-keystroke cliff in large documents). The edit-window +// delimiter guards plus the trailing-fence guard make the window splice +// sound for interior edits; these tests pin that, and the fence-heavy fuzz +// holds the equivalence contract (fallback allowed, divergence never). +// + +import Foundation +import Testing +@testable import MarkdownEngine + +@Suite("Fence-interior incremental parse") +struct FenceInteriorIncrementalTests { + + private func chars(_ s: String) -> [unichar] { + let ns = s as NSString + var buf = [unichar](repeating: 0, count: ns.length) + if ns.length > 0 { ns.getCharacters(&buf, range: NSRange(location: 0, length: ns.length)) } + return buf + } + + private func splice(_ old: String, at loc: Int, remove: Int, insert: String) + -> (new: String, diff: BufferDiff) { + let ns = NSMutableString(string: old) + ns.replaceCharacters(in: NSRange(location: loc, length: remove), with: insert) + let insertLen = (insert as NSString).length + return (ns as String, BufferDiff( + changeStart: loc, + changeEndOld: loc + remove, + changeEndNew: loc + insertLen, + delta: insertLen - remove + )) + } + + @Test func interiorFenceEditSplicesIncrementally() throws { + let old = "para one\n\n```swift\nlet x = 1\nlet y = 2\n```\n\ntail paragraph" + let editLoc = (old as NSString).range(of: "x = 1").location + let (new, diff) = splice(old, at: editLoc, remove: 1, insert: "value") + + let result = BlockParser.incrementalParse( + oldChars: chars(old), oldBlocks: BlockParser.computeBlocks(old), + newChars: chars(new), newNS: new as NSString, diff: diff + ) + + let blocks = try #require(result?.blocks) + #expect(blocks == BlockParser.computeBlocks(new)) + } + + @Test func interiorBlockLatexEditSplicesIncrementally() throws { + let old = "before\n\n$$\nE = mc^2\n$$\n\nafter text" + let editLoc = (old as NSString).range(of: "mc^2").location + let (new, diff) = splice(old, at: editLoc, remove: 0, insert: "k") + + let result = BlockParser.incrementalParse( + oldChars: chars(old), oldBlocks: BlockParser.computeBlocks(old), + newChars: chars(new), newNS: new as NSString, diff: diff + ) + + let blocks = try #require(result?.blocks) + #expect(blocks == BlockParser.computeBlocks(new)) + } + + // Un-closing edit far from the backticks (trailing chars on the closer + // line): the splice must either bail (nil) or match ground truth. + @Test func unclosingEditStaysEquivalent() { + let old = "para\n\n```\ncode line\n``` \n\ntail one\n\ntail two" + let closerRange = (old as NSString).range(of: "``` ") + let editLoc = NSMaxRange(closerRange) - 1 // 6 past the backticks + let (new, diff) = splice(old, at: editLoc, remove: 0, insert: "x") + + let result = BlockParser.incrementalParse( + oldChars: chars(old), oldBlocks: BlockParser.computeBlocks(old), + newChars: chars(new), newNS: new as NSString, diff: diff + ) + + if let result { + #expect(result.blocks == BlockParser.computeBlocks(new)) + } + } + + // Fence-heavy differential fuzz: edits biased into fence/latex interiors. + @Test(arguments: [0xFE7CE, 0x5EED5, 0xACE02, 0xB16F1] as [UInt64]) + func fenceHeavyFuzzMatchesFullParse(seed: UInt64) { + var state = SplitMix(seed: seed) + let state1 = DocumentParseState() + var text = [ + "# Doc with many fences", + "```swift", "let a = 1", "let b = 2", "func f() {", " return", "}", "```", + "prose between the fences with **bold**", + "$$", "\\sum_{i=0}^n i^2", "$$", + "```python", "def g():", " pass", "```", + "- a list item", + "trailing paragraph", + ].joined(separator: "\n") + _ = state1.tokens(for: text, edit: nil) + + let interiorSnippets = ["x", "ab", " ", "\n", "word", " indent", "0"] + for step in 0..<300 { + let ns = NSMutableString(string: text) + let loc = state.int(ns.length + 1) + let removeLen = min(state.int(5), ns.length - loc) + let insert = state.int(5) == 0 ? "" : interiorSnippets[state.int(interiorSnippets.count)] + ns.replaceCharacters(in: NSRange(location: loc, length: removeLen), with: insert) + text = ns as String + + let edit = ParseEditDescriptor( + editedRange: NSRange(location: loc, length: (insert as NSString).length), + delta: (insert as NSString).length - removeLen + ) + let incremental = state1.tokens(for: text, edit: edit) + let full = MarkdownTokenizer.fullTokens(blocks: BlockParser.computeBlocks(text), ns: text as NSString) + let same = incremental.count == full.count && zip(incremental, full).allSatisfy { + $0.kind == $1.kind && $0.range == $1.range && $0.contentRange == $1.contentRange + && $0.markerRanges == $1.markerRanges + } + #expect(same, "step \(step): fence-heavy incremental diverged (edit at \(loc), removed \(removeLen), inserted \(insert.debugDescription))") + if !same { return } + } + } + + private struct SplitMix { + var seed: UInt64 + mutating func next() -> UInt64 { + seed &+= 0x9E3779B97F4A7C15 + var z = seed + z = (z ^ (z >> 30)) &* 0xBF58476D1CE4E5B9 + z = (z ^ (z >> 27)) &* 0x94D049BB133111EB + return z ^ (z >> 31) + } + mutating func int(_ upper: Int) -> Int { upper <= 0 ? 0 : Int(next() % UInt64(upper)) } + } +} From 16735b631e873e7763aa76d45f8df211c66e1e24 Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Sun, 12 Jul 2026 14:58:32 +0200 Subject: [PATCH 24/39] =?UTF-8?q?perf(tables):=20skip=20by=20rendering=20n?= =?UTF-8?q?eed,=20not=20length=20uniqueness=20=E2=80=94=20533-similar-tabl?= =?UTF-8?q?es=20doc=20skipped=20nothing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live measurement (682k chars, 533 near-identical tables) showed skipped=0 with substring+meta at 3.35ms/keystroke: the unique-length heuristic never fires when tables share length classes, which generated/templated tables almost always do. A sourceID is only CONSUMED by a table that renders this pass (inactive + in scope), and equal content implies equal length — so only tables sharing a length with a RENDERING table need their hash for occurrence stability. Typing prose renders no table, so now every table skips (styledRanges and the unclipped spellingState writes drop with it). Co-Authored-By: Claude Fable 5 --- .../Styling/MarkdownStyler+Tables.swift | 28 +++++++++---------- .../TableScopeSkipTests.swift | 21 ++++++++++++++ 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift index c525a638..e5342d90 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift @@ -162,26 +162,26 @@ extension MarkdownStyler { var renderedCount = 0 let tablesT0 = DispatchTime.now().uptimeNanoseconds // Iterate the pre-classified table array (not all document tokens). - // The occurrence counter needs every table that could DUPLICATE - // another (stable duplicate-table sourceIDs) — but equal content - // implies equal source length, so a table whose length is unique in - // the document can never affect another table's occurrence index. + // The occurrence counter exists for stable duplicate-table sourceIDs, + // and a sourceID is only CONSUMED by a table that renders this pass + // (inactive + in scope). Equal content implies equal source length, + // so only tables sharing a length with a rendering table can affect + // its occurrence index — every other inactive table skips the + // substring + parse/hash AND the .spellingState write (applied + // UNCLIPPED, it used to touch every table in the document on every + // keystroke). Typing prose renders no table → all tables skip. let tableIndexed = ctx.tableIndexed - var lengthCounts: [Int: Int] = [:] - lengthCounts.reserveCapacity(tableIndexed.count) - for (_, token) in tableIndexed { lengthCounts[token.range.length, default: 0] += 1 } + var neededLengths: Set = [] + for (idx, token) in tableIndexed + where !ctx.activeTokenIndices.contains(idx) && !ctx.outsideScope(token.range) { + neededLengths.insert(token.range.length) + } var skippedCount = 0 var metaNanos: UInt64 = 0 for (idx, token) in tableIndexed { tableCount += 1 - // Inactive + out-of-scope: attribute application clips everything - // away anyway. Unique length ⇒ no duplicate can depend on this - // table's hash — skip the substring + parse/hash AND the - // .spellingState write (which is applied UNCLIPPED and used to - // touch every table in the document on every keystroke). if !ctx.activeTokenIndices.contains(idx), - ctx.outsideScope(token.range), - lengthCounts[token.range.length] == 1 { + !neededLengths.contains(token.range.length) { skippedCount += 1 continue } diff --git a/Tests/MarkdownEngineTests/TableScopeSkipTests.swift b/Tests/MarkdownEngineTests/TableScopeSkipTests.swift index 424795b6..4596133b 100644 --- a/Tests/MarkdownEngineTests/TableScopeSkipTests.swift +++ b/Tests/MarkdownEngineTests/TableScopeSkipTests.swift @@ -75,6 +75,27 @@ struct TableScopeSkipTests { #expect(touchingTable.contains { $0.attributes[.spellingState] as? Int == 0 }) } + @Test func outOfScopeDuplicatesSkipWhenNothingRenders() throws { + // Two IDENTICAL tables, scope far away from both: no table renders + // this pass, so no occurrence index is consumed — BOTH must be + // skipped even though they share a length (the 533-similar-tables + // perf doc showed the unique-length heuristic never firing). + let table = "| dup | dup |\n|---|---|\n| x | y |" + let text = table + "\n\nmiddle\n\n" + table + "\n\ntrailing paragraph of prose" + let ranges = tableRanges(in: text) + #expect(ranges.count == 2) + let last = try #require(ranges.last) + let scopeLo = NSMaxRange(last) + 2 + let ctx = makeContext(text: text, scopeBounds: (lo: scopeLo, hi: (text as NSString).length)) + + let attrs = MarkdownStyler.styleTables(ctx) + + for range in ranges { + let touching = attrs.filter { NSIntersectionRange($0.range, range).length > 0 } + #expect(touching.isEmpty) + } + } + @Test func duplicateTablesKeepStableOccurrenceBookkeeping() throws { // Two IDENTICAL tables; the second is in scope, the first is not. // The first must still be hashed (same length ⇒ potential duplicate), From 6f7d8f1e583199a59c73f07bc43649eecb7c6afa Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Sun, 12 Jul 2026 14:17:19 +0200 Subject: [PATCH 25/39] perf(parse): binary-search the block windows; instrument the parse-state split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The incremental machinery itself showed up at selParse=6.77ms in a 682k-char document: the affected-block window in BlockParser.incrementalParse and the touched-block scan in incrementalTokens walked the block list linearly — O(#blocks) client code per keystroke (tens of thousands of blocks at 100k words, -Onone). Blocks tile in order, so both are binary searches now. Also: PerfTrace note with the parse-state split (buffer / blocks / tokens, splice-vs-FULL mode, block+token counts) so the next live measurement pinpoints what remains — the O(#tokens) widen/prefix/suffix passes are the known Phase-3 (relative ranges) target. Covered by the existing differential fuzz suites (ParseIncremental- EquivalenceTests + FenceInteriorIncrementalTests, 250-300 random edits per seed against ground truth). Co-Authored-By: Claude Fable 5 --- .../MarkdownEngine/Parser/BlockParser.swift | 18 ++++++++++---- .../Parser/BlockScopedTokenizer.swift | 24 +++++++++++++++---- .../Parser/DocumentParseState.swift | 11 +++++++++ 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/Sources/MarkdownEngine/Parser/BlockParser.swift b/Sources/MarkdownEngine/Parser/BlockParser.swift index fdc4c295..9d043190 100644 --- a/Sources/MarkdownEngine/Parser/BlockParser.swift +++ b/Sources/MarkdownEngine/Parser/BlockParser.swift @@ -155,10 +155,20 @@ enum BlockParser { } // 2. Affected old-block window (±1 block margin for merges/splits). - var firstIdx = 0 - while firstIdx + 1 < oldBlocks.count, oldBlocks[firstIdx + 1].range.location <= changeStart { firstIdx += 1 } - var lastIdx = oldBlocks.count - 1 - while lastIdx > 0, NSMaxRange(oldBlocks[lastIdx - 1].range) >= changeEnd { lastIdx -= 1 } + // Blocks tile the document in order — binary search instead of the + // linear walks that cost O(#blocks) per keystroke in large documents. + var lo = 0, hi = oldBlocks.count - 1 + while lo < hi { // last block starting <= changeStart + let m = (lo + hi + 1) / 2 + if oldBlocks[m].range.location <= changeStart { lo = m } else { hi = m - 1 } + } + let firstIdx = lo + lo = 0; hi = oldBlocks.count - 1 + while lo < hi { // first block ending >= changeEnd + let m = (lo + hi) / 2 + if NSMaxRange(oldBlocks[m].range) >= changeEnd { hi = m } else { lo = m + 1 } + } + let lastIdx = lo let winFirst = max(0, min(firstIdx, lastIdx) - 1) let winLast = min(oldBlocks.count - 1, max(firstIdx, lastIdx) + 1) diff --git a/Sources/MarkdownEngine/Parser/BlockScopedTokenizer.swift b/Sources/MarkdownEngine/Parser/BlockScopedTokenizer.swift index 89dc5dd9..4ed5b14d 100644 --- a/Sources/MarkdownEngine/Parser/BlockScopedTokenizer.swift +++ b/Sources/MarkdownEngine/Parser/BlockScopedTokenizer.swift @@ -92,12 +92,26 @@ extension MarkdownTokenizer { || BlockParser.hasBlockDelimiter(n, changeStart, changeEndNew) { return nil } // New blocks touching the changed char range [changeStart, changeEndNew]. - var lo = blocks.count, hi = -1 - for (i, b) in blocks.enumerated() - where b.range.location <= changeEndNew && NSMaxRange(b.range) >= changeStart { - lo = min(lo, i); hi = max(hi, i) + // Blocks tile in order → the touching set is one contiguous run; + // binary search replaces the O(#blocks) full scan per keystroke. + var lo = 0, hi = blocks.count - 1 + while lo < hi { // first block ending >= changeStart + let m = (lo + hi) / 2 + if NSMaxRange(blocks[m].range) >= changeStart { hi = m } else { lo = m + 1 } } - if hi < 0 { return delta == 0 ? (prevTokens, 0) : nil } + var first = lo + lo = 0; hi = blocks.count - 1 + while lo < hi { // last block starting <= changeEndNew + let m = (lo + hi + 1) / 2 + if blocks[m].range.location <= changeEndNew { lo = m } else { hi = m - 1 } + } + let last = lo + // Validate the run actually touches (mirrors the old filter exactly). + if first > last || blocks[first].range.location > changeEndNew || NSMaxRange(blocks[last].range) < changeStart { + return delta == 0 ? (prevTokens, 0) : nil + } + lo = first + hi = last // Widen the window until no previous token straddles either cut (a block's extent can change in place). var expanded = true diff --git a/Sources/MarkdownEngine/Parser/DocumentParseState.swift b/Sources/MarkdownEngine/Parser/DocumentParseState.swift index ddf32053..18e74af7 100644 --- a/Sources/MarkdownEngine/Parser/DocumentParseState.swift +++ b/Sources/MarkdownEngine/Parser/DocumentParseState.swift @@ -53,6 +53,7 @@ final class DocumentParseState { func tokens(for text: String, edit: ParseEditDescriptor?) -> [MarkdownToken] { let ns = text as NSString let newLen = ns.length + let tStart = DispatchTime.now().uptimeNanoseconds lock.lock() let prevChars = chars @@ -104,6 +105,8 @@ final class DocumentParseState { } } + let tBuffer = DispatchTime.now().uptimeNanoseconds + // 2. Blocks: window splice on the shared diff, full reparse fallback. var newBlocks: [Block]? if wasValid, let diff { @@ -113,6 +116,7 @@ final class DocumentParseState { )?.blocks } let resolvedBlocks = newBlocks ?? BlockParser.computeBlocks(text) + let tBlocks = DispatchTime.now().uptimeNanoseconds // 3. Tokens: prefix/suffix reuse on the same diff, full fallback. var newTokens: [MarkdownToken]? @@ -123,6 +127,13 @@ final class DocumentParseState { )?.tokens } let resolvedTokens = newTokens ?? MarkdownTokenizer.fullTokens(blocks: resolvedBlocks, ns: ns) + let tTokens = DispatchTime.now().uptimeNanoseconds + PerfTrace.note { + let ms = { (a: UInt64, b: UInt64) in String(format: "%.2f", Double(b - a) / 1_000_000) } + let blockMode = newBlocks != nil ? "splice" : "FULL" + let tokenMode = newTokens != nil ? "incremental" : "FULL" + return "parseState split: buffer=\(ms(tStart, tBuffer))ms blocks(\(blockMode))=\(ms(tBuffer, tBlocks))ms tokens(\(tokenMode))=\(ms(tBlocks, tTokens))ms #blocks=\(resolvedBlocks.count) #tokens=\(resolvedTokens.count)" + } lock.lock() chars = newChars From ff5be7b278e92f45ad4b902804c6ae7c52c5a808 Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Sun, 12 Jul 2026 14:41:34 +0200 Subject: [PATCH 26/39] =?UTF-8?q?fix(parse):=20line-expand=20the=20block-d?= =?UTF-8?q?elimiter=20guard=20=E2=80=94=20indented=20$$=20openers=20flippe?= =?UTF-8?q?d=20past=20the=20=C2=B13=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review of a479348 found (and reproduced standalone) a real divergence: isBlockLatexOpen matches the TRIMMED line prefix, so an edit in the leading whitespace of an indented ' $$' opener flips its pairing while sitting more than 3 chars from the literal $$ — the old ±3 guard passed, the window splice kept the stale block, and the wrong parse persisted through the seeded caches (both insert and delete directions). hasBlockDelimiter now expands the scan to the full LINES the edit touches (delimiters are line-classified), with a capped boundary walk that reports a delimiter when the cap is hit (conservative full parse). Pinned by two regression tests with the exact repro; the fence-heavy fuzz doc gained indented $$ blocks. Co-Authored-By: Claude Fable 5 --- .../MarkdownEngine/Parser/BlockParser.swift | 25 ++++++++++-- .../FenceInteriorIncrementalTests.swift | 39 +++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/Sources/MarkdownEngine/Parser/BlockParser.swift b/Sources/MarkdownEngine/Parser/BlockParser.swift index 9d043190..f779dbc4 100644 --- a/Sources/MarkdownEngine/Parser/BlockParser.swift +++ b/Sources/MarkdownEngine/Parser/BlockParser.swift @@ -121,10 +121,29 @@ enum BlockParser { return BufferDiff(changeStart: p, changeEndOld: oldLen - s, changeEndNew: newLen - s, delta: newLen - oldLen) } - /// Does `[lo, hi)` (± margin for an edit-boundary delimiter) contain a `$$` or ``` that can ripple? + /// Does any LINE touched by `[lo, hi)` contain a `$$` or ``` that can ripple? + /// Line-expanded, not just ±3 around the edit: block delimiters are + /// line-classified with a TRIMMED prefix (`isBlockLatexOpen`), so editing + /// the leading whitespace of an indented `$$` opener flips the pairing + /// from arbitrarily far away from the literal `$$`. The boundary walk is + /// capped; hitting the cap reports a delimiter (conservative full parse). static func hasBlockDelimiter(_ buf: [unichar], _ lo: Int, _ hi: Int) -> Bool { - var i = max(0, lo - 3) - let end = min(buf.count, hi + 3) + let cap = 4096 + var start = max(0, lo - 3) + var steps = 0 + while start > 0, buf[start - 1] != 0x0A, buf[start - 1] != 0x0D { + start -= 1 + steps += 1 + if steps > cap { return true } + } + var end = min(buf.count, hi + 3) + steps = 0 + while end < buf.count, buf[end] != 0x0A, buf[end] != 0x0D { + end += 1 + steps += 1 + if steps > cap { return true } + } + var i = start while i < end { if buf[i] == 0x24 { // $ if i + 1 < end, buf[i + 1] == 0x24 { return true } // $$ diff --git a/Tests/MarkdownEngineTests/FenceInteriorIncrementalTests.swift b/Tests/MarkdownEngineTests/FenceInteriorIncrementalTests.swift index 6b9aec08..2e8b20b4 100644 --- a/Tests/MarkdownEngineTests/FenceInteriorIncrementalTests.swift +++ b/Tests/MarkdownEngineTests/FenceInteriorIncrementalTests.swift @@ -85,6 +85,43 @@ struct FenceInteriorIncrementalTests { } } + // Review finding (a479348): isBlockLatexOpen matches the TRIMMED line + // prefix, so an indented `$$` opener can be flipped by an edit in its + // leading whitespace — arbitrarily far from the literal `$$`, past the + // ±3-char delimiter guard — and the dissolved opener's former closer + // re-pairs with a later `$$` block OUTSIDE the splice window. The splice + // must bail (or match ground truth) for any edit on a line carrying a + // block delimiter. + @Test func indentedLatexOpenerWhitespaceInsertStaysEquivalent() { + let old = "intro\n\n $$\n a = 1\n $$\nmiddle text\n\n$$\nb = 2\n$$\ntail" + let openerLoc = (old as NSString).range(of: " $$").location + let (new, diff) = splice(old, at: openerLoc, remove: 0, insert: "x") + + let result = BlockParser.incrementalParse( + oldChars: chars(old), oldBlocks: BlockParser.computeBlocks(old), + newChars: chars(new), newNS: new as NSString, diff: diff + ) + + if let result { + #expect(result.blocks == BlockParser.computeBlocks(new)) + } + } + + @Test func indentedLatexOpenerWhitespaceDeleteStaysEquivalent() { + let old = "intro\n\nx $$\n a = 1\n $$\nmiddle text\n\n$$\nb = 2\n$$\ntail" + let editLoc = (old as NSString).range(of: "x $$").location + let (new, diff) = splice(old, at: editLoc, remove: 1, insert: "") + + let result = BlockParser.incrementalParse( + oldChars: chars(old), oldBlocks: BlockParser.computeBlocks(old), + newChars: chars(new), newNS: new as NSString, diff: diff + ) + + if let result { + #expect(result.blocks == BlockParser.computeBlocks(new)) + } + } + // Fence-heavy differential fuzz: edits biased into fence/latex interiors. @Test(arguments: [0xFE7CE, 0x5EED5, 0xACE02, 0xB16F1] as [UInt64]) func fenceHeavyFuzzMatchesFullParse(seed: UInt64) { @@ -95,6 +132,8 @@ struct FenceInteriorIncrementalTests { "```swift", "let a = 1", "let b = 2", "func f() {", " return", "}", "```", "prose between the fences with **bold**", "$$", "\\sum_{i=0}^n i^2", "$$", + "more prose here", + " $$", " e^{i\\pi} = -1", " $$", "```python", "def g():", " pass", "```", "- a list item", "trailing paragraph", From 69d7e8b53d0318306fc485ef5778270b48ba1876 Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Sun, 12 Jul 2026 15:03:43 +0200 Subject: [PATCH 27/39] =?UTF-8?q?diag:=20attribute=20the=20invisible=20fra?= =?UTF-8?q?me=20share=20=E2=80=94=20reveal/settle=20+=20spell=20callbacks?= =?UTF-8?q?=20+=20overscroll=20detail=20+=20other=3D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 682k live frames grew to ~47ms with ~32ms outside every span. New DEBUG-only accounting to pin it down: - PerfTrace.accumulate sums repeated in-AppKit work under one +label (caret reveal, spell-checker callbacks) and end() prints other= (total minus everything measured). - scrollRangeToVisible and its doc-start→caret settle layout accumulate as +reveal / +revealSettle — the settle is O(caret depth) REAL layout and runs only on out-of-view verdicts, invisible until now. - setSpellingState accumulates as +spellCb (full-document contains scans per checker callback). - recalcOverscroll notes fullLayout/hChanged/osChanged per keystroke — a persistent fullLayout=1 with flipping heights is the bistable-height loop paying a FULL document ensureLayout inside the overscroll span. - selActive/selAuto/selCtx spans + classify note close the selection- change accounting gap. Co-Authored-By: Claude Fable 5 --- .../Diagnostics/PerfTrace.swift | 33 +++++++++++++++-- .../NativeTextViewCoordinator+Restyling.swift | 5 +++ ...tiveTextViewCoordinator+TextDelegate.swift | 36 +++++++++++-------- .../NativeTextView+FrameAndOverscroll.swift | 17 ++++++++- .../NativeTextView+SpellingPolicy.swift | 7 ++++ 5 files changed, 80 insertions(+), 18 deletions(-) diff --git a/Sources/MarkdownEngine/Diagnostics/PerfTrace.swift b/Sources/MarkdownEngine/Diagnostics/PerfTrace.swift index ed90babc..d7da3836 100644 --- a/Sources/MarkdownEngine/Diagnostics/PerfTrace.swift +++ b/Sources/MarkdownEngine/Diagnostics/PerfTrace.swift @@ -36,6 +36,10 @@ enum PerfTrace { private static var docLength = 0 private static var phases: [(String, Double)] = [] private static var notes: [String] = [] + /// Summed costs for code that runs MANY times per frame or from inside + /// AppKit callbacks (caret reveal, spell-checker callbacks) — printed as + /// `+label=…` after the sequential phases. + private static var accumulated: [(String, Double)] = [] private static func nowMs() -> Double { Double(DispatchTime.now().uptimeNanoseconds) / 1_000_000 @@ -56,9 +60,28 @@ enum PerfTrace { active = true phases.removeAll(keepingCapacity: true) notes.removeAll(keepingCapacity: true) + accumulated.removeAll(keepingCapacity: true) frameStart = now } + /// Like `measure`, but SUMS repeated calls under one label instead of + /// appending a phase per call — for work triggered from inside AppKit + /// (caret reveal, spell-checker callbacks) that can fire several times + /// per keystroke and would otherwise stay invisible in the frame. + @discardableResult + static func accumulate(_ label: String, _ body: () -> T) -> T { + guard enabled, active else { return body() } + let t0 = nowMs() + let result = body() + let dt = nowMs() - t0 + if let i = accumulated.firstIndex(where: { $0.0 == label }) { + accumulated[i].1 += dt + } else { + accumulated.append((label, dt)) + } + return result + } + /// Time one sequential top-level phase of the current frame. @discardableResult static func measure(_ label: String, _ body: () -> T) -> T { @@ -77,12 +100,18 @@ enum PerfTrace { } /// Close the frame and print total + per-phase breakdown + notes. + /// `other` = total − Σ(phases + accumulated): time inside the frame that + /// no span covers (AppKit edit processing, layout, unmeasured code). static func end() { guard enabled, active else { return } active = false let total = Double(DispatchTime.now().uptimeNanoseconds - frameStart) / 1_000_000 - let breakdown = phases.map { String(format: "%@=%.2f", $0.0, $0.1) }.joined(separator: " ") - print(String(format: "⌨️ PERF doc=%dch total=%.2fms | %@", docLength, total, breakdown)) + var breakdown = phases.map { String(format: "%@=%.2f", $0.0, $0.1) }.joined(separator: " ") + if !accumulated.isEmpty { + breakdown += " " + accumulated.map { String(format: "+%@=%.2f", $0.0, $0.1) }.joined(separator: " ") + } + let covered = phases.reduce(0) { $0 + $1.1 } + accumulated.reduce(0) { $0 + $1.1 } + print(String(format: "⌨️ PERF doc=%dch total=%.2fms | %@ other=%.2f", docLength, total, breakdown, total - covered)) for note in notes { print(" └─ \(note)") } } diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift index 7d9de565..107af51d 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift @@ -180,6 +180,7 @@ extension NativeTextViewCoordinator { } let tokens = parseState.tokens(for: text, edit: edit) + let tClassify = DispatchTime.now().uptimeNanoseconds var codeTokens: [MarkdownToken] = [] var latexTokens: [MarkdownToken] = [] var blockLatexTokens: [MarkdownToken] = [] @@ -247,6 +248,10 @@ extension NativeTextViewCoordinator { cachedParsedLength = length cachedParseGeneration = parseGeneration cachedParsedDocument = parsed + PerfTrace.note { + let ms = Double(DispatchTime.now().uptimeNanoseconds - tClassify) / 1_000_000 + return "classify=\(String(format: "%.2f", ms))ms #tokens=\(tokens.count)" + } return parsed } diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift index 239a0362..29b11c41 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift @@ -338,8 +338,10 @@ extension NativeTextViewCoordinator { let blockLatexTokens = parsed.blockLatexTokens let prevActive = activeTokenIndices - activeTokenIndices = activeTokenIndices(parsed: parsed, selection: selRange, in: nsText, suppressed: !tv.isEditable) - filterImageEmbedActiveTokens(parsed: parsed, text: nsText, selectionLocation: selRange.location) + PerfTrace.measure("selActive") { + activeTokenIndices = activeTokenIndices(parsed: parsed, selection: selRange, in: nsText, suppressed: !tv.isEditable) + filterImageEmbedActiveTokens(parsed: parsed, text: nsText, selectionLocation: selRange.location) + } // Snap-back: when the caret LEFT a wiki/image token, re-sync its displayed name to the live target name. if selRange.length == 0, @@ -377,13 +379,15 @@ extension NativeTextViewCoordinator { } } - updateAutocorrectSettings( - tv, - caretLocation: selLoc, - codeTokens: codeTokens, - latexTokens: latexTokens, - allTokens: tokens - ) + PerfTrace.measure("selAuto") { + updateAutocorrectSettings( + tv, + caretLocation: selLoc, + codeTokens: codeTokens, + latexTokens: latexTokens, + allTokens: tokens + ) + } let caretLoc = selRange.location let paragraphRange = nsText.paragraphRange(for: NSRange(location: caretLoc, length: 0)) @@ -486,12 +490,14 @@ extension NativeTextViewCoordinator { // Text unchanged past this point (the snap-back branch returned above); // only the selection may have moved. let selLocation = tv.selectedRange().location - let inlineContext = inlineTokenContext( - at: selLocation, - parsed: parsed, - codeTokens: codeTokens, - text: nsText - ) + let inlineContext = PerfTrace.measure("selCtx") { + inlineTokenContext( + at: selLocation, + parsed: parsed, + codeTokens: codeTokens, + text: nsText + ) + } let isInsideImageEmbed = { guard case .imageEmbed = inlineContext else { return false } return true diff --git a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift index 9f369e87..9ee09f02 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift @@ -27,6 +27,7 @@ extension NativeTextView { let lineHeight = layoutBridgeDefaultLineHeight(for: self.baseFont, using: layoutBridge) // File switch/resize forces full layout until height settles; typing stays O(edit). if debugTag == "?" { pendingFullLayoutMeasure = true } + let forcedFullLayout = pendingFullLayoutMeasure let measured = measuredBaseContentHeight( minimumHeight: lineHeight, forceFullLayout: pendingFullLayoutMeasure @@ -42,6 +43,12 @@ extension NativeTextView { let overscrollChanged = abs(resolvedOverscroll - activeBottomOverscroll) > 0.5 // Height settled → stop forcing full layout (until the next switch/resize). if !(baseHeightChanged || overscrollChanged) { pendingFullLayoutMeasure = false } + // A persistent fullLayout=1 with hChanged/osChanged flipping every + // keystroke = the bistable-height loop: every keystroke then pays a + // FULL document ensureLayout inside the overscroll span. + PerfTrace.note { + "overscroll[\(debugTag)]: fullLayout=\(forcedFullLayout ? 1 : 0) h=\(Int(measured))\(baseHeightChanged ? " hChanged" : "")\(overscrollChanged ? " osChanged" : "")" + } guard baseHeightChanged || overscrollChanged else { return } baseContentHeight = measured activeBottomOverscroll = resolvedOverscroll @@ -292,6 +299,12 @@ extension NativeTextView { } override func scrollRangeToVisible(_ range: NSRange) { + // Runs from inside AppKit's post-edit processing — outside every + // sequential span; accumulate so the frame stops hiding it. + PerfTrace.accumulate("reveal") { revealRangeIfNeeded(range) } + } + + private func revealRangeIfNeeded(_ range: NSRange) { if suppressAutoRevealOnce { suppressAutoRevealOnce = false return @@ -362,7 +375,9 @@ extension NativeTextView { // caret is already visible (the common per-keystroke case). if let end = tlm.textContentManager?.location(tlm.documentRange.location, offsetBy: min(range.location + 1, docLength)), let settleRange = NSTextRange(location: tlm.documentRange.location, end: end) { - tlm.ensureLayout(for: settleRange) + // O(doc-start → caret) real layout — the prime suspect + // whenever `+reveal` dominates a frame. + PerfTrace.accumulate("revealSettle") { tlm.ensureLayout(for: settleRange) } } revealRect = caretSegmentRect(fallback: revealRect) frame = revealRect.offsetBy(dx: 0, dy: self.frame.origin.y) diff --git a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+SpellingPolicy.swift b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+SpellingPolicy.swift index 23b7b499..91d6c9a4 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+SpellingPolicy.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+SpellingPolicy.swift @@ -13,6 +13,13 @@ import AppKit extension NativeTextView { override func setSpellingState(_ value: Int, range charRange: NSRange) { + // Spell-checker callback — fires from AppKit, potentially several + // times per keystroke, and does full-document contains() scans below; + // accumulate so the cost shows up in the PERF frame. + PerfTrace.accumulate("spellCb") { applySpellingPolicy(value, range: charRange) } + } + + private func applySpellingPolicy(_ value: Int, range charRange: NSRange) { let coordinator = delegate as? NativeTextViewCoordinator if value != 0 { if self.string.contains("`") { From 20f80a23d258072fc5f8bf13df9822c745eedd07 Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Sun, 12 Jul 2026 15:10:12 +0200 Subject: [PATCH 28/39] diag: checkpoint the callback boundaries; stamp the overlay presence scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit other=30ms survives with the engine back at the fast state — the cost sits BETWEEN our delegate callbacks, not in them. @shouldOut/@selIn/@selOut/ @didIn checkpoints (offsets from frame start) locate which AppKit gap eats it: edit application (shouldOut→selIn), or the didChangeText machinery (selOut→didIn). The wide-table presence scan (a full attribute-run walk on every restyle when the doc has NO wide table) gets a ⏱ stamp when >0.3ms — it runs between keystrokes and contributes felt lag without appearing in any frame. Co-Authored-By: Claude Fable 5 --- Sources/MarkdownEngine/Diagnostics/PerfTrace.swift | 12 +++++++++++- .../MarkdownEngine/Renderer/WideTableOverlay.swift | 9 ++++++++- .../NativeTextViewCoordinator+TextDelegate.swift | 4 ++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/Sources/MarkdownEngine/Diagnostics/PerfTrace.swift b/Sources/MarkdownEngine/Diagnostics/PerfTrace.swift index d7da3836..759e157e 100644 --- a/Sources/MarkdownEngine/Diagnostics/PerfTrace.swift +++ b/Sources/MarkdownEngine/Diagnostics/PerfTrace.swift @@ -99,6 +99,15 @@ enum PerfTrace { notes.append(make()) } + /// Record a named timestamp (offset from frame start) inline in the + /// breakdown, printed as `@label=12.34`. The gaps BETWEEN checkpoints and + /// the measured spans locate work the spans don't cover (AppKit edit + /// application, layout, notification dispatch between our callbacks). + static func checkpoint(_ label: String) { + guard enabled, active else { return } + phases.append(("@" + label, Double(DispatchTime.now().uptimeNanoseconds - frameStart) / 1_000_000)) + } + /// Close the frame and print total + per-phase breakdown + notes. /// `other` = total − Σ(phases + accumulated): time inside the frame that /// no span covers (AppKit edit processing, layout, unmeasured code). @@ -110,7 +119,8 @@ enum PerfTrace { if !accumulated.isEmpty { breakdown += " " + accumulated.map { String(format: "+%@=%.2f", $0.0, $0.1) }.joined(separator: " ") } - let covered = phases.reduce(0) { $0 + $1.1 } + accumulated.reduce(0) { $0 + $1.1 } + let covered = phases.filter { !$0.0.hasPrefix("@") }.reduce(0) { $0 + $1.1 } + + accumulated.reduce(0) { $0 + $1.1 } print(String(format: "⌨️ PERF doc=%dch total=%.2fms | %@ other=%.2f", docLength, total, breakdown, total - covered)) for note in notes { print(" └─ \(note)") } } diff --git a/Sources/MarkdownEngine/Renderer/WideTableOverlay.swift b/Sources/MarkdownEngine/Renderer/WideTableOverlay.swift index 03c48712..fabd480c 100644 --- a/Sources/MarkdownEngine/Renderer/WideTableOverlay.swift +++ b/Sources/MarkdownEngine/Renderer/WideTableOverlay.swift @@ -233,11 +233,18 @@ extension NativeTextView { let fullRange = NSRange(location: 0, length: storage.length) // Cheap presence-check first: skip the full-document layout pass when - // the doc has no wide tables. enumerateAttribute stops on first hit. + // the doc has no wide tables. enumerateAttribute stops on first hit — + // but a MISS walks every attribute run in the document (scheduled + // after each restyle), so stamp it when it gets slow. + let presenceT0 = DispatchTime.now().uptimeNanoseconds var hasAnyWideTable = false storage.enumerateAttribute(.scrollableBlockSourceID, in: fullRange, options: []) { value, _, stop in if value is Int { hasAnyWideTable = true; stop.pointee = true } } + let presenceMs = Double(DispatchTime.now().uptimeNanoseconds - presenceT0) / 1_000_000 + if presenceMs > 0.3 { + PerfTrace.stamp("wideTableOverlay.presenceScan", presenceMs, "wide=\(hasAnyWideTable ? 1 : 0) docLen=\(storage.length)") + } guard hasAnyWideTable else { removeAllWideTableOverlays() return diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift index 29b11c41..7e4214d6 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift @@ -70,6 +70,7 @@ extension NativeTextViewCoordinator { public func textDidChange(_ notification: Notification) { guard let tv = notification.object as? NSTextView else { return } + PerfTrace.checkpoint("didIn") // Before the early returns: the first keystroke must hide the placeholder. (tv as? NativeTextView)?.refreshPlaceholderVisibility() // Raw mode: display IS storage — sync the binding, skip the restyle. @@ -299,6 +300,8 @@ extension NativeTextViewCoordinator { // Raw mode: plain source — no reveal, snap-back, or inline previews. if configuration.rawSourceMode { return } if isWritingToolsActive { return } + PerfTrace.checkpoint("selIn") + defer { PerfTrace.checkpoint("selOut") } let selRange = tv.selectedRange() let currentEventType = NSApp.currentEvent?.type // ONE bridge of the document text — this handler fires on every @@ -663,6 +666,7 @@ extension NativeTextViewCoordinator { suppressed: !textView.isEditable ) + defer { PerfTrace.checkpoint("shouldOut") } return PerfTrace.measure("smartInput") { // Block LaTeX auto-wrap: insert newlines to keep $$ on its own line if MarkdownInputHandler.handleBlockLatexAutoWrap( From 4ce0bbd704bb4a0e237b3180368a2b2267f60ffc Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Sun, 12 Jul 2026 15:17:28 +0200 Subject: [PATCH 29/39] diag: count+time the TextKit-2 fragment provider; call counts on accumulated spans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The checkpoints located the missing ~25ms precisely: between shouldOut and selIn — AppKit's edit application (replaceCharacters → processEditing → TextKit-2 sync/layout). Our only code in that window besides O(1) hooks is the layout-fragment provider, which pays a font-metrics lookup + paragraph style + NSDictionary per created fragment; if invalidation makes TextKit recreate hundreds of fragments per keystroke, that's the gap. +fragProv=…(×n) will show it. Accumulated spans now print call counts. Co-Authored-By: Claude Fable 5 --- Sources/MarkdownEngine/Diagnostics/PerfTrace.swift | 9 +++++---- .../Renderer/MarkdownTextLayoutFragment.swift | 9 +++++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Sources/MarkdownEngine/Diagnostics/PerfTrace.swift b/Sources/MarkdownEngine/Diagnostics/PerfTrace.swift index 759e157e..31dae22e 100644 --- a/Sources/MarkdownEngine/Diagnostics/PerfTrace.swift +++ b/Sources/MarkdownEngine/Diagnostics/PerfTrace.swift @@ -38,8 +38,8 @@ enum PerfTrace { private static var notes: [String] = [] /// Summed costs for code that runs MANY times per frame or from inside /// AppKit callbacks (caret reveal, spell-checker callbacks) — printed as - /// `+label=…` after the sequential phases. - private static var accumulated: [(String, Double)] = [] + /// `+label=…(×n)` after the sequential phases. + private static var accumulated: [(String, Double, Int)] = [] private static func nowMs() -> Double { Double(DispatchTime.now().uptimeNanoseconds) / 1_000_000 @@ -76,8 +76,9 @@ enum PerfTrace { let dt = nowMs() - t0 if let i = accumulated.firstIndex(where: { $0.0 == label }) { accumulated[i].1 += dt + accumulated[i].2 += 1 } else { - accumulated.append((label, dt)) + accumulated.append((label, dt, 1)) } return result } @@ -117,7 +118,7 @@ enum PerfTrace { let total = Double(DispatchTime.now().uptimeNanoseconds - frameStart) / 1_000_000 var breakdown = phases.map { String(format: "%@=%.2f", $0.0, $0.1) }.joined(separator: " ") if !accumulated.isEmpty { - breakdown += " " + accumulated.map { String(format: "+%@=%.2f", $0.0, $0.1) }.joined(separator: " ") + breakdown += " " + accumulated.map { String(format: "+%@=%.2f(×%d)", $0.0, $0.1, $0.2) }.joined(separator: " ") } let covered = phases.filter { !$0.0.hasPrefix("@") }.reduce(0) { $0 + $1.1 } + accumulated.reduce(0) { $0 + $1.1 } diff --git a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift index cdffd74b..3184dcd2 100644 --- a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift +++ b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift @@ -614,6 +614,15 @@ final class MarkdownLayoutManagerDelegate: NSObject, NSTextLayoutManagerDelegate _ textLayoutManager: NSTextLayoutManager, textLayoutFragmentFor location: any NSTextLocation, in textElement: NSTextElement + ) -> NSTextLayoutFragment { + PerfTrace.accumulate("fragProv") { + makeFragment(textLayoutManager: textLayoutManager, textElement: textElement) + } + } + + private func makeFragment( + textLayoutManager: NSTextLayoutManager, + textElement: NSTextElement ) -> NSTextLayoutFragment { let fragment = MarkdownTextLayoutFragment(textElement: textElement, range: textElement.elementRange) // Seed body font + paragraphStyle so the trailing fragment doesn't inherit heading metrics (FB15131180). From fe449b88de7fc57631b409e938422bc7b353e996 Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Sun, 12 Jul 2026 16:42:38 +0200 Subject: [PATCH 30/39] chore: trim perf tests to the core cases, drop the implementation plan, fix CI cache collision - Reduce the five typing-perf test files to their main behaviors (fence/$$ interior splice + the indented-$$ regression; interceptor trust flag; table scope-skip; precomputed blocks; list-handler code context). The removed edge/variant cases and the redundant fuzz are covered by the pre-existing ParseIncrementalEquivalenceTests + InterceptorStorageSyncTests. - Fix the failing CI test (TableImageCacheTests.secondRequestIsServedFromCache): the old TableScopeSkipTests rendered the same '| alpha | beta |' table, warming the shared static tableImageCache so the cache test's first request was no longer a fresh render under parallel CI ordering. The trimmed table tests use unique sources (kappa/lambda, mu/nu). - Delete the implementation plan (docs/superpowers/plans/2026-07-07-typing-perf-hotpath.md). 217 tests green. Co-Authored-By: Claude Fable 5 --- .../FenceInteriorIncrementalTests.swift | 150 +-- .../InterceptorTrustTests.swift | 45 +- .../ListHandlerCodeContextTests.swift | 75 +- .../PrecomputedBlocksTests.swift | 19 +- .../TableScopeSkipTests.swift | 80 +- .../plans/2026-07-07-typing-perf-hotpath.md | 963 ------------------ 6 files changed, 62 insertions(+), 1270 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-07-typing-perf-hotpath.md diff --git a/Tests/MarkdownEngineTests/FenceInteriorIncrementalTests.swift b/Tests/MarkdownEngineTests/FenceInteriorIncrementalTests.swift index 2e8b20b4..51656dd3 100644 --- a/Tests/MarkdownEngineTests/FenceInteriorIncrementalTests.swift +++ b/Tests/MarkdownEngineTests/FenceInteriorIncrementalTests.swift @@ -5,11 +5,10 @@ // Created by Luca Chen on 12.07.26. // // Typing INSIDE a fenced code / $$ block used to bail the incremental block -// parse (full O(doc) computeBlocks + fullTokens on every keystroke — the -// single biggest per-keystroke cliff in large documents). The edit-window -// delimiter guards plus the trailing-fence guard make the window splice -// sound for interior edits; these tests pin that, and the fence-heavy fuzz -// holds the equivalence contract (fallback allowed, divergence never). +// parse (full O(doc) reparse every keystroke). The window splice now handles +// interior edits; these pin the two main cases plus the indented-$$ opener +// regression the review caught. Broad differential coverage lives in the +// pre-existing ParseIncrementalEquivalenceTests fuzz (fence + $$ templates). // import Foundation @@ -39,140 +38,37 @@ struct FenceInteriorIncrementalTests { )) } - @Test func interiorFenceEditSplicesIncrementally() throws { - let old = "para one\n\n```swift\nlet x = 1\nlet y = 2\n```\n\ntail paragraph" - let editLoc = (old as NSString).range(of: "x = 1").location - let (new, diff) = splice(old, at: editLoc, remove: 1, insert: "value") - - let result = BlockParser.incrementalParse( + /// nil = splice bailed to full parse (also correct); true/false = blocks match. + private func spliceEqualsFullParse(_ old: String, at loc: Int, remove: Int, insert: String) -> Bool? { + let (new, diff) = splice(old, at: loc, remove: remove, insert: insert) + guard let result = BlockParser.incrementalParse( oldChars: chars(old), oldBlocks: BlockParser.computeBlocks(old), newChars: chars(new), newNS: new as NSString, diff: diff - ) + ) else { return nil } + return result.blocks == BlockParser.computeBlocks(new) + } - let blocks = try #require(result?.blocks) - #expect(blocks == BlockParser.computeBlocks(new)) + @Test func interiorFenceEditSplicesIncrementally() { + let old = "para one\n\n```swift\nlet x = 1\nlet y = 2\n```\n\ntail paragraph" + let editLoc = (old as NSString).range(of: "x = 1").location + #expect(spliceEqualsFullParse(old, at: editLoc, remove: 1, insert: "value") == true) } - @Test func interiorBlockLatexEditSplicesIncrementally() throws { + @Test func interiorBlockLatexEditSplicesIncrementally() { let old = "before\n\n$$\nE = mc^2\n$$\n\nafter text" let editLoc = (old as NSString).range(of: "mc^2").location - let (new, diff) = splice(old, at: editLoc, remove: 0, insert: "k") - - let result = BlockParser.incrementalParse( - oldChars: chars(old), oldBlocks: BlockParser.computeBlocks(old), - newChars: chars(new), newNS: new as NSString, diff: diff - ) - - let blocks = try #require(result?.blocks) - #expect(blocks == BlockParser.computeBlocks(new)) + #expect(spliceEqualsFullParse(old, at: editLoc, remove: 0, insert: "k") == true) } - // Un-closing edit far from the backticks (trailing chars on the closer - // line): the splice must either bail (nil) or match ground truth. - @Test func unclosingEditStaysEquivalent() { - let old = "para\n\n```\ncode line\n``` \n\ntail one\n\ntail two" - let closerRange = (old as NSString).range(of: "``` ") - let editLoc = NSMaxRange(closerRange) - 1 // 6 past the backticks - let (new, diff) = splice(old, at: editLoc, remove: 0, insert: "x") - - let result = BlockParser.incrementalParse( - oldChars: chars(old), oldBlocks: BlockParser.computeBlocks(old), - newChars: chars(new), newNS: new as NSString, diff: diff - ) - - if let result { - #expect(result.blocks == BlockParser.computeBlocks(new)) - } - } - - // Review finding (a479348): isBlockLatexOpen matches the TRIMMED line - // prefix, so an indented `$$` opener can be flipped by an edit in its - // leading whitespace — arbitrarily far from the literal `$$`, past the - // ±3-char delimiter guard — and the dissolved opener's former closer - // re-pairs with a later `$$` block OUTSIDE the splice window. The splice - // must bail (or match ground truth) for any edit on a line carrying a - // block delimiter. + // Review regression: isBlockLatexOpen matches the TRIMMED line prefix, so + // an edit in the leading whitespace of an indented `$$` opener flips its + // pairing from past the old ±3-char delimiter guard. The splice must bail + // or match ground truth. @Test func indentedLatexOpenerWhitespaceInsertStaysEquivalent() { let old = "intro\n\n $$\n a = 1\n $$\nmiddle text\n\n$$\nb = 2\n$$\ntail" let openerLoc = (old as NSString).range(of: " $$").location - let (new, diff) = splice(old, at: openerLoc, remove: 0, insert: "x") - - let result = BlockParser.incrementalParse( - oldChars: chars(old), oldBlocks: BlockParser.computeBlocks(old), - newChars: chars(new), newNS: new as NSString, diff: diff - ) - - if let result { - #expect(result.blocks == BlockParser.computeBlocks(new)) - } - } - - @Test func indentedLatexOpenerWhitespaceDeleteStaysEquivalent() { - let old = "intro\n\nx $$\n a = 1\n $$\nmiddle text\n\n$$\nb = 2\n$$\ntail" - let editLoc = (old as NSString).range(of: "x $$").location - let (new, diff) = splice(old, at: editLoc, remove: 1, insert: "") - - let result = BlockParser.incrementalParse( - oldChars: chars(old), oldBlocks: BlockParser.computeBlocks(old), - newChars: chars(new), newNS: new as NSString, diff: diff - ) - - if let result { - #expect(result.blocks == BlockParser.computeBlocks(new)) - } - } - - // Fence-heavy differential fuzz: edits biased into fence/latex interiors. - @Test(arguments: [0xFE7CE, 0x5EED5, 0xACE02, 0xB16F1] as [UInt64]) - func fenceHeavyFuzzMatchesFullParse(seed: UInt64) { - var state = SplitMix(seed: seed) - let state1 = DocumentParseState() - var text = [ - "# Doc with many fences", - "```swift", "let a = 1", "let b = 2", "func f() {", " return", "}", "```", - "prose between the fences with **bold**", - "$$", "\\sum_{i=0}^n i^2", "$$", - "more prose here", - " $$", " e^{i\\pi} = -1", " $$", - "```python", "def g():", " pass", "```", - "- a list item", - "trailing paragraph", - ].joined(separator: "\n") - _ = state1.tokens(for: text, edit: nil) - - let interiorSnippets = ["x", "ab", " ", "\n", "word", " indent", "0"] - for step in 0..<300 { - let ns = NSMutableString(string: text) - let loc = state.int(ns.length + 1) - let removeLen = min(state.int(5), ns.length - loc) - let insert = state.int(5) == 0 ? "" : interiorSnippets[state.int(interiorSnippets.count)] - ns.replaceCharacters(in: NSRange(location: loc, length: removeLen), with: insert) - text = ns as String - - let edit = ParseEditDescriptor( - editedRange: NSRange(location: loc, length: (insert as NSString).length), - delta: (insert as NSString).length - removeLen - ) - let incremental = state1.tokens(for: text, edit: edit) - let full = MarkdownTokenizer.fullTokens(blocks: BlockParser.computeBlocks(text), ns: text as NSString) - let same = incremental.count == full.count && zip(incremental, full).allSatisfy { - $0.kind == $1.kind && $0.range == $1.range && $0.contentRange == $1.contentRange - && $0.markerRanges == $1.markerRanges - } - #expect(same, "step \(step): fence-heavy incremental diverged (edit at \(loc), removed \(removeLen), inserted \(insert.debugDescription))") - if !same { return } - } - } - - private struct SplitMix { - var seed: UInt64 - mutating func next() -> UInt64 { - seed &+= 0x9E3779B97F4A7C15 - var z = seed - z = (z ^ (z >> 30)) &* 0xBF58476D1CE4E5B9 - z = (z ^ (z >> 27)) &* 0x94D049BB133111EB - return z ^ (z >> 31) + if let result = spliceEqualsFullParse(old, at: openerLoc, remove: 0, insert: "x") { + #expect(result) } - mutating func int(_ upper: Int) -> Int { upper <= 0 ? 0 : Int(next() % UInt64(upper)) } } } diff --git a/Tests/MarkdownEngineTests/InterceptorTrustTests.swift b/Tests/MarkdownEngineTests/InterceptorTrustTests.swift index ff4d2c67..a1f44413 100644 --- a/Tests/MarkdownEngineTests/InterceptorTrustTests.swift +++ b/Tests/MarkdownEngineTests/InterceptorTrustTests.swift @@ -4,12 +4,11 @@ // // Created by Luca Chen on 12.07.26. // -// A smart-input interceptor suppresses the typed key and performs ONE -// programmatic edit whose descriptor is refreshed in shouldChangeTextIn -// (60faf0b). That descriptor describes the applied transition exactly, so -// the keystroke must stay TRUSTED — the old pendingEditCount==2 distrust -// forced a full O(doc) backtick rescan + descriptorless diff parse on -// every Enter-in-list, auto-pair, Tab-indent, and "->" substitution. +// A smart-input interceptor suppresses the typed key and performs one +// programmatic edit; the keystroke must stay TRUSTED (single tracked edit) +// so textDidChange keeps the O(edit) fast paths instead of the O(doc) +// fallback. Storage correctness on these paths is covered by +// InterceptorStorageSyncTests; here we pin the trust flag. // import AppKit @@ -63,38 +62,4 @@ struct InterceptorTrustTests { #expect(coord.lastComputedStorage == "abc → def") #expect(coord.debugLastEditWasTrusted == true) } - - @Test func tabIndentStaysTrusted() { - let (tv, coord) = makeEditor(text: "- hello") - tv.setSelectedRange(NSRange(location: 7, length: 0)) - - tv.insertText("\t", replacementRange: NSRange(location: 7, length: 0)) - - #expect(tv.string == "\t- hello") - #expect(coord.lastComputedStorage == "\t- hello") - #expect(coord.debugLastEditWasTrusted == true) - } - - @Test func listEnterContinuationStaysTrusted() { - let (tv, coord) = makeEditor(text: "- hello") - tv.setSelectedRange(NSRange(location: 7, length: 0)) - - tv.insertText("\n", replacementRange: NSRange(location: 7, length: 0)) - - #expect(tv.string == "- hello\n- ") - #expect(coord.lastComputedStorage == "- hello\n- ") - #expect(coord.debugLastEditWasTrusted == true) - } - - @Test func autoPairStaysTrusted() { - let (tv, coord) = makeEditor(text: "abc") - tv.setSelectedRange(NSRange(location: 3, length: 0)) - - tv.insertText("(", replacementRange: NSRange(location: 3, length: 0)) - - #expect(tv.string == "abc()") - #expect(coord.lastComputedStorage == "abc()") - #expect(coord.debugLastEditWasTrusted == true) - } - } diff --git a/Tests/MarkdownEngineTests/ListHandlerCodeContextTests.swift b/Tests/MarkdownEngineTests/ListHandlerCodeContextTests.swift index a9f93931..f9d4071e 100644 --- a/Tests/MarkdownEngineTests/ListHandlerCodeContextTests.swift +++ b/Tests/MarkdownEngineTests/ListHandlerCodeContextTests.swift @@ -4,12 +4,10 @@ // // Created by Luca Chen on 12.07.26. // -// Characterization for the smart-input list handler's code-block awareness: -// list continuation / Tab indent / "->" substitution must stay inert inside -// fenced code, and fence completion must close an unmatched ``` opener. -// Locks the behavior across the O(doc)-scan removal (the handler used to -// re-derive "am I in code?" via a full-document contains("`") + tokenizer -// pass on EVERY space/Enter/Tab). +// The list handler now gets "is the caret in code?" from the keystroke's +// parse (codeTokens) instead of a full-document contains("`") + tokenizer +// scan on every space/Enter/Tab. These pin that list continuation still +// fires outside code and stays inert inside a fenced block. // import AppKit @@ -21,8 +19,7 @@ import Testing @Suite("List handler code-block context") struct ListHandlerCodeContextTests { - /// Editor wired like production: coordinator as delegate, sync state seeded. - private func makeEditor(text: String) -> (NativeTextView, NativeTextViewCoordinator) { + private func makeEditor(text: String) -> NativeTextView { _ = NSApplication.shared let textView = NativeTextView(frame: NSRect(x: 0, y: 0, width: 800, height: 600)) textView.isEditable = true @@ -41,11 +38,11 @@ struct ListHandlerCodeContextTests { coordinator.lastSyncedText = text coordinator.lastComputedStorage = text coordinator.previousDisplayLength = (text as NSString).length - return (textView, coordinator) + return textView } @Test func enterContinuesListOutsideCode() { - let (tv, _) = makeEditor(text: "- hello") + let tv = makeEditor(text: "- hello") tv.setSelectedRange(NSRange(location: 7, length: 0)) tv.insertText("\n", replacementRange: NSRange(location: 7, length: 0)) @@ -55,65 +52,11 @@ struct ListHandlerCodeContextTests { @Test func enterDoesNotContinueListInsideFencedCode() { let text = "```\n- hello\n```" - let (tv, _) = makeEditor(text: text) - // Caret at the end of "- hello" (inside the fence). - tv.setSelectedRange(NSRange(location: 11, length: 0)) + let tv = makeEditor(text: text) + tv.setSelectedRange(NSRange(location: 11, length: 0)) // end of "- hello", inside the fence tv.insertText("\n", replacementRange: NSRange(location: 11, length: 0)) #expect(tv.string == "```\n- hello\n\n```") } - - @Test func tabIndentsListItemOutsideCode() { - let (tv, _) = makeEditor(text: "- hello") - tv.setSelectedRange(NSRange(location: 7, length: 0)) - - tv.insertText("\t", replacementRange: NSRange(location: 7, length: 0)) - - #expect(tv.string == "\t- hello") - } - - @Test func tabStaysLiteralInsideFencedCode() { - let text = "```\n- hello\n```" - let (tv, _) = makeEditor(text: text) - tv.setSelectedRange(NSRange(location: 11, length: 0)) - - tv.insertText("\t", replacementRange: NSRange(location: 11, length: 0)) - - #expect(tv.string == "```\n- hello\t\n```") - } - - @Test func arrowSubstitutionStaysLiteralInsideFencedCode() { - let text = "```\nabc - def\n```" - let (tv, _) = makeEditor(text: text) - // Right after the "-" inside the fence. - tv.setSelectedRange(NSRange(location: 9, length: 0)) - - tv.insertText(">", replacementRange: NSRange(location: 9, length: 0)) - - #expect(tv.string == "```\nabc -> def\n```") - } - - @Test func enterAtUnmatchedFenceOpenerInsertsClosingFence() { - let (tv, _) = makeEditor(text: "```swift") - tv.setSelectedRange(NSRange(location: 8, length: 0)) - - tv.insertText("\n", replacementRange: NSRange(location: 8, length: 0)) - - #expect(tv.string == "```swift\n\n```") - } - - @Test func enterAtClosingFenceJustBreaksLine() { - // Caret at the end of the CLOSING fence: one ``` precedes the line - // (odd → it's a closer, not an opener), so no completion fires and - // the newline stays literal. - let text = "```\ncode\n```" - let (tv, _) = makeEditor(text: text) - let end = (text as NSString).length - tv.setSelectedRange(NSRange(location: end, length: 0)) - - tv.insertText("\n", replacementRange: NSRange(location: end, length: 0)) - - #expect(tv.string == "```\ncode\n```\n") - } } diff --git a/Tests/MarkdownEngineTests/PrecomputedBlocksTests.swift b/Tests/MarkdownEngineTests/PrecomputedBlocksTests.swift index 5d89878a..1ac0c207 100644 --- a/Tests/MarkdownEngineTests/PrecomputedBlocksTests.swift +++ b/Tests/MarkdownEngineTests/PrecomputedBlocksTests.swift @@ -5,9 +5,8 @@ // Created by Luca Chen on 12.07.26. // // The per-keystroke restyle hands its already-computed block list down to -// DocumentAST.parse — the styler must consume it verbatim instead of -// re-deriving blocks (which re-extracted + memcmp'd the full document -// buffer on every keystroke, even on cache hits). +// DocumentAST.parse so the styler consumes it verbatim instead of +// re-extracting + memcmp'ing the full document buffer every keystroke. // import Foundation @@ -20,7 +19,7 @@ struct PrecomputedBlocksTests { @Test func precomputedBlocksAreConsumedVerbatim() { let text = "alpha\n\nbeta" // Deliberately WRONG for this text: one paragraph covering only "alpha". - // If the styler re-parsed, it would see two paragraphs + a blank. + // A re-parse would see two paragraphs + a blank instead. let bogus = [Block(kind: .paragraph, range: NSRange(location: 0, length: 6))] let ast = DocumentAST.parse(text, precomputedBlocks: bogus) @@ -29,19 +28,9 @@ struct PrecomputedBlocksTests { #expect(ast.first?.range == NSRange(location: 0, length: 6)) } - @Test func withoutPrecomputedBlocksTheParserRuns() { - let text = "alpha\n\nbeta" - - let ast = DocumentAST.parse(text) - - // Real structure: paragraph, blank, paragraph. - #expect(ast.count == 3) - } - @Test func parsedDocumentCarriesTheKeystrokesBlocks() { let state = DocumentParseState() - let text = "alpha\n\nbeta" - _ = state.tokens(for: text, edit: nil) + _ = state.tokens(for: "alpha\n\nbeta", edit: nil) let blocks = state.currentBlocks diff --git a/Tests/MarkdownEngineTests/TableScopeSkipTests.swift b/Tests/MarkdownEngineTests/TableScopeSkipTests.swift index 4596133b..52e98461 100644 --- a/Tests/MarkdownEngineTests/TableScopeSkipTests.swift +++ b/Tests/MarkdownEngineTests/TableScopeSkipTests.swift @@ -4,11 +4,12 @@ // // Created by Luca Chen on 12.07.26. // -// styleTables used to substring + parse/hash EVERY table in the document on -// every keystroke (and write .spellingState over each one, unclipped by the -// restyle scope). Out-of-scope inactive tables whose length is unique — no -// other table can share their content, so no duplicate's occurrence index -// depends on their hash — are skipped entirely now. +// styleTables used to substring + parse/hash + write .spellingState for +// EVERY table in the document per keystroke. Now a table only does that +// work when it renders (inactive + in scope) or shares a length with a +// rendering table (duplicate-sourceID stability). Typing prose renders no +// table → all skip. Table sources here are unique so they never warm the +// shared image cache used by TableImageCacheTests. // import AppKit @@ -21,8 +22,7 @@ struct TableScopeSkipTests { private func makeContext( text: String, - scopeBounds: (lo: Int, hi: Int)?, - activeTokenIndices: Set = [] + scopeBounds: (lo: Int, hi: Int)? ) -> MarkdownStyler.StylingContext { _ = NSApplication.shared // styleTables reads NSApp.effectiveAppearance let tokens = MarkdownTokenizer.parseTokensViaAST(in: text) @@ -31,7 +31,7 @@ struct TableScopeSkipTests { nsText: text as NSString, tokens: tokens, codeTokens: [], - activeTokenIndices: activeTokenIndices, + activeTokenIndices: [], baseFont: font, layoutBridge: nil, baseDefaultLineHeight: 18, @@ -50,70 +50,32 @@ struct TableScopeSkipTests { .map(\.range) } - @Test func outOfScopeUniqueTableEmitsNothing() throws { - let text = "| alpha | beta |\n|---|---|\n| 1 | 2 |\n\nplain paragraph text here" - let tableRange = try #require(tableRanges(in: text).first) - // Scope: only the trailing paragraph, far past the table. - let scopeLo = NSMaxRange(tableRange) + 2 - let ctx = makeContext(text: text, scopeBounds: (lo: scopeLo, hi: (text as NSString).length)) - - let attrs = MarkdownStyler.styleTables(ctx) - - let touchingTable = attrs.filter { NSIntersectionRange($0.range, tableRange).length > 0 } - #expect(touchingTable.isEmpty) - } - - @Test func inScopeTableStillEmitsItsAttributes() throws { - let text = "| alpha | beta |\n|---|---|\n| 1 | 2 |\n\nplain paragraph text here" - let tableRange = try #require(tableRanges(in: text).first) - let ctx = makeContext(text: text, scopeBounds: (lo: 0, hi: NSMaxRange(tableRange))) - - let attrs = MarkdownStyler.styleTables(ctx) - - let touchingTable = attrs.filter { NSIntersectionRange($0.range, tableRange).length > 0 } - #expect(!touchingTable.isEmpty) - #expect(touchingTable.contains { $0.attributes[.spellingState] as? Int == 0 }) - } - - @Test func outOfScopeDuplicatesSkipWhenNothingRenders() throws { - // Two IDENTICAL tables, scope far away from both: no table renders - // this pass, so no occurrence index is consumed — BOTH must be - // skipped even though they share a length (the 533-similar-tables - // perf doc showed the unique-length heuristic never firing). - let table = "| dup | dup |\n|---|---|\n| x | y |" + // Typing prose far from the tables renders none → every table skips. + @Test func outOfScopeTablesEmitNothing() throws { + let table = "| kappa | lambda |\n|---|---|\n| 5 | 6 |" let text = table + "\n\nmiddle\n\n" + table + "\n\ntrailing paragraph of prose" let ranges = tableRanges(in: text) #expect(ranges.count == 2) let last = try #require(ranges.last) - let scopeLo = NSMaxRange(last) + 2 - let ctx = makeContext(text: text, scopeBounds: (lo: scopeLo, hi: (text as NSString).length)) + let ctx = makeContext(text: text, scopeBounds: (lo: NSMaxRange(last) + 2, hi: (text as NSString).length)) let attrs = MarkdownStyler.styleTables(ctx) for range in ranges { - let touching = attrs.filter { NSIntersectionRange($0.range, range).length > 0 } - #expect(touching.isEmpty) + #expect(attrs.filter { NSIntersectionRange($0.range, range).length > 0 }.isEmpty) } } - @Test func duplicateTablesKeepStableOccurrenceBookkeeping() throws { - // Two IDENTICAL tables; the second is in scope, the first is not. - // The first must still be hashed (same length ⇒ potential duplicate), - // so the second's occurrence index stays 1 — observable through its - // spellingState emission staying on the slow path. - let table = "| dup | dup |\n|---|---|\n| x | y |" - let text = table + "\n\nmiddle words\n\n" + table - let ranges = tableRanges(in: text) - #expect(ranges.count == 2) - let second = try #require(ranges.last) - let ctx = makeContext(text: text, scopeBounds: (lo: second.location, hi: NSMaxRange(second))) + // A table inside the restyle scope must still emit its attributes. + @Test func inScopeTableStillEmitsItsAttributes() throws { + let text = "| mu | nu |\n|---|---|\n| 7 | 8 |\n\nplain paragraph text here" + let tableRange = try #require(tableRanges(in: text).first) + let ctx = makeContext(text: text, scopeBounds: (lo: 0, hi: NSMaxRange(tableRange))) let attrs = MarkdownStyler.styleTables(ctx) - // The out-of-scope FIRST duplicate still runs meta (its spellingState - // emission is the marker for "not skipped"). - let first = try #require(ranges.first) - let touchingFirst = attrs.filter { NSIntersectionRange($0.range, first).length > 0 } - #expect(touchingFirst.contains { $0.attributes[.spellingState] as? Int == 0 }) + let touching = attrs.filter { NSIntersectionRange($0.range, tableRange).length > 0 } + #expect(!touching.isEmpty) + #expect(touching.contains { $0.attributes[.spellingState] as? Int == 0 }) } } diff --git a/docs/superpowers/plans/2026-07-07-typing-perf-hotpath.md b/docs/superpowers/plans/2026-07-07-typing-perf-hotpath.md deleted file mode 100644 index 257828a1..00000000 --- a/docs/superpowers/plans/2026-07-07-typing-perf-hotpath.md +++ /dev/null @@ -1,963 +0,0 @@ -# Typing-Performance Hot-Path Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Cut the per-keystroke cost that scales with document length, so typing latency stays (near-)constant regardless of file size. - -**Architecture:** The engine's per-keystroke path is `NativeTextViewCoordinator.textDidChange` → wiki-link storage sync → backtick census → `parsedDocument` (tokenize) → paragraph-scoped restyle → `ensureVisibleLayout`. Measured with PerfTrace (Debug build): a 139k-char doc costs ~12.8 ms/keystroke vs ~1.1 ms for a 456-char doc; a doc with 10 tables costs 13–28 ms because every table re-renders to NSImage on every keystroke. Each task below removes one measured O(doc) cost with a bounded, fallback-guarded replacement. No behavioral changes intended — every task must leave `swift test` green. - -**Tech Stack:** Swift 5.9 SPM package (`swift build` / `swift test`), AppKit NSTextView + TextKit 2, Swift Testing (`import Testing`, `@Suite`/`@Test`/`#expect`) for tests. - -## Global Constraints - -- Repo: `/Users/lucachen/Desktop/swift-markdown-engine` (the `~/Documents/GitHub` clone is stale — never touch it). -- Work happens on branch `perf/typing-hotpath`, created from `main` in Task 0. Commit per task; **never push** and never tag/release — Luca releases manually after sign-off. -- Every **new** source file starts with the Xcode header comment: `// .swift` / `// MarkdownEngine` / `// Created by Luca Chen on 07.07.26.` -- The engine ships API only — no UI components. -- Build check: `swift build` → `Build complete!`. Test check: `swift test` → all suites pass. SourceKit "cannot find type" diagnostics in extensions are known false positives — trust `swift build` only. -- End commit messages with `Co-Authored-By: Claude Fable 5 `. -- The Nodes app repo (`~/Documents/GitHub/Nodes`) currently points at this local engine checkout via an uncommitted pbxproj override — needed for live measurement in Task 6. Do not commit anything in the app repo. -- PerfTrace instrumentation (Debug-only) stays in place through all tasks — Task 6 uses it for before/after numbers. Its eventual removal is a separate post-sign-off step. -- All measured numbers are Debug (`-Onone`); ratios are meaningful, absolute times are inflated. Algorithmic fixes here are build-independent. - -**Measured baseline (2026-07-07, Debug):** - -| Scenario | total/keystroke | dominant phases | -|---|---|---| -| 139k chars, plain | ~12.8 ms | parse 6.4, wiki 3.4, restyle 1.7, backtick 0.75 | -| 456 chars | ~1.1 ms | restyle ~1.0 | -| 7.9k chars, 10 tables | 13–28 ms | restyle 12–26 (styleTables 7–17, all 10 tables re-rendered), ensureVisibleLayout walks 130 frags (115 wasted) | - ---- - -### Task 0: Branch setup + commit the PerfTrace instrumentation - -**Files:** -- No source changes; git only. The working tree currently sits on `pr/87` with uncommitted edits to 4 files + the new `Sources/MarkdownEngine/Diagnostics/PerfTrace.swift`. None of these files differ between `pr/87` and `main` (verified via `git diff main...HEAD --name-only`), so the edits carry across the switch. - -**Interfaces:** -- Produces: branch `perf/typing-hotpath` (from `main`) containing the committed PerfTrace instrumentation that all later tasks build on and Task 6 measures with. - -- [ ] **Step 1: Switch to main and branch** - -```bash -cd /Users/lucachen/Desktop/swift-markdown-engine -git fetch origin -git switch main -git pull --ff-only -git switch -c perf/typing-hotpath -``` - -Expected: switch succeeds with "M" markers for the 4 modified files (edits carried over), no conflicts. - -- [ ] **Step 2: Build to verify the carried edits compile on main** - -Run: `swift build` -Expected: `Build complete!` - -- [ ] **Step 3: Commit the instrumentation** - -```bash -git add Sources/MarkdownEngine/Diagnostics/PerfTrace.swift \ - Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift \ - Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift \ - Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift \ - Sources/MarkdownEngine/Renderer/WideTableOverlay.swift -git commit -m "chore: temporary PerfTrace typing instrumentation (Debug-only) - -Co-Authored-By: Claude Fable 5 " -``` - ---- - -### Task 1: Cache rendered table images (biggest measured win: 7–17 ms → ~0 in table docs) - -**Files:** -- Modify: `Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift` (styleTables ~line 28–110) -- Test: Create `Tests/MarkdownEngineTests/TableImageCacheTests.swift` - -**Interfaces:** -- Consumes: existing `renderTable(_:baseFont:theme:codeBackgroundColor:latex:appearance:)` and `parseTableSource(_:) -> ParsedTable?` in the same file; `MarkdownStyler.StylingContext` (fields: `baseFont`, `codeBackgroundColor`, `configuration`, `services`). -- Produces: `static func tableImage(for source: String, parsed: ParsedTable, ctx: StylingContext, appearance: NSAppearance) -> (image: NSImage, rendered: Bool)` on `MarkdownStyler` — `rendered == false` means cache hit. - -**Why:** `styleTables` currently calls `renderTable` for **every inactive table in the document on every keystroke** (PerfTrace: `scanned=10, re-rendered=10 NSImage in 7–17ms`). Table content only changes when its source text changes — a source-keyed cache makes steady-state typing free. - -- [ ] **Step 1: Write the failing test** - -Create `Tests/MarkdownEngineTests/TableImageCacheTests.swift`: - -```swift -// -// TableImageCacheTests.swift -// MarkdownEngine -// -// Created by Luca Chen on 07.07.26. -// - -import AppKit -import Foundation -import Testing -@testable import MarkdownEngine - -@Suite("Table image cache") -struct TableImageCacheTests { - - private func makeContext(for source: String) -> MarkdownStyler.StylingContext { - let font = NSFont.systemFont(ofSize: 15) - return MarkdownStyler.StylingContext( - nsText: source as NSString, - tokens: [], - codeTokens: [], - activeTokenIndices: [], - baseFont: font, - layoutBridge: nil, - baseDefaultLineHeight: 18, - codeBackgroundColor: .windowBackgroundColor, - latexMarkerFont: font, - configuration: .default, - wikiLinkIDProvider: { _ in nil } - ) - } - - @Test func secondRequestIsServedFromCache() throws { - let source = "| alpha | beta |\n|---|---|\n| 1 | 2 |" - let parsed = try #require(MarkdownStyler.parseTableSource(source)) - let ctx = makeContext(for: source) - let aqua = try #require(NSAppearance(named: .aqua)) - - let first = MarkdownStyler.tableImage(for: source, parsed: parsed, ctx: ctx, appearance: aqua) - let second = MarkdownStyler.tableImage(for: source, parsed: parsed, ctx: ctx, appearance: aqua) - - #expect(first.rendered) - #expect(!second.rendered) - #expect(first.image === second.image) - } - - @Test func appearanceChangeRendersFresh() throws { - let source = "| gamma | delta |\n|---|---|\n| 3 | 4 |" - let parsed = try #require(MarkdownStyler.parseTableSource(source)) - let ctx = makeContext(for: source) - let aqua = try #require(NSAppearance(named: .aqua)) - let dark = try #require(NSAppearance(named: .darkAqua)) - - _ = MarkdownStyler.tableImage(for: source, parsed: parsed, ctx: ctx, appearance: aqua) - let darkResult = MarkdownStyler.tableImage(for: source, parsed: parsed, ctx: ctx, appearance: dark) - - #expect(darkResult.rendered) - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `swift test --filter TableImageCacheTests 2>&1 | tail -5` -Expected: FAIL / compile error — `tableImage(for:parsed:ctx:appearance:)` does not exist. - -- [ ] **Step 3: Implement the cache** - -In `Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift`, add inside `extension MarkdownStyler` (above `styleTables`): - -```swift - /// Rendered-table image cache. A table's pixels depend only on its source, - /// font, colors, and appearance — so identical keys can reuse the NSImage - /// instead of re-rendering every inactive table on every keystroke. - private static let tableImageCache: NSCache = { - let cache = NSCache() - cache.countLimit = 128 - return cache - }() - - /// Returns the rendered image for `source`, from cache when possible. - /// `rendered` is true only when a fresh render actually happened. - static func tableImage( - for source: String, - parsed: ParsedTable, - ctx: StylingContext, - appearance: NSAppearance - ) -> (image: NSImage, rendered: Bool) { - let key = "\(ctx.baseFont.fontName)|\(ctx.baseFont.pointSize)|\(appearance.name.rawValue)|\(ctx.configuration.theme.bodyText)|\(ctx.codeBackgroundColor)|\(source)" as NSString - if let cached = tableImageCache.object(forKey: key) { - return (cached, false) - } - let image = renderTable( - parsed, - baseFont: ctx.baseFont, - theme: ctx.configuration.theme, - codeBackgroundColor: ctx.codeBackgroundColor, - latex: ctx.services.latex, - appearance: appearance - ) - tableImageCache.setObject(image, forKey: key) - return (image, true) - } -``` - -Then in `styleTables`, replace the direct render call: - -```swift - // See renderTable: resolve table colors under the text view's real appearance. - let renderAppearance = ctx.layoutBridge?.firstTextContainer?.textView?.effectiveAppearance - ?? NSApp.effectiveAppearance - let image = renderTable( - parsed, - baseFont: ctx.baseFont, - theme: ctx.configuration.theme, - codeBackgroundColor: ctx.codeBackgroundColor, - latex: ctx.services.latex, - appearance: renderAppearance - ) - renderedCount += 1 - let imageBounds = CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height) -``` - -with: - -```swift - // See renderTable: resolve table colors under the text view's real appearance. - let renderAppearance = ctx.layoutBridge?.firstTextContainer?.textView?.effectiveAppearance - ?? NSApp.effectiveAppearance - let (image, rendered) = tableImage( - for: source, - parsed: parsed, - ctx: ctx, - appearance: renderAppearance - ) - if rendered { renderedCount += 1 } - let imageBounds = CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height) -``` - -(The existing PerfTrace note line stays as is — after this task, steady-state typing should print `re-rendered=0`.) - -- [ ] **Step 4: Run tests** - -Run: `swift test --filter TableImageCacheTests 2>&1 | tail -5` → PASS. -Run: `swift test 2>&1 | tail -5` → all suites pass (table rendering behavior unchanged). - -- [ ] **Step 5: Commit** - -```bash -git add Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift Tests/MarkdownEngineTests/TableImageCacheTests.swift -git commit -m "perf(tables): cache rendered table images keyed by source+font+appearance - -Every keystroke re-rendered every inactive table to a fresh NSImage -(7-17ms with 10 tables). Table pixels depend only on source, font, -colors and appearance, so identical keys now reuse the cached image. - -Co-Authored-By: Claude Fable 5 " -``` - ---- - -### Task 2: Incremental wiki-link storage sync (3.4 ms → ~0.1 ms at 139k) - -**Files:** -- Modify: `Sources/MarkdownEngine/Services/WikiLinkService.swift` (add one function after `makeStorageState`, ~line 190) -- Modify: `Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift` (add 2 stored properties near line 88) -- Modify: `Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift` (`textDidChange`, ~lines 105–137) -- Modify: `Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift` (`rebuildTextStorageAndStyle`, seed the new state, ~line 39) -- Test: Create `Tests/MarkdownEngineTests/WikiLinkIncrementalTests.swift` - -**Interfaces:** -- Consumes: `WikiLinkService.RangeKey` (`.location`, `.length`, `init(_ NSRange)`), `WikiLinkService.LinkMetadata` (`.id`, `.storageRange`, public init), `WikiLinkService.makeStorageState(from:existingMetadata:textStorage:)` as the fallback; coordinator vars `lastSyncedText`, `wikiLinkMetadata`, `pendingEditedRange`. -- Produces: `WikiLinkService.updatedStorageState(displayText:editedRange:changeInLength:previousStorage:previousMetadata:) -> (storage: String, metadata: [RangeKey: LinkMetadata])?` — nil means "fall back to full rebuild". New coordinator vars `previousDisplayLength: Int`, `lastComputedStorage: String`, `wikiVerifyCounter: UInt` that Task 6 relies on being maintained. - -**Why:** `makeStorageState` rescans and rebuilds the **entire** document string on every keystroke as soon as one `[[` exists anywhere (measured 3.4 ms at 139k). Outside link syntax, display text and storage text are byte-identical — a keystroke that doesn't touch link syntax can be spliced into the previous storage string in O(edit + #links), with all link ranges after the edit shifted by the length delta. - -- [ ] **Step 1: Write the failing tests** - -Create `Tests/MarkdownEngineTests/WikiLinkIncrementalTests.swift`: - -```swift -// -// WikiLinkIncrementalTests.swift -// MarkdownEngine -// -// Created by Luca Chen on 07.07.26. -// - -import Foundation -import Testing -@testable import MarkdownEngine - -@Suite("WikiLink incremental storage state") -struct WikiLinkIncrementalTests { - - /// storage: "Hello [[Note|abc123]] world" → display: "Hello [[Note]] world" - /// display link range {6, 8}, storage link range {6, 15}. - private func seed() -> (display: String, storage: String, - meta: [WikiLinkService.RangeKey: WikiLinkService.LinkMetadata]) { - let storage = "Hello [[Note|abc123]] world" - let state = WikiLinkService.makeDisplayState(from: storage) - return (state.display, storage, state.metadata) - } - - @Test func appendAfterLinkSplicesStorage() throws { - let (display, storage, meta) = seed() - let newDisplay = display + "x" // typed "x" at the end - let result = try #require(WikiLinkService.updatedStorageState( - displayText: newDisplay, - editedRange: NSRange(location: (display as NSString).length, length: 1), - changeInLength: 1, - previousStorage: storage, - previousMetadata: meta - )) - #expect(result.storage == "Hello [[Note|abc123]] worldx") - // Link before the edit: metadata unchanged. - let key = try #require(result.metadata.keys.first) - #expect(key.location == 6 && key.length == 8) - #expect(result.metadata[key]?.id == "abc123") - #expect(result.metadata[key]?.storageRange == NSRange(location: 6, length: 15)) - } - - @Test func insertBeforeLinkShiftsMetadata() throws { - let (display, storage, meta) = seed() - let newDisplay = "x" + display // typed "x" at position 0 - let result = try #require(WikiLinkService.updatedStorageState( - displayText: newDisplay, - editedRange: NSRange(location: 0, length: 1), - changeInLength: 1, - previousStorage: storage, - previousMetadata: meta - )) - #expect(result.storage == "xHello [[Note|abc123]] world") - let key = try #require(result.metadata.keys.first) - #expect(key.location == 7 && key.length == 8) // shifted by +1 - #expect(result.metadata[key]?.storageRange == NSRange(location: 7, length: 15)) - #expect(result.metadata[key]?.id == "abc123") - } - - @Test func deletionInPlainTextWorks() throws { - let (display, storage, meta) = seed() - // Delete the "l" of "world" (display location 18 — far enough from the - // link that neither the ±3 probe nor the metadata guard band trips). - let ns = display as NSString - let newDisplay = ns.replacingCharacters(in: NSRange(location: 18, length: 1), with: "") - let result = try #require(WikiLinkService.updatedStorageState( - displayText: newDisplay, - editedRange: NSRange(location: 18, length: 0), - changeInLength: -1, - previousStorage: storage, - previousMetadata: meta - )) - #expect(result.storage == "Hello [[Note|abc123]] word") - } - - @Test func editTouchingLinkFallsBack() { - let (display, storage, meta) = seed() - // Typing directly after "]]" (display location 14) is inside the ±3 guard band. - let result = WikiLinkService.updatedStorageState( - displayText: display + " ", - editedRange: NSRange(location: 14, length: 1), - changeInLength: 1, - previousStorage: storage, - previousMetadata: meta - ) - #expect(result == nil) - } - - @Test func editCreatingLinkSyntaxFallsBack() { - // "[[x]" + typed "]" completes a link → the new-text probe sees "]]" → bail. - let display = "pre [[x] post" - let ns = display as NSString - let newDisplay = ns.replacingCharacters(in: NSRange(location: 8, length: 0), with: "]") - let result = WikiLinkService.updatedStorageState( - displayText: newDisplay, - editedRange: NSRange(location: 8, length: 1), - changeInLength: 1, - previousStorage: display, - previousMetadata: [:] - ) - #expect(result == nil) - } - - @Test func unknownDeltaFallsBack() { - let (display, storage, meta) = seed() - let result = WikiLinkService.updatedStorageState( - displayText: display, - editedRange: NSRange(location: 0, length: 0), - changeInLength: Int.min, - previousStorage: storage, - previousMetadata: meta - ) - #expect(result == nil) - } - - /// Property sweep: inserting one char at every position that takes the fast - /// path must yield a storage string that (a) round-trips back to exactly the - /// new display text and (b) preserves every link id. (Comparing against a - /// full `makeStorageState(textStorage: nil)` rebuild would be wrong here — - /// the full rebuild loses ids when link ranges shift, which is precisely - /// what the incremental path fixes.) - @Test func spliceRoundTripsAtEverySafePosition() { - let storage = "aaaa [[One|id1]] bbbb [[Two|id2]] cccc" - let state = WikiLinkService.makeDisplayState(from: storage) - let display = state.display as NSString - var fastPathTaken = 0 - - for position in 0...display.length { - let newDisplay = display.replacingCharacters(in: NSRange(location: position, length: 0), with: "q") - guard let fast = WikiLinkService.updatedStorageState( - displayText: newDisplay, - editedRange: NSRange(location: position, length: 1), - changeInLength: 1, - previousStorage: storage, - previousMetadata: state.metadata - ) else { continue } // guarded position → fallback, fine - fastPathTaken += 1 - let roundTrip = WikiLinkService.makeDisplayState(from: fast.storage) - #expect(roundTrip.display == newDisplay, "display round-trip diverged at position \(position)") - #expect(fast.metadata.values.compactMap(\.id).sorted() == ["id1", "id2"], - "link id lost at position \(position)") - } - #expect(fastPathTaken > 0, "sweep was vacuous — no position took the fast path") - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `swift test --filter WikiLinkIncrementalTests 2>&1 | tail -5` -Expected: compile error — `updatedStorageState` does not exist. - -- [ ] **Step 3: Implement `updatedStorageState`** - -In `Sources/MarkdownEngine/Services/WikiLinkService.swift`, add after `makeStorageState` (after line ~190): - -```swift - /// Incremental counterpart to `makeStorageState`: splice a single contiguous - /// edit into the previous storage form in O(edit + #links). - /// - /// Outside link syntax, display and storage text are identical, so an edit - /// that provably cannot create, destroy, or touch a link maps 1:1 into the - /// storage string; links after the edit just shift by the length delta. - /// Returns nil whenever that proof fails — callers fall back to the full - /// `makeStorageState` rebuild. - public static func updatedStorageState( - displayText: String, - editedRange: NSRange, - changeInLength delta: Int, - previousStorage: String, - previousMetadata: [RangeKey: LinkMetadata] - ) -> (storage: String, metadata: [RangeKey: LinkMetadata])? { - let nsDisplay = displayText as NSString - let nsPrevStorage = previousStorage as NSString - - // Only contiguous, small, well-formed edits take the fast path. - guard delta != Int.min, - editedRange.location != NSNotFound, - editedRange.length >= 0, editedRange.length <= 4096, - NSMaxRange(editedRange) <= nsDisplay.length, - editedRange.length - delta >= 0 else { return nil } - - let oldEditLength = editedRange.length - delta - let oldEditRange = NSRange(location: editedRange.location, length: oldEditLength) - - // The edit must not create or complete link syntax: no [[ or ]] near it - // in the NEW text (±3 covers a bracket typed against an existing one)… - let probeStart = max(0, editedRange.location - 3) - let probeEnd = min(nsDisplay.length, NSMaxRange(editedRange) + 3) - let probe = nsDisplay.substring(with: NSRange(location: probeStart, length: probeEnd - probeStart)) - if probe.contains("[[") || probe.contains("]]") { return nil } - - // …and no existing link may overlap the edit (old display coordinates; - // ±3 also rejects edits adjacent to a link's markers). - let guardRange = NSRange(location: max(0, oldEditRange.location - 3), length: oldEditLength + 6) - for key in previousMetadata.keys { - if NSIntersectionRange(NSRange(location: key.location, length: key.length), guardRange).length > 0 { - return nil - } - } - - // Map the display edit offset into storage coordinates: every link - // before the edit is longer in storage by (storage length − display length). - var storageOffsetDelta = 0 - for (key, meta) in previousMetadata where key.location < editedRange.location { - storageOffsetDelta += meta.storageRange.length - key.length - } - let storageEditStart = editedRange.location + storageOffsetDelta - guard storageEditStart >= 0, - storageEditStart + oldEditLength <= nsPrevStorage.length else { return nil } - - // Splice — outside links the replaced/inserted characters are identical - // in both forms. - let replacement = nsDisplay.substring(with: editedRange) - let storage = nsPrevStorage.replacingCharacters( - in: NSRange(location: storageEditStart, length: oldEditLength), - with: replacement - ) - - // Shift every link after the edit by the delta; links before it are untouched. - var metadata: [RangeKey: LinkMetadata] = [:] - metadata.reserveCapacity(previousMetadata.count) - for (key, meta) in previousMetadata { - if key.location >= NSMaxRange(oldEditRange) { - metadata[RangeKey(NSRange(location: key.location + delta, length: key.length))] = - LinkMetadata(id: meta.id, - storageRange: NSRange(location: meta.storageRange.location + delta, - length: meta.storageRange.length)) - } else { - metadata[key] = meta - } - } - return (storage, metadata) - } -``` - -- [ ] **Step 4: Run the service tests** - -Run: `swift test --filter WikiLinkIncrementalTests 2>&1 | tail -5` -Expected: PASS (all 7 tests). - -- [ ] **Step 5: Wire it into the coordinator** - -In `Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift`, next to `var previousBacktickCount: Int = 0` (line ~88), add: - -```swift - /// Display-text length after the previous textDidChange — yields the edit's - /// length delta without retaining the previous text. - var previousDisplayLength: Int = -1 - /// Storage form computed by the previous wiki sync, kept synchronously - /// (unlike `lastSyncedText`, which updates via async dispatch and can lag a - /// keystroke). This is the splice base for the incremental path. - var lastComputedStorage: String = "" - /// DEBUG-only sampling counter for verifying splices against full rebuilds. - var wikiVerifyCounter: UInt = 0 -``` - -In `NativeTextViewCoordinator+TextDelegate.swift`, `textDidChange`: replace the block from `let rawSelRange = tv.selectedRange()` down to the end of the `if !wtActive { … }` wiki-sync block (currently ~lines 105–128) with: - -```swift - let rawSelRange = tv.selectedRange() - let docString = tv.string - let fullText = docString as NSString - let fullLength = fullText.length - guard !tv.hasMarkedText() else { return } - let safeLocation = min(rawSelRange.location, fullLength) - let safeSelRange = NSRange(location: safeLocation, length: 0) - previousCaretLocation = safeSelRange.location - PerfTrace.begin(docLength: fullLength) - - // Edit descriptor, hoisted above the wiki sync so both it and the - // paragraph scoping below share it. - let editedRange = pendingEditedRange ?? tv.textStorage?.editedRange ?? safeSelRange - pendingEditedRange = nil - let lengthDelta = previousDisplayLength >= 0 ? fullLength - previousDisplayLength : Int.min - previousDisplayLength = fullLength - - if !wtActive { - let storageState = PerfTrace.measure("wiki") { - WikiLinkService.updatedStorageState( - displayText: docString, - editedRange: editedRange, - changeInLength: lengthDelta, - previousStorage: lastComputedStorage, - previousMetadata: wikiLinkMetadata - ) ?? WikiLinkService.makeStorageState( - from: docString, - existingMetadata: wikiLinkMetadata, - textStorage: tv.textStorage - ) - } - self.wikiLinkMetadata = storageState.metadata - self.lastComputedStorage = storageState.storage -#if DEBUG - // Sampled safety net: every 64th keystroke, prove the splice equals - // a full rebuild. Remove together with PerfTrace after sign-off. - wikiVerifyCounter &+= 1 - if wikiVerifyCounter % 64 == 0 { - let reference = WikiLinkService.makeStorageState( - from: docString, - existingMetadata: wikiLinkMetadata, - textStorage: tv.textStorage - ) - assert(reference.storage == storageState.storage, - "wiki incremental splice diverged from full rebuild") - } -#endif - if storageState.storage != self.lastSyncedText { - DispatchQueue.main.async { - self.lastSyncedText = storageState.storage - self.text = storageState.storage - } - } - } -``` - -Then, a few lines below, **delete** the now-duplicate declarations (the old `let fullText = tv.string as NSString` line and the old `let editedRange = pendingEditedRange ?? …` / `pendingEditedRange = nil` pair further down, ~old lines 126 and 136–137) — `fullText`, `fullLength`, and `editedRange` now come from the hoisted block. The later `let safeEditedRange` computation keeps working unchanged. - -In `NativeTextViewCoordinator+Restyling.swift`, `rebuildTextStorageAndStyle`, directly after `lastSyncedText = text` (line ~39), seed the new state: - -```swift - lastSyncedText = text - lastComputedStorage = text - previousDisplayLength = (displayText as NSString).length -``` - -- [ ] **Step 6: Build and run the full suite** - -Run: `swift build && swift test 2>&1 | tail -5` -Expected: `Build complete!`, all suites pass. - -- [ ] **Step 7: Commit** - -```bash -git add Sources/MarkdownEngine/Services/WikiLinkService.swift \ - Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift \ - Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift \ - Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift \ - Tests/MarkdownEngineTests/WikiLinkIncrementalTests.swift -git commit -m "perf(wiki): splice keystroke edits into the storage form incrementally - -makeStorageState rescanned and rebuilt the whole document per keystroke -once any [[ existed (3.4ms at 139k chars). Edits that provably cannot -touch link syntax now splice into the previous storage string in -O(edit + links); anything ambiguous falls back to the full rebuild. -DEBUG samples every 64th keystroke against the full rebuild. - -Co-Authored-By: Claude Fable 5 " -``` - ---- - -### Task 3: Backtick census without full-document split (0.75 ms → ~0.05 ms) - -**Files:** -- Modify: `Sources/MarkdownEngine/Parser/MarkdownDetection.swift` (add one function) -- Modify: `Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift` (the `backtick` measure line, ~line 156) -- Test: Create `Tests/MarkdownEngineTests/BacktickCensusTests.swift` - -**Interfaces:** -- Consumes: `fullText: NSString` already hoisted in Task 2's `textDidChange` block. -- Produces: `MarkdownDetection.tripleBacktickCount(in: NSString) -> Int` with semantics identical to `components(separatedBy: "```").count - 1` (non-overlapping, left-to-right). - -**Why:** `components(separatedBy: "```")` allocates an array of substrings spanning the whole document on every keystroke. A single UTF-16 scan does the same count allocation-free. - -- [ ] **Step 1: Write the failing test** - -Create `Tests/MarkdownEngineTests/BacktickCensusTests.swift`: - -```swift -// -// BacktickCensusTests.swift -// MarkdownEngine -// -// Created by Luca Chen on 07.07.26. -// - -import Foundation -import Testing -@testable import MarkdownEngine - -@Suite("Backtick census") -struct BacktickCensusTests { - - @Test func matchesComponentsSemantics() { - let samples = [ - "", "`", "``", "```", "````", "`````", "``````", - "a```b```c", - "x\n```swift\nlet a = 1\n```\ny", - "inline `code` only", - "```````" - ] - for sample in samples { - let expected = sample.components(separatedBy: "```").count - 1 - #expect(MarkdownDetection.tripleBacktickCount(in: sample as NSString) == expected, - "mismatch for \(sample.debugDescription)") - } - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `swift test --filter BacktickCensusTests 2>&1 | tail -5` -Expected: compile error — `tripleBacktickCount` does not exist. - -- [ ] **Step 3: Implement** - -In `Sources/MarkdownEngine/Parser/MarkdownDetection.swift`, add inside `enum MarkdownDetection`: - -```swift - /// Count of non-overlapping ``` occurrences, scanning left to right — - /// exactly `components(separatedBy: "```").count - 1`, but as one UTF-16 - /// pass with no substring-array allocation. - static func tripleBacktickCount(in text: NSString) -> Int { - let length = text.length - guard length >= 3 else { return 0 } - var buffer = [unichar](repeating: 0, count: length) - text.getCharacters(&buffer, range: NSRange(location: 0, length: length)) - var count = 0 - var i = 0 - while i + 2 < length { // i can reach length - 3 - if buffer[i] == 0x60, buffer[i + 1] == 0x60, buffer[i + 2] == 0x60 { - count += 1 - i += 3 - } else { - i += 1 - } - } - return count - } -``` - -In `NativeTextViewCoordinator+TextDelegate.swift`, replace: - -```swift - let backtickCount = PerfTrace.measure("backtick") { tv.string.components(separatedBy: "```").count - 1 } -``` - -with: - -```swift - let backtickCount = PerfTrace.measure("backtick") { MarkdownDetection.tripleBacktickCount(in: fullText) } -``` - -Also replace the two remaining `tv.string` uses in `textDidChange` with the hoisted `docString` (the `parse` measure line 163: `parsedDocument(for: docString)`) so the bridge happens once per keystroke. - -- [ ] **Step 4: Run tests** - -Run: `swift test --filter BacktickCensusTests 2>&1 | tail -5` → PASS. -Run: `swift test 2>&1 | tail -5` → all pass. - -- [ ] **Step 5: Commit** - -```bash -git add Sources/MarkdownEngine/Parser/MarkdownDetection.swift \ - Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift \ - Tests/MarkdownEngineTests/BacktickCensusTests.swift -git commit -m "perf(parse): count code fences with one UTF-16 scan, hoist tv.string - -components(separatedBy:) allocated a whole-document substring array per -keystroke just to count fences. One allocation-free scan replaces it, -and textDidChange bridges tv.string exactly once. - -Co-Authored-By: Claude Fable 5 " -``` - ---- - -### Task 4: `ensureVisibleLayout` starts at the viewport, not the document head - -**Files:** -- Modify: `Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift` (`ensureVisibleLayout`, ~line 400) -- Test: none (AppKit layout — verified live via the PerfTrace `walked=` counter in Task 6; per Luca's convention no unit tests for view-layer tweaks) - -**Interfaces:** -- Consumes: `textLayoutManager` (TextKit 2 `NSTextLayoutManager`), its `textViewportLayoutController.viewportRange`. -- Produces: same function, same guarantee ("visible fragments have ensured layout"), cost now O(viewport) instead of O(caret position). - -**Why:** the walk currently starts at `documentRange.location` with `.ensuresLayout`, so typing at the bottom of a document forces a pass over every fragment above the viewport (measured: 115 of 130 fragments wasted at only 7.9k chars; grows linearly with position). - -- [ ] **Step 1: Reimplement the walk** - -Replace the body of `ensureVisibleLayout()`: - -```swift - /// Force TextKit 2 to lay out all fragments within the current visible rect. - func ensureVisibleLayout() { - guard let tlm = textLayoutManager else { return } - let visBot = visibleRect.maxY - // Start at the viewport instead of the document head: walking from the - // start forces layout of every fragment above the viewport, making a - // keystroke cost O(caret position in document). - let start = tlm.textViewportLayoutController.viewportRange?.location - ?? tlm.documentRange.location - var walked = 0 - tlm.enumerateTextLayoutFragments(from: start, options: [.ensuresLayout]) { fragment in - walked += 1 - return fragment.layoutFragmentFrame.minY <= visBot - } - PerfTrace.note { "ensureVisibleLayout walked=\(walked) frags from viewport" } - } -``` - -- [ ] **Step 2: Build and run the suite** - -Run: `swift build && swift test 2>&1 | tail -5` -Expected: `Build complete!`, all pass (HeightBehaviorTests and ScrollingHeaderControllerTests are the layout-adjacent suites to watch). - -- [ ] **Step 3: Commit** - -```bash -git add Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift -git commit -m "perf(layout): ensureVisibleLayout walks from the viewport, not the document head - -Enumerating from documentRange.location with .ensuresLayout laid out -every fragment above the viewport on each keystroke - O(caret position). -Starting at viewportRange keeps the walk O(viewport). - -Co-Authored-By: Claude Fable 5 " -``` - ---- - -### Task 5: O(1) `parsedDocument` cache hits + single UTF-16 extraction (parse 6.4 ms → target ≤ 3 ms) - -**Files:** -- Modify: `Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift` (cache vars near line 97) -- Modify: `Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift` (`parsedDocument`, line ~162; `rebuildTextStorageAndStyle`) -- Modify: `Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift` (`shouldChangeTextIn`, line ~460) -- Modify: `Sources/MarkdownEngine/Parser/BlockScopedTokenizer.swift` (`parseTokensViaAST`, line ~29) -- Modify: `Sources/MarkdownEngine/Parser/BlockParser.swift` (`parse`, line ~51) -- Test: existing parser suites must stay green (`BlockParserTests`, `ASTPipelineTests`, `InlineParserTests`, `ListParsingTests`); no new tests (pure caching/plumbing, observable behavior unchanged). - -**Interfaces:** -- Consumes: `parsedDocument(for:)` call sites (TextDelegate, Restyling, Autocorrect, ContextMenu, InlineSelection — all pass the live display text). -- Produces: `BlockParser.parse(_ text: String, utf16Chars: [unichar]? = nil) -> [Block]` (existing single-argument calls keep compiling via the default); coordinator vars `parseGeneration: UInt64`, `cachedParseGeneration: UInt64`, `cachedParsedLength: Int` that any future storage-mutating code must bump (`parseGeneration &+= 1`). - -**Why (two independent O(doc) costs inside the measured 6.4 ms):** -1. The `cachedParsedText == text` compare is O(doc) — it runs (and fails) on every keystroke, and runs (and fully scans) on every caret move. -2. `parseTokensViaAST` extracts the full UTF-16 buffer, then `BlockParser.parse` extracts the **same buffer again**. - -- [ ] **Step 1: Generation-tagged cache** - -In `NativeTextViewCoordinator.swift`, next to `var cachedParsedText: String?` (line ~97), add: - -```swift - /// Monotonic edit counter: bumped whenever the text storage can have - /// changed. Lets `parsedDocument` return cache hits in O(1) instead of an - /// O(doc) string compare. Any code that mutates the storage directly - /// (bypassing shouldChangeText/textDidChange) must bump this. - var parseGeneration: UInt64 = 0 - var cachedParseGeneration: UInt64 = .max - var cachedParsedLength: Int = -1 -``` - -In `NativeTextViewCoordinator+Restyling.swift`, rewrite the head and tail of `parsedDocument(for:)`: - -```swift - func parsedDocument(for text: String) -> ParsedDocument { - let length = (text as NSString).length - if let cachedParsedDocument, cachedParsedLength == length { - // O(1) hit: nothing has edited the storage since the cached parse. - if cachedParseGeneration == parseGeneration { - return cachedParsedDocument - } - // Generation moved but the text may still be identical (e.g. an - // attribute-only pass): verify once, then it's O(1) again. - if let cachedParsedText, cachedParsedText == text { - cachedParseGeneration = parseGeneration - return cachedParsedDocument - } - } - - let tokens = MarkdownTokenizer.parseTokensViaAST(in: text) - // … (existing token classification loop stays byte-identical) … - - cachedParsedText = text - cachedParsedLength = length - cachedParseGeneration = parseGeneration - cachedParsedDocument = parsed - return parsed - } -``` - -Bump sites: -1. `NativeTextViewCoordinator+TextDelegate.swift`, inside `textView(_:shouldChangeTextIn:replacementString:)` (line ~460), first line of the body: `parseGeneration &+= 1`. -2. Same file, `textDidChange`, directly after `PerfTrace.begin(docLength: fullLength)`: `parseGeneration &+= 1` (belt for edits that reach `didChangeText` without the delegate ask — undo, some IME paths). -3. `NativeTextViewCoordinator+Restyling.swift`, `rebuildTextStorageAndStyle`, directly after `textView.string = displayText` inside the `if textView.string != displayText` branch: `parseGeneration &+= 1`. -4. Run `grep -rn "replaceCharacters(in:\|insertText(" Sources/MarkdownEngine --include="*.swift" | grep -v Tests` and for every hit that mutates the **editor's** textStorage without going through `shouldChangeText`/`didChangeText` (candidates: task-checkbox toggle, wiki-link snapback, the inline-replacement pipeline in Restyling), add `parseGeneration &+= 1` (via the coordinator reference available at that site) right after the mutation. If a site has no coordinator access, leave it — the length + string-compare fallback keeps it correct, just slower. - -- [ ] **Step 2: Share the UTF-16 buffer** - -In `BlockParser.swift`, change the `parse` signature and buffer setup (line ~51): - -```swift - /// Splits `text` into gap-free tiling blocks; memoizes the last parse so both per-keystroke callers share one line-scan. - /// Pass `utf16Chars` when the caller already extracted the buffer (must match `text`). - static func parse(_ text: String, utf16Chars: [unichar]? = nil) -> [Block] { - let textNS = text as NSString - let newLen = textNS.length - let newChars: [unichar] - if let utf16Chars, utf16Chars.count == newLen { - newChars = utf16Chars - } else { - var buffer = [unichar](repeating: 0, count: newLen) - if newLen > 0 { textNS.getCharacters(&buffer, range: NSRange(location: 0, length: newLen)) } - newChars = buffer - } -``` - -(the rest of the function body is unchanged — it already uses `newChars`). - -In `BlockScopedTokenizer.swift`, `parseTokensViaAST` (line ~29), pass the buffer it already extracted: - -```swift - let blocks = BlockParser.parse(text, utf16Chars: newChars) -``` - -- [ ] **Step 3: Build and run the full suite** - -Run: `swift build && swift test 2>&1 | tail -5` -Expected: `Build complete!`, all suites pass — especially `BlockParserTests` and `ASTPipelineTests` (identical outputs, only plumbing changed). - -- [ ] **Step 4: Commit** - -```bash -git add Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift \ - Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift \ - Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift \ - Sources/MarkdownEngine/Parser/BlockParser.swift \ - Sources/MarkdownEngine/Parser/BlockScopedTokenizer.swift -git commit -m "perf(parse): O(1) parsedDocument cache hits, single UTF-16 extraction - -The parse cache compared the whole document string on every call - -O(doc) per keystroke AND per caret move. A generation counter bumped on -every storage edit makes hits O(1), with the string compare kept as a -correctness fallback. BlockParser now reuses the UTF-16 buffer the -tokenizer already extracted instead of re-extracting it. - -Co-Authored-By: Claude Fable 5 " -``` - ---- - -### Task 6: Live before/after measurement and report - -**Files:** -- No engine changes. Uses the Nodes app (already wired to this local engine checkout) and the PerfTrace output. - -**Interfaces:** -- Consumes: PerfTrace `⌨️ PERF` lines and `└─` notes from Tasks 0–5. - -- [ ] **Step 1: Build the app against the finished branch** - -```bash -cd /Users/lucachen/Documents/GitHub/Nodes -xcodebuild -project Nodes.xcodeproj -scheme Nodes -configuration Debug CODE_SIGNING_ALLOWED=NO build 2>&1 | tail -3 -``` - -Expected: `** BUILD SUCCEEDED **` - -- [ ] **Step 2: Ask Luca to repeat the exact baseline scenario** - -Same three docs as the 2026-07-07 baseline: the 139k-char note (typing mid-document), the ~456-char note, and the 10-table note (typing at the bottom). ~10 keystrokes each, paste the console. - -- [ ] **Step 3: Evaluate against these acceptance targets (Debug numbers)** - -| Metric | Baseline | Target | -|---|---|---| -| 139k doc: `total` | ~12.8 ms | ≤ 5 ms | -| 139k doc: `wiki` | ~3.4 ms | ≤ 0.3 ms steady (no fallback churn) | -| 139k doc: `parse` | ~6.4 ms | ≤ 3 ms | -| 139k doc: `backtick` | ~0.75 ms | ≤ 0.1 ms | -| Table doc: `styleTables` note | `re-rendered=10` | `re-rendered=0` steady-state | -| Table doc: `total` | 13–28 ms | ≤ 6 ms | -| Bottom-of-doc: `ensureVisibleLayout` | `115 above viewport (wasted)` | walked ≈ viewport fragment count, independent of caret position | - -Also confirm zero `assertionFailure` from the wiki DEBUG sampler and that links still round-trip (type near links, rename nothing, check `[[Name|UUID]]` survives in the saved file). - -- [ ] **Step 4: Report to Luca and stop** - -Summarize before/after per metric. Do **not** push, do not open a PR, do not release — Luca decides integration (and the eventual PerfTrace removal) after reviewing the numbers. - ---- - -## Explicitly Out of Scope (follow-ups, not part of this plan) - -- **Latex/imageEmbed restyle scope** (`TextDelegate.swift:188` appends every latex/image paragraph in the doc to each restyle): measured 0.00 ms in the baseline docs (none present). Re-measure with a formula-heavy doc first; only then scope it. -- **Green-tree incremental block parse** (relative-width ranges, no suffix shifting): the BlockParser header marks this as deliberate "Phase 3"; today's suffix-shift is O(#tokens) struct copies and acceptable. -- **First-keystroke-after-switch spike** (64 ms / restyle 45 ms once): one-time cost, different mechanism (full initial restyle), separate investigation. -- **`updateCodeBlockSelection`** (0.35 ms at 139k) and **overscroll recalc** (0.14 ms): below the noise floor after the fixes above. -- **WideTableOverlay full-doc ensureLayout**: measured 0.1 ms (layout already settled when it runs) — the audit flagged the code shape, but the data says leave it. -- **PerfTrace removal** and the app repo's pbxproj revert to the remote engine reference: after Luca signs off. From 3e8cdfb24ae2e4cac6b68f78521ea083ca655bfc Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Mon, 13 Jul 2026 21:21:09 +0200 Subject: [PATCH 31/39] feat(tables): wrap cell text to the container width instead of growing sideways MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long sentences in a cell used to force the whole table wide (one line per cell), pushing every table into the horizontal-scroll overlay. Now, when the natural column widths exceed the available container width, columns share the width proportionally (floored at ~3.5em each, never above their natural width) and cells word-wrap onto extra lines — Obsidian-style. Rows size individually to their tallest wrapped cell. - renderTable/tableImage take availableWidth; it is part of the image cache key (layout depends on it), so appearance/width changes render fresh while repeated same-width restyles stay cached. - Tables whose per-column floors genuinely don't fit (many columns) still render wider than the container and keep the scrollable-overlay path. - Known limit: without a reading column, a window resize doesn't re-render already-styled non-wide tables until their next restyle (the fixed-width reading column — Nodes — is unaffected). TableWrappingTests pin: wraps to width, height grows when narrower, small tables keep natural width, width is a cache-key dimension. Co-Authored-By: Claude Fable 5 --- .../Styling/MarkdownStyler+Tables.swift | 87 ++++++++++++----- .../TableImageCacheTests.swift | 16 ++-- .../TableWrappingTests.swift | 94 +++++++++++++++++++ 3 files changed, 167 insertions(+), 30 deletions(-) create mode 100644 Tests/MarkdownEngineTests/TableWrappingTests.swift diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift index e5342d90..e2793ba7 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift @@ -132,13 +132,17 @@ extension MarkdownStyler { /// Returns the rendered image for `source`, from cache when possible. /// `rendered` is true only when a fresh render actually happened. + /// `availableWidth` caps the table's width (cells wrap onto extra lines); + /// it is part of the cache key because the layout depends on it. static func tableImage( for source: String, parsed: ParsedTable, ctx: StylingContext, - appearance: NSAppearance + appearance: NSAppearance, + availableWidth: CGFloat ) -> (image: NSImage, rendered: Bool) { - let key = (themeKeyPrefix(ctx: ctx, appearance: appearance) + "|" + source) as NSString + let widthKey = Int(availableWidth.rounded()) + let key = (themeKeyPrefix(ctx: ctx, appearance: appearance) + "|w\(widthKey)|" + source) as NSString if let cached = tableImageCache.object(forKey: key) { return (cached, false) } @@ -148,7 +152,8 @@ extension MarkdownStyler { theme: ctx.configuration.theme, codeBackgroundColor: ctx.codeBackgroundColor, latex: ctx.services.latex, - appearance: appearance + appearance: appearance, + availableWidth: availableWidth ) tableImageCache.setObject(image, forKey: key) return (image, true) @@ -225,16 +230,20 @@ extension MarkdownStyler { // See renderTable: resolve table colors under the text view's real appearance. let renderAppearance = ctx.layoutBridge?.firstTextContainer?.textView?.effectiveAppearance ?? NSApp.effectiveAppearance + // Cells wrap to the container width (Obsidian-style); the render + // only exceeds it when the per-column floors genuinely don't fit, + // in which case the scrollable overlay below takes over. + let containerWidth = effectiveContainerWidth(for: ctx) let (image, rendered) = tableImage( for: source, parsed: parsed, ctx: ctx, - appearance: renderAppearance + appearance: renderAppearance, + availableWidth: containerWidth ) if rendered { renderedCount += 1 } let imageBounds = CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height) // Wide tables → scrollable mode (NSScrollView overlay); narrow → collapsed. - let containerWidth = effectiveContainerWidth(for: ctx) let isWide = image.size.width > containerWidth + 0.5 let computedSourceID = stableTableSourceID( for: source, @@ -435,7 +444,8 @@ extension MarkdownStyler { theme: MarkdownEditorTheme, codeBackgroundColor: NSColor, latex: any LatexRenderer, - appearance: NSAppearance + appearance: NSAppearance, + availableWidth: CGFloat ) -> NSImage { let columnCount = table.alignments.count let cellHPadding: CGFloat = 12 @@ -470,11 +480,8 @@ extension MarkdownStyler { } var columnWidths = [CGFloat](repeating: minColumnContentWidth, count: columnCount) - var maxCellHeight: CGFloat = baseLineHeight func considerCell(_ cell: NSAttributedString, col: Int) { - let size = cell.size() - columnWidths[col] = max(columnWidths[col], ceil(size.width)) - maxCellHeight = max(maxCellHeight, ceil(size.height)) + columnWidths[col] = max(columnWidths[col], ceil(cell.size().width)) } for (i, cell) in headerCells.enumerated() where i < columnCount { considerCell(cell, col: i) @@ -485,13 +492,49 @@ extension MarkdownStyler { } } - let lineHeight = max(baseLineHeight, maxCellHeight) + // Obsidian-style layout: when the natural column widths exceed the + // available container width, columns share the available width + // proportionally (each floored at a few ems, never above its natural + // width) and cells WRAP onto extra lines instead of growing the table + // sideways. If even the floors don't fit (many-column monsters), the + // table stays wider than the container and the horizontal-scroll + // overlay takes over as before. + let chrome = CGFloat(columnCount) * 2 * cellHPadding + + CGFloat(columnCount + 1) * borderWidth + let contentAvailable = availableWidth - chrome + let naturalContent = columnWidths.reduce(0, +) + if contentAvailable > 0, naturalContent > contentAvailable { + let wrapFloor = max(minColumnContentWidth, baseFont.pointSize * 3.5) + let scale = contentAvailable / naturalContent + columnWidths = columnWidths.map { natural in + max(min(natural, wrapFloor), (natural * scale).rounded(.down)) + } + } + + // Per-row heights: each row is as tall as its tallest (wrapped) cell. + func cellHeight(_ cell: NSAttributedString, col: Int) -> CGFloat { + let bounds = cell.boundingRect( + with: NSSize(width: columnWidths[col], height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin] + ) + return ceil(bounds.height) + } let rowCount = 1 + table.rows.count // header + body rows + var rowContentHeights = [CGFloat](repeating: baseLineHeight, count: rowCount) + for (i, cell) in headerCells.enumerated() where i < columnCount { + rowContentHeights[0] = max(rowContentHeights[0], cellHeight(cell, col: i)) + } + for (rowIdx, row) in bodyCells.enumerated() { + for (i, cell) in row.enumerated() where i < columnCount { + rowContentHeights[rowIdx + 1] = max(rowContentHeights[rowIdx + 1], cellHeight(cell, col: i)) + } + } + let totalWidth = columnWidths.reduce(0, +) + CGFloat(columnCount) * 2 * cellHPadding + CGFloat(columnCount + 1) * borderWidth - let rowHeight = lineHeight + 2 * cellVPadding - let totalHeight = CGFloat(rowCount) * rowHeight + CGFloat(rowCount + 1) * borderWidth + let totalHeight = rowContentHeights.reduce(0) { $0 + $1 + 2 * cellVPadding } + + CGFloat(rowCount + 1) * borderWidth let size = NSSize(width: totalWidth, height: totalHeight) @@ -504,7 +547,7 @@ extension MarkdownStyler { var rowTop = [CGFloat](repeating: 0, count: rowCount + 1) rowTop[0] = borderWidth for i in 0.. NSImage { + let parsed = try #require(MarkdownStyler.parseTableSource(source)) + let font = NSFont.systemFont(ofSize: 15) + var ctx = MarkdownStyler.StylingContext( + nsText: source as NSString, + tokens: [], + codeTokens: [], + activeTokenIndices: [], + baseFont: font, + layoutBridge: nil, + baseDefaultLineHeight: 18, + codeBackgroundColor: .windowBackgroundColor, + latexMarkerFont: font, + configuration: .default, + wikiLinkIDProvider: { _ in nil } + ) + ctx.scopeBounds = nil + let aqua = try #require(NSAppearance(named: .aqua)) + return MarkdownStyler.tableImage( + for: source, parsed: parsed, ctx: ctx, + appearance: aqua, availableWidth: availableWidth + ).image + } + + @Test func wideTableWrapsToTheAvailableWidth() throws { + let image = try render(wideSource, availableWidth: 650) + #expect(image.size.width <= 650.5) + } + + @Test func wrappingGrowsTheRowsInstead() throws { + let narrowRender = try render(wideSource, availableWidth: 650) + let wideRender = try render(wideSource, availableWidth: 4000) + // Same content in less width must occupy more height (wrapped lines). + #expect(narrowRender.size.height > wideRender.size.height + 10) + } + + @Test func smallTableKeepsItsNaturalWidth() throws { + let source = "| a | b |\n|---|---|\n| 1 | 2 |" + let image = try render(source, availableWidth: 650) + // Far below the available width — no artificial stretching. + #expect(image.size.width < 200) + } + + @Test func differentAvailableWidthsRenderFreshImages() throws { + let source = "| wrapcache | test |\n|---|---|\n| some fairly long sentence that needs wrapping in narrow layouts | x |" + let parsed = try #require(MarkdownStyler.parseTableSource(source)) + let font = NSFont.systemFont(ofSize: 15) + let ctx = MarkdownStyler.StylingContext( + nsText: source as NSString, + tokens: [], codeTokens: [], activeTokenIndices: [], + baseFont: font, layoutBridge: nil, baseDefaultLineHeight: 18, + codeBackgroundColor: .windowBackgroundColor, latexMarkerFont: font, + configuration: .default, wikiLinkIDProvider: { _ in nil } + ) + let aqua = try #require(NSAppearance(named: .aqua)) + + let first = MarkdownStyler.tableImage(for: source, parsed: parsed, ctx: ctx, appearance: aqua, availableWidth: 300) + let second = MarkdownStyler.tableImage(for: source, parsed: parsed, ctx: ctx, appearance: aqua, availableWidth: 900) + let third = MarkdownStyler.tableImage(for: source, parsed: parsed, ctx: ctx, appearance: aqua, availableWidth: 300) + + #expect(first.rendered) + #expect(second.rendered) // width is part of the cache key + #expect(!third.rendered) // same width again → cache hit + #expect(first.image.size.width != second.image.size.width) + } +} From be098b9d969a50fe8da42686ba3c0d08be486aaa Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Mon, 13 Jul 2026 21:26:30 +0200 Subject: [PATCH 32/39] Revert "feat(tables): wrap cell text to the container width instead of growing sideways" This reverts commit 3e8cdfb24ae2e4cac6b68f78521ea083ca655bfc. --- .../Styling/MarkdownStyler+Tables.swift | 87 +++++------------ .../TableImageCacheTests.swift | 16 ++-- .../TableWrappingTests.swift | 94 ------------------- 3 files changed, 30 insertions(+), 167 deletions(-) delete mode 100644 Tests/MarkdownEngineTests/TableWrappingTests.swift diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift index e2793ba7..e5342d90 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift @@ -132,17 +132,13 @@ extension MarkdownStyler { /// Returns the rendered image for `source`, from cache when possible. /// `rendered` is true only when a fresh render actually happened. - /// `availableWidth` caps the table's width (cells wrap onto extra lines); - /// it is part of the cache key because the layout depends on it. static func tableImage( for source: String, parsed: ParsedTable, ctx: StylingContext, - appearance: NSAppearance, - availableWidth: CGFloat + appearance: NSAppearance ) -> (image: NSImage, rendered: Bool) { - let widthKey = Int(availableWidth.rounded()) - let key = (themeKeyPrefix(ctx: ctx, appearance: appearance) + "|w\(widthKey)|" + source) as NSString + let key = (themeKeyPrefix(ctx: ctx, appearance: appearance) + "|" + source) as NSString if let cached = tableImageCache.object(forKey: key) { return (cached, false) } @@ -152,8 +148,7 @@ extension MarkdownStyler { theme: ctx.configuration.theme, codeBackgroundColor: ctx.codeBackgroundColor, latex: ctx.services.latex, - appearance: appearance, - availableWidth: availableWidth + appearance: appearance ) tableImageCache.setObject(image, forKey: key) return (image, true) @@ -230,20 +225,16 @@ extension MarkdownStyler { // See renderTable: resolve table colors under the text view's real appearance. let renderAppearance = ctx.layoutBridge?.firstTextContainer?.textView?.effectiveAppearance ?? NSApp.effectiveAppearance - // Cells wrap to the container width (Obsidian-style); the render - // only exceeds it when the per-column floors genuinely don't fit, - // in which case the scrollable overlay below takes over. - let containerWidth = effectiveContainerWidth(for: ctx) let (image, rendered) = tableImage( for: source, parsed: parsed, ctx: ctx, - appearance: renderAppearance, - availableWidth: containerWidth + appearance: renderAppearance ) if rendered { renderedCount += 1 } let imageBounds = CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height) // Wide tables → scrollable mode (NSScrollView overlay); narrow → collapsed. + let containerWidth = effectiveContainerWidth(for: ctx) let isWide = image.size.width > containerWidth + 0.5 let computedSourceID = stableTableSourceID( for: source, @@ -444,8 +435,7 @@ extension MarkdownStyler { theme: MarkdownEditorTheme, codeBackgroundColor: NSColor, latex: any LatexRenderer, - appearance: NSAppearance, - availableWidth: CGFloat + appearance: NSAppearance ) -> NSImage { let columnCount = table.alignments.count let cellHPadding: CGFloat = 12 @@ -480,8 +470,11 @@ extension MarkdownStyler { } var columnWidths = [CGFloat](repeating: minColumnContentWidth, count: columnCount) + var maxCellHeight: CGFloat = baseLineHeight func considerCell(_ cell: NSAttributedString, col: Int) { - columnWidths[col] = max(columnWidths[col], ceil(cell.size().width)) + let size = cell.size() + columnWidths[col] = max(columnWidths[col], ceil(size.width)) + maxCellHeight = max(maxCellHeight, ceil(size.height)) } for (i, cell) in headerCells.enumerated() where i < columnCount { considerCell(cell, col: i) @@ -492,49 +485,13 @@ extension MarkdownStyler { } } - // Obsidian-style layout: when the natural column widths exceed the - // available container width, columns share the available width - // proportionally (each floored at a few ems, never above its natural - // width) and cells WRAP onto extra lines instead of growing the table - // sideways. If even the floors don't fit (many-column monsters), the - // table stays wider than the container and the horizontal-scroll - // overlay takes over as before. - let chrome = CGFloat(columnCount) * 2 * cellHPadding - + CGFloat(columnCount + 1) * borderWidth - let contentAvailable = availableWidth - chrome - let naturalContent = columnWidths.reduce(0, +) - if contentAvailable > 0, naturalContent > contentAvailable { - let wrapFloor = max(minColumnContentWidth, baseFont.pointSize * 3.5) - let scale = contentAvailable / naturalContent - columnWidths = columnWidths.map { natural in - max(min(natural, wrapFloor), (natural * scale).rounded(.down)) - } - } - - // Per-row heights: each row is as tall as its tallest (wrapped) cell. - func cellHeight(_ cell: NSAttributedString, col: Int) -> CGFloat { - let bounds = cell.boundingRect( - with: NSSize(width: columnWidths[col], height: .greatestFiniteMagnitude), - options: [.usesLineFragmentOrigin] - ) - return ceil(bounds.height) - } + let lineHeight = max(baseLineHeight, maxCellHeight) let rowCount = 1 + table.rows.count // header + body rows - var rowContentHeights = [CGFloat](repeating: baseLineHeight, count: rowCount) - for (i, cell) in headerCells.enumerated() where i < columnCount { - rowContentHeights[0] = max(rowContentHeights[0], cellHeight(cell, col: i)) - } - for (rowIdx, row) in bodyCells.enumerated() { - for (i, cell) in row.enumerated() where i < columnCount { - rowContentHeights[rowIdx + 1] = max(rowContentHeights[rowIdx + 1], cellHeight(cell, col: i)) - } - } - let totalWidth = columnWidths.reduce(0, +) + CGFloat(columnCount) * 2 * cellHPadding + CGFloat(columnCount + 1) * borderWidth - let totalHeight = rowContentHeights.reduce(0) { $0 + $1 + 2 * cellVPadding } - + CGFloat(rowCount + 1) * borderWidth + let rowHeight = lineHeight + 2 * cellVPadding + let totalHeight = CGFloat(rowCount) * rowHeight + CGFloat(rowCount + 1) * borderWidth let size = NSSize(width: totalWidth, height: totalHeight) @@ -547,7 +504,7 @@ extension MarkdownStyler { var rowTop = [CGFloat](repeating: 0, count: rowCount + 1) rowTop[0] = borderWidth for i in 0.. NSImage { - let parsed = try #require(MarkdownStyler.parseTableSource(source)) - let font = NSFont.systemFont(ofSize: 15) - var ctx = MarkdownStyler.StylingContext( - nsText: source as NSString, - tokens: [], - codeTokens: [], - activeTokenIndices: [], - baseFont: font, - layoutBridge: nil, - baseDefaultLineHeight: 18, - codeBackgroundColor: .windowBackgroundColor, - latexMarkerFont: font, - configuration: .default, - wikiLinkIDProvider: { _ in nil } - ) - ctx.scopeBounds = nil - let aqua = try #require(NSAppearance(named: .aqua)) - return MarkdownStyler.tableImage( - for: source, parsed: parsed, ctx: ctx, - appearance: aqua, availableWidth: availableWidth - ).image - } - - @Test func wideTableWrapsToTheAvailableWidth() throws { - let image = try render(wideSource, availableWidth: 650) - #expect(image.size.width <= 650.5) - } - - @Test func wrappingGrowsTheRowsInstead() throws { - let narrowRender = try render(wideSource, availableWidth: 650) - let wideRender = try render(wideSource, availableWidth: 4000) - // Same content in less width must occupy more height (wrapped lines). - #expect(narrowRender.size.height > wideRender.size.height + 10) - } - - @Test func smallTableKeepsItsNaturalWidth() throws { - let source = "| a | b |\n|---|---|\n| 1 | 2 |" - let image = try render(source, availableWidth: 650) - // Far below the available width — no artificial stretching. - #expect(image.size.width < 200) - } - - @Test func differentAvailableWidthsRenderFreshImages() throws { - let source = "| wrapcache | test |\n|---|---|\n| some fairly long sentence that needs wrapping in narrow layouts | x |" - let parsed = try #require(MarkdownStyler.parseTableSource(source)) - let font = NSFont.systemFont(ofSize: 15) - let ctx = MarkdownStyler.StylingContext( - nsText: source as NSString, - tokens: [], codeTokens: [], activeTokenIndices: [], - baseFont: font, layoutBridge: nil, baseDefaultLineHeight: 18, - codeBackgroundColor: .windowBackgroundColor, latexMarkerFont: font, - configuration: .default, wikiLinkIDProvider: { _ in nil } - ) - let aqua = try #require(NSAppearance(named: .aqua)) - - let first = MarkdownStyler.tableImage(for: source, parsed: parsed, ctx: ctx, appearance: aqua, availableWidth: 300) - let second = MarkdownStyler.tableImage(for: source, parsed: parsed, ctx: ctx, appearance: aqua, availableWidth: 900) - let third = MarkdownStyler.tableImage(for: source, parsed: parsed, ctx: ctx, appearance: aqua, availableWidth: 300) - - #expect(first.rendered) - #expect(second.rendered) // width is part of the cache key - #expect(!third.rendered) // same width again → cache hit - #expect(first.image.size.width != second.image.size.width) - } -} From d1fe160609a3850437548bd2af20ba669ab13c1c Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Mon, 13 Jul 2026 21:41:15 +0200 Subject: [PATCH 33/39] Reapply "feat(tables): wrap cell text to the container width instead of growing sideways" This reverts commit be098b9d969a50fe8da42686ba3c0d08be486aaa. --- .../Styling/MarkdownStyler+Tables.swift | 87 ++++++++++++----- .../TableImageCacheTests.swift | 16 ++-- .../TableWrappingTests.swift | 94 +++++++++++++++++++ 3 files changed, 167 insertions(+), 30 deletions(-) create mode 100644 Tests/MarkdownEngineTests/TableWrappingTests.swift diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift index e5342d90..e2793ba7 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift @@ -132,13 +132,17 @@ extension MarkdownStyler { /// Returns the rendered image for `source`, from cache when possible. /// `rendered` is true only when a fresh render actually happened. + /// `availableWidth` caps the table's width (cells wrap onto extra lines); + /// it is part of the cache key because the layout depends on it. static func tableImage( for source: String, parsed: ParsedTable, ctx: StylingContext, - appearance: NSAppearance + appearance: NSAppearance, + availableWidth: CGFloat ) -> (image: NSImage, rendered: Bool) { - let key = (themeKeyPrefix(ctx: ctx, appearance: appearance) + "|" + source) as NSString + let widthKey = Int(availableWidth.rounded()) + let key = (themeKeyPrefix(ctx: ctx, appearance: appearance) + "|w\(widthKey)|" + source) as NSString if let cached = tableImageCache.object(forKey: key) { return (cached, false) } @@ -148,7 +152,8 @@ extension MarkdownStyler { theme: ctx.configuration.theme, codeBackgroundColor: ctx.codeBackgroundColor, latex: ctx.services.latex, - appearance: appearance + appearance: appearance, + availableWidth: availableWidth ) tableImageCache.setObject(image, forKey: key) return (image, true) @@ -225,16 +230,20 @@ extension MarkdownStyler { // See renderTable: resolve table colors under the text view's real appearance. let renderAppearance = ctx.layoutBridge?.firstTextContainer?.textView?.effectiveAppearance ?? NSApp.effectiveAppearance + // Cells wrap to the container width (Obsidian-style); the render + // only exceeds it when the per-column floors genuinely don't fit, + // in which case the scrollable overlay below takes over. + let containerWidth = effectiveContainerWidth(for: ctx) let (image, rendered) = tableImage( for: source, parsed: parsed, ctx: ctx, - appearance: renderAppearance + appearance: renderAppearance, + availableWidth: containerWidth ) if rendered { renderedCount += 1 } let imageBounds = CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height) // Wide tables → scrollable mode (NSScrollView overlay); narrow → collapsed. - let containerWidth = effectiveContainerWidth(for: ctx) let isWide = image.size.width > containerWidth + 0.5 let computedSourceID = stableTableSourceID( for: source, @@ -435,7 +444,8 @@ extension MarkdownStyler { theme: MarkdownEditorTheme, codeBackgroundColor: NSColor, latex: any LatexRenderer, - appearance: NSAppearance + appearance: NSAppearance, + availableWidth: CGFloat ) -> NSImage { let columnCount = table.alignments.count let cellHPadding: CGFloat = 12 @@ -470,11 +480,8 @@ extension MarkdownStyler { } var columnWidths = [CGFloat](repeating: minColumnContentWidth, count: columnCount) - var maxCellHeight: CGFloat = baseLineHeight func considerCell(_ cell: NSAttributedString, col: Int) { - let size = cell.size() - columnWidths[col] = max(columnWidths[col], ceil(size.width)) - maxCellHeight = max(maxCellHeight, ceil(size.height)) + columnWidths[col] = max(columnWidths[col], ceil(cell.size().width)) } for (i, cell) in headerCells.enumerated() where i < columnCount { considerCell(cell, col: i) @@ -485,13 +492,49 @@ extension MarkdownStyler { } } - let lineHeight = max(baseLineHeight, maxCellHeight) + // Obsidian-style layout: when the natural column widths exceed the + // available container width, columns share the available width + // proportionally (each floored at a few ems, never above its natural + // width) and cells WRAP onto extra lines instead of growing the table + // sideways. If even the floors don't fit (many-column monsters), the + // table stays wider than the container and the horizontal-scroll + // overlay takes over as before. + let chrome = CGFloat(columnCount) * 2 * cellHPadding + + CGFloat(columnCount + 1) * borderWidth + let contentAvailable = availableWidth - chrome + let naturalContent = columnWidths.reduce(0, +) + if contentAvailable > 0, naturalContent > contentAvailable { + let wrapFloor = max(minColumnContentWidth, baseFont.pointSize * 3.5) + let scale = contentAvailable / naturalContent + columnWidths = columnWidths.map { natural in + max(min(natural, wrapFloor), (natural * scale).rounded(.down)) + } + } + + // Per-row heights: each row is as tall as its tallest (wrapped) cell. + func cellHeight(_ cell: NSAttributedString, col: Int) -> CGFloat { + let bounds = cell.boundingRect( + with: NSSize(width: columnWidths[col], height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin] + ) + return ceil(bounds.height) + } let rowCount = 1 + table.rows.count // header + body rows + var rowContentHeights = [CGFloat](repeating: baseLineHeight, count: rowCount) + for (i, cell) in headerCells.enumerated() where i < columnCount { + rowContentHeights[0] = max(rowContentHeights[0], cellHeight(cell, col: i)) + } + for (rowIdx, row) in bodyCells.enumerated() { + for (i, cell) in row.enumerated() where i < columnCount { + rowContentHeights[rowIdx + 1] = max(rowContentHeights[rowIdx + 1], cellHeight(cell, col: i)) + } + } + let totalWidth = columnWidths.reduce(0, +) + CGFloat(columnCount) * 2 * cellHPadding + CGFloat(columnCount + 1) * borderWidth - let rowHeight = lineHeight + 2 * cellVPadding - let totalHeight = CGFloat(rowCount) * rowHeight + CGFloat(rowCount + 1) * borderWidth + let totalHeight = rowContentHeights.reduce(0) { $0 + $1 + 2 * cellVPadding } + + CGFloat(rowCount + 1) * borderWidth let size = NSSize(width: totalWidth, height: totalHeight) @@ -504,7 +547,7 @@ extension MarkdownStyler { var rowTop = [CGFloat](repeating: 0, count: rowCount + 1) rowTop[0] = borderWidth for i in 0.. NSImage { + let parsed = try #require(MarkdownStyler.parseTableSource(source)) + let font = NSFont.systemFont(ofSize: 15) + var ctx = MarkdownStyler.StylingContext( + nsText: source as NSString, + tokens: [], + codeTokens: [], + activeTokenIndices: [], + baseFont: font, + layoutBridge: nil, + baseDefaultLineHeight: 18, + codeBackgroundColor: .windowBackgroundColor, + latexMarkerFont: font, + configuration: .default, + wikiLinkIDProvider: { _ in nil } + ) + ctx.scopeBounds = nil + let aqua = try #require(NSAppearance(named: .aqua)) + return MarkdownStyler.tableImage( + for: source, parsed: parsed, ctx: ctx, + appearance: aqua, availableWidth: availableWidth + ).image + } + + @Test func wideTableWrapsToTheAvailableWidth() throws { + let image = try render(wideSource, availableWidth: 650) + #expect(image.size.width <= 650.5) + } + + @Test func wrappingGrowsTheRowsInstead() throws { + let narrowRender = try render(wideSource, availableWidth: 650) + let wideRender = try render(wideSource, availableWidth: 4000) + // Same content in less width must occupy more height (wrapped lines). + #expect(narrowRender.size.height > wideRender.size.height + 10) + } + + @Test func smallTableKeepsItsNaturalWidth() throws { + let source = "| a | b |\n|---|---|\n| 1 | 2 |" + let image = try render(source, availableWidth: 650) + // Far below the available width — no artificial stretching. + #expect(image.size.width < 200) + } + + @Test func differentAvailableWidthsRenderFreshImages() throws { + let source = "| wrapcache | test |\n|---|---|\n| some fairly long sentence that needs wrapping in narrow layouts | x |" + let parsed = try #require(MarkdownStyler.parseTableSource(source)) + let font = NSFont.systemFont(ofSize: 15) + let ctx = MarkdownStyler.StylingContext( + nsText: source as NSString, + tokens: [], codeTokens: [], activeTokenIndices: [], + baseFont: font, layoutBridge: nil, baseDefaultLineHeight: 18, + codeBackgroundColor: .windowBackgroundColor, latexMarkerFont: font, + configuration: .default, wikiLinkIDProvider: { _ in nil } + ) + let aqua = try #require(NSAppearance(named: .aqua)) + + let first = MarkdownStyler.tableImage(for: source, parsed: parsed, ctx: ctx, appearance: aqua, availableWidth: 300) + let second = MarkdownStyler.tableImage(for: source, parsed: parsed, ctx: ctx, appearance: aqua, availableWidth: 900) + let third = MarkdownStyler.tableImage(for: source, parsed: parsed, ctx: ctx, appearance: aqua, availableWidth: 300) + + #expect(first.rendered) + #expect(second.rendered) // width is part of the cache key + #expect(!third.rendered) // same width again → cache hit + #expect(first.image.size.width != second.image.size.width) + } +} From 69fe28fd98220b4614b88598f154fb48f2a929ed Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Mon, 13 Jul 2026 21:41:36 +0200 Subject: [PATCH 34/39] =?UTF-8?q?feat(tables):=20W3C=20auto-layout=20minim?= =?UTF-8?q?ums=20=E2=80=94=20never=20below=20the=20longest=20word,=20horiz?= =?UTF-8?q?ontal=20scroll=20when=20minimums=20don't=20fit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refines the cell-wrapping layout per the CSS automatic table layout algorithm (W3C 17.5.2.2): column min = widest whitespace-separated segment (measured per segment — a too-narrow boundingRect emergency- breaks INSIDE words and understates the minimum), column max = one-line width. Available width is distributed proportionally to (max−min) above the minimums; when even the minimums don't fit (many-column tables), the table stays wide and the horizontal-scroll overlay takes over. Demo gains a Tables section showing both cases. Co-Authored-By: Claude Fable 5 --- Demo/MarkdownEngineDemo/ContentView.swift | 22 ++++++ .../Styling/MarkdownStyler+Tables.swift | 67 ++++++++++++++----- .../TableWrappingTests.swift | 27 ++++++++ 3 files changed, 101 insertions(+), 15 deletions(-) diff --git a/Demo/MarkdownEngineDemo/ContentView.swift b/Demo/MarkdownEngineDemo/ContentView.swift index a22204ec..64f469d3 100644 --- a/Demo/MarkdownEngineDemo/ContentView.swift +++ b/Demo/MarkdownEngineDemo/ContentView.swift @@ -107,12 +107,34 @@ private var sampleMarkdown: String { [ markdownHeader, inlineFormattingSection, + tableSection, latexSection, codeSection, markdownFooter, ].joined(separator: "\n\n") } +/// Table layout demo: the first table's cells WRAP to the available width +/// (CSS auto-layout style); the second has so many columns that even the +/// longest-word minimums don't fit — it stays wide and scrolls horizontally. +private let tableSection = """ +## Tables + +Cells wrap to the available width: + +| Rechtsform | Gründungskosten | Laufende Kosten/Jahr | +|---|---|---| +| Einzelunternehmen (Kleingewerbe) | 20–60€ (Gewerbeanmeldung) | ~0€ (nur Steuerberater optional, 300–800€) | +| GbR (mit zwei Gesellschaftern) | 20–60€ x Anzahl Gesellschafter (jeder meldet einzeln an) | Gesellschaftervertrag empfohlen (Anwalt: 500–1.500€ einmalig) | +| UG (haftungsbeschränkt) | Notar + Handelsregister: ~300–500€ (Musterprotokoll) bis 1.000€+ | IHK-Beitrag (~150–400€), Steuerberater fast Pflicht | + +Too many columns → horizontal scroll instead of crushed cells: + +| Rechtsformvergleich | Gründungskostenaufstellung | Haftungsbeschränkung | Steuerberaterkosten | Handelsregistereintrag | Stammkapitalanforderung | +|---|---|---|---|---|---| +| Einzelunternehmen | Gewerbeanmeldung | unbeschränkt | optional | nein | keines | +""" + private let markdownHeader = """ # MarkdownEngine diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift index e2793ba7..80da7593 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift @@ -479,9 +479,39 @@ extension MarkdownStyler { } } - var columnWidths = [CGFloat](repeating: minColumnContentWidth, count: columnCount) + // CSS automatic table layout (W3C 17.5.2.2), which is what browser-based + // editors like Obsidian get for free: each column has a MAXIMUM width + // (content on one line) and a MINIMUM width (MCW — content may wrap but + // must not overflow, i.e. the widest unbreakable whitespace-separated + // segment). Measured segment-by-segment: a too-narrow boundingRect + // would emergency-break INSIDE words and understate the minimum. + func widestUnbreakableSegment(_ cell: NSAttributedString) -> CGFloat { + let str = cell.string as NSString + let whitespace = CharacterSet.whitespacesAndNewlines + var widest: CGFloat = 0 + var segStart = -1 + for i in 0...str.length { + let isBreak = i == str.length || { + guard let scalar = Unicode.Scalar(str.character(at: i)) else { return false } + return whitespace.contains(scalar) + }() + if isBreak { + if segStart >= 0 { + let segment = cell.attributedSubstring(from: NSRange(location: segStart, length: i - segStart)) + widest = max(widest, ceil(segment.size().width)) + segStart = -1 + } + } else if segStart < 0 { + segStart = i + } + } + return widest + } + var maxWidths = [CGFloat](repeating: minColumnContentWidth, count: columnCount) + var minWidths = [CGFloat](repeating: minColumnContentWidth, count: columnCount) func considerCell(_ cell: NSAttributedString, col: Int) { - columnWidths[col] = max(columnWidths[col], ceil(cell.size().width)) + maxWidths[col] = max(maxWidths[col], ceil(cell.size().width)) + minWidths[col] = max(minWidths[col], widestUnbreakableSegment(cell)) } for (i, cell) in headerCells.enumerated() where i < columnCount { considerCell(cell, col: i) @@ -492,22 +522,29 @@ extension MarkdownStyler { } } - // Obsidian-style layout: when the natural column widths exceed the - // available container width, columns share the available width - // proportionally (each floored at a few ems, never above its natural - // width) and cells WRAP onto extra lines instead of growing the table - // sideways. If even the floors don't fit (many-column monsters), the - // table stays wider than the container and the horizontal-scroll - // overlay takes over as before. + // Distribute the available width: + // - everything fits on one line → natural (maximum) widths; + // - too wide → shrink to the available width, but never below a + // column's longest unbreakable word; the slack above the minimums is + // distributed proportionally to each column's (max − min) stretch; + // - even the minimums don't fit (many-column tables) → columns stay at + // their minimums, the table renders wider than the container, and + // the horizontal-scroll overlay takes over as before. let chrome = CGFloat(columnCount) * 2 * cellHPadding + CGFloat(columnCount + 1) * borderWidth let contentAvailable = availableWidth - chrome - let naturalContent = columnWidths.reduce(0, +) - if contentAvailable > 0, naturalContent > contentAvailable { - let wrapFloor = max(minColumnContentWidth, baseFont.pointSize * 3.5) - let scale = contentAvailable / naturalContent - columnWidths = columnWidths.map { natural in - max(min(natural, wrapFloor), (natural * scale).rounded(.down)) + let sumMax = maxWidths.reduce(0, +) + let sumMin = minWidths.reduce(0, +) + var columnWidths = maxWidths + if contentAvailable > 0, sumMax > contentAvailable { + if sumMin >= contentAvailable { + columnWidths = minWidths + } else { + let extra = contentAvailable - sumMin + let totalStretch = sumMax - sumMin + columnWidths = zip(minWidths, maxWidths).map { mn, mx in + mn + ((mx - mn) / totalStretch * extra).rounded(.down) + } } } diff --git a/Tests/MarkdownEngineTests/TableWrappingTests.swift b/Tests/MarkdownEngineTests/TableWrappingTests.swift index 7cc05e2d..ec7bc45e 100644 --- a/Tests/MarkdownEngineTests/TableWrappingTests.swift +++ b/Tests/MarkdownEngineTests/TableWrappingTests.swift @@ -62,6 +62,33 @@ struct TableWrappingTests { #expect(narrowRender.size.height > wideRender.size.height + 10) } + // W3C auto layout: columns never shrink below their longest unbreakable + // word — when even those minimums don't fit, the table stays WIDER than + // the container and the horizontal-scroll overlay takes over. + @Test func manyColumnsFallBackToHorizontalScroll() throws { + let source = """ + | Rechtsformvergleich | Gründungskostenaufstellung | Haftungsbeschränkung | Steuerberaterkosten | Handelsregistereintrag | Stammkapitalanforderung | + |---|---|---|---|---|---| + | Einzelunternehmen | Gewerbeanmeldung | unbeschränkt | optional | nein | keines | + """ + let image = try render(source, availableWidth: 500) + // The longest-word minimums of six columns can't fit in 500pt — the + // table must stay wider and scroll horizontally, not crush the columns. + #expect(image.size.width > 500.5) + } + + @Test func columnsNeverShrinkBelowTheLongestWord() throws { + let source = """ + | A | B | + |---|---| + | Donaudampfschifffahrtsgesellschaftskapitän | x | + """ + let image = try render(source, availableWidth: 200) + // The unbreakable word is wider than 200 — the table must exceed the + // available width rather than break mid-word. + #expect(image.size.width > 200.5) + } + @Test func smallTableKeepsItsNaturalWidth() throws { let source = "| a | b |\n|---|---|\n| 1 | 2 |" let image = try render(source, availableWidth: 650) From 6efe6b79e3e4623459b3e811f0f6b24af341f44a Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Mon, 13 Jul 2026 21:54:20 +0200 Subject: [PATCH 35/39] =?UTF-8?q?fix(spelling):=20suppress=20spell=20squig?= =?UTF-8?q?gles=20inside=20tables=20=E2=80=94=20the=20stray=20red=20dot=20?= =?UTF-8?q?under=20rendered=20tables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live-diagnosed (🔴 SPELL value=1 inTable=true APPLIED text="Setapp"): the checker painted red squiggles onto words in table SOURCE, which is collapsed to a ~1pt line under the rendered image — showing up as a stray red dot below the table. Tables were the one rendered token kind missing from isInsideSpellcheckSuppressedToken (code/latex/links were covered); the per-keystroke doc-wide spellingState scrub that used to hide this went away with the table-skip optimization. The suspected typing-lag from the earlier attempt at this fix was since proven to be TextKit-internal element shifting, unrelated to this path (+spellCb accumulate shows the callback cost directly if ever in doubt). Co-Authored-By: Claude Fable 5 --- ...ativeTextViewCoordinator+Autocorrect.swift | 4 ++-- .../TableScopeSkipTests.swift | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Autocorrect.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Autocorrect.swift index 15eaeb6d..d546ef85 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Autocorrect.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Autocorrect.swift @@ -82,7 +82,7 @@ extension NativeTextViewCoordinator { func isInsideSpellcheckSuppressedToken(location: Int, in text: String) -> Bool { let parsed = parsedDocument(for: text) return parsed.tokens.contains { token in - guard token.kind == .wikiLink || token.kind == .link || token.kind == .imageEmbed else { + guard token.kind == .wikiLink || token.kind == .link || token.kind == .imageEmbed || token.kind == .table else { return false } return NSLocationInRange(location, token.range) @@ -92,7 +92,7 @@ extension NativeTextViewCoordinator { func isInsideSpellcheckSuppressedToken(range: NSRange, in text: String) -> Bool { let parsed = parsedDocument(for: text) return parsed.tokens.contains { token in - guard token.kind == .wikiLink || token.kind == .link || token.kind == .imageEmbed else { + guard token.kind == .wikiLink || token.kind == .link || token.kind == .imageEmbed || token.kind == .table else { return false } return NSIntersectionRange(token.range, range).length > 0 diff --git a/Tests/MarkdownEngineTests/TableScopeSkipTests.swift b/Tests/MarkdownEngineTests/TableScopeSkipTests.swift index 52e98461..5befda46 100644 --- a/Tests/MarkdownEngineTests/TableScopeSkipTests.swift +++ b/Tests/MarkdownEngineTests/TableScopeSkipTests.swift @@ -14,6 +14,7 @@ import AppKit import Foundation +import SwiftUI import Testing @testable import MarkdownEngine @@ -66,6 +67,28 @@ struct TableScopeSkipTests { } } + // The spell checker must never paint squiggles on table source: the + // collapsed ~1pt source line under the rendered image shows them as a + // stray red dot (proven live: `🔴 SPELL … inTable=true APPLIED`). + @Test @MainActor func spellcheckSuppressionCoversTables() { + _ = NSApplication.shared + let text = "| alpha | beta |\n|---|---|\n| Setapp | 2 |\n\nprose" + let coordinator = NativeTextViewCoordinator( + text: .constant(text), + fontName: "SF Pro Text", + fontSize: 14, + isWikiLinkActive: .constant(false), + onLinkClick: nil, + onInlineSelectionChange: nil + ) + let typoRange = (text as NSString).range(of: "Setapp") + + #expect(coordinator.isInsideSpellcheckSuppressedToken(range: typoRange, in: text)) + #expect(coordinator.isInsideSpellcheckSuppressedToken(location: typoRange.location, in: text)) + let proseRange = (text as NSString).range(of: "prose") + #expect(!coordinator.isInsideSpellcheckSuppressedToken(range: proseRange, in: text)) + } + // A table inside the restyle scope must still emit its attributes. @Test func inScopeTableStillEmitsItsAttributes() throws { let text = "| mu | nu |\n|---|---|\n| 7 | 8 |\n\nplain paragraph text here" From 6627a59936ab492213ed65112501ca0385ff60aa Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Mon, 13 Jul 2026 21:59:59 +0200 Subject: [PATCH 36/39] fix(cursor): arrow instead of I-beam over wide-table scroll overlays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overlay is a control surface (rendered table image + horizontal scroller), not text, but the text view's tracking areas are not occlusion-aware — super kept setting the I-beam through the overlay on every mouse move. Mirrors the existing task-checkbox suppression branch (skip super entirely; setting the arrow after it flickers). Co-Authored-By: Claude Fable 5 --- .../NativeTextView+CursorRects.swift | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+CursorRects.swift b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+CursorRects.swift index 76ef4bf7..43964234 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+CursorRects.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+CursorRects.swift @@ -23,6 +23,12 @@ extension NativeTextView { // on every move, so setting the arrow after it flickers — skip // super entirely, like the exclusion-zone branch. NSCursor.arrow.set() + } else if isEditable, isOverWideTableOverlay(event) { + // Same treatment for wide-table scroll overlays: the overlay is a + // control surface (rendered image + horizontal scroller), not + // text, but the text view's tracking areas are not occlusion-aware + // and super keeps setting the I-beam through it. + NSCursor.arrow.set() } else { super.mouseMoved(with: event) applyReadOnlyCursor(for: event) @@ -34,12 +40,26 @@ extension NativeTextView { if isEditable { NSCursor.arrow.set() } } else if isEditable, isOverTaskCheckboxBox(event) { NSCursor.arrow.set() + } else if isEditable, isOverWideTableOverlay(event) { + NSCursor.arrow.set() } else { super.mouseEntered(with: event) applyReadOnlyCursor(for: event) } } + /// True when the pointer is over a wide-table scroll overlay (mirrors the + /// task-checkbox suppression above; read-only mode already shows the arrow + /// via `applyReadOnlyCursor`). + private func isOverWideTableOverlay(_ event: NSEvent) -> Bool { + guard !wideTableOverlays.isEmpty else { return false } + for (_, overlay) in wideTableOverlays where overlay.superview != nil && !overlay.isHidden { + let point = overlay.convert(event.locationInWindow, from: nil) + if overlay.bounds.contains(point) { return true } + } + return false + } + /// True inside an embedder exclusion zone — a panel over the editor or a /// full-window overlay (search/transfer) that owns the cursor. NOT gated on /// `isEditable`: overlays make the editor read-only, and gating let its From f4ade738951b40d32fb703382512e6b05eda61d2 Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Mon, 13 Jul 2026 22:00:14 +0200 Subject: [PATCH 37/39] chore: drop the spell-suppression unit test and the red-dot diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one-line .table fix stays (6efe6b7); the temp 🔴 SPELL logging served its purpose (live-proved the squiggle-on-collapsed-table-source cause). Co-Authored-By: Claude Fable 5 --- .../TableScopeSkipTests.swift | 23 ------------------- 1 file changed, 23 deletions(-) diff --git a/Tests/MarkdownEngineTests/TableScopeSkipTests.swift b/Tests/MarkdownEngineTests/TableScopeSkipTests.swift index 5befda46..52e98461 100644 --- a/Tests/MarkdownEngineTests/TableScopeSkipTests.swift +++ b/Tests/MarkdownEngineTests/TableScopeSkipTests.swift @@ -14,7 +14,6 @@ import AppKit import Foundation -import SwiftUI import Testing @testable import MarkdownEngine @@ -67,28 +66,6 @@ struct TableScopeSkipTests { } } - // The spell checker must never paint squiggles on table source: the - // collapsed ~1pt source line under the rendered image shows them as a - // stray red dot (proven live: `🔴 SPELL … inTable=true APPLIED`). - @Test @MainActor func spellcheckSuppressionCoversTables() { - _ = NSApplication.shared - let text = "| alpha | beta |\n|---|---|\n| Setapp | 2 |\n\nprose" - let coordinator = NativeTextViewCoordinator( - text: .constant(text), - fontName: "SF Pro Text", - fontSize: 14, - isWikiLinkActive: .constant(false), - onLinkClick: nil, - onInlineSelectionChange: nil - ) - let typoRange = (text as NSString).range(of: "Setapp") - - #expect(coordinator.isInsideSpellcheckSuppressedToken(range: typoRange, in: text)) - #expect(coordinator.isInsideSpellcheckSuppressedToken(location: typoRange.location, in: text)) - let proseRange = (text as NSString).range(of: "prose") - #expect(!coordinator.isInsideSpellcheckSuppressedToken(range: proseRange, in: text)) - } - // A table inside the restyle scope must still emit its attributes. @Test func inScopeTableStillEmitsItsAttributes() throws { let text = "| mu | nu |\n|---|---|\n| 7 | 8 |\n\nplain paragraph text here" From 082fda7883d122d525aacaca86b68b33e7701c5e Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Mon, 13 Jul 2026 22:01:39 +0200 Subject: [PATCH 38/39] fix(cursor): limit the arrow to the wide-table overlay's horizontal scroller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only the scroller strip is a control surface — over the rendered table image the normal text-cursor behavior stays. Co-Authored-By: Claude Fable 5 --- .../NativeTextView/NativeTextView+CursorRects.swift | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+CursorRects.swift b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+CursorRects.swift index 43964234..ea7a24f8 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+CursorRects.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+CursorRects.swift @@ -48,14 +48,17 @@ extension NativeTextView { } } - /// True when the pointer is over a wide-table scroll overlay (mirrors the - /// task-checkbox suppression above; read-only mode already shows the arrow - /// via `applyReadOnlyCursor`). + /// True when the pointer is over a wide-table overlay's HORIZONTAL + /// SCROLLER (mirrors the task-checkbox suppression above; read-only mode + /// already shows the arrow via `applyReadOnlyCursor`). Only the scroller + /// strip is a control surface — over the rendered table image itself the + /// normal text cursor behavior stays. private func isOverWideTableOverlay(_ event: NSEvent) -> Bool { guard !wideTableOverlays.isEmpty else { return false } for (_, overlay) in wideTableOverlays where overlay.superview != nil && !overlay.isHidden { - let point = overlay.convert(event.locationInWindow, from: nil) - if overlay.bounds.contains(point) { return true } + guard let scroller = overlay.horizontalScroller, !scroller.isHidden else { continue } + let point = scroller.convert(event.locationInWindow, from: nil) + if scroller.bounds.contains(point) { return true } } return false } From 917370e48525a5643ba9af618f2e16cc0843c857 Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Tue, 14 Jul 2026 09:32:54 +0200 Subject: [PATCH 39/39] Propagate lists config to the live editor in updateNSView Auto-close pairs / list helpers were only written in makeNSView, so an embedder settings toggle stayed inert until the editor was rebuilt (app restart). The keystroke handlers read textView.configuration live, so syncing the field is all a runtime flip needs. Co-Authored-By: Claude Fable 5 --- .../MarkdownEngine/TextView/NativeTextViewWrapper.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift index e1a1d120..43eea010 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift @@ -464,6 +464,13 @@ public struct NativeTextViewWrapper: NSViewRepresentable { let coordinator = context.coordinator DispatchQueue.main.async { coordinator.isWikiLinkActive = false } } + // Sync the input-behavior toggles (auto-close pairs, list helpers). + // The keystroke handlers read textView.configuration live, but only + // makeNSView used to write it — an embedder settings change was inert + // until the editor was rebuilt. Plain assignment: a tiny value struct, + // and no rebuild is needed for it to take effect. + textView.configuration.lists = configuration.lists + context.coordinator.configuration.lists = configuration.lists // Reading column centers by POSITION (container subview), so the text inset is constant. let desiredTextInset = NSSize( width: configuration.textInsets.horizontal,