Skip to content

Commit bb10a96

Browse files
committed
wip: TextKit 2 fragment redraw issue on insert (diagnostics included)
This commit folds two iterations together: 1. Remove `textView.isContinuousSpellCheckingEnabled = false` toggle that caused a recursive textDidChange cascade. 2. Add `[SC]` diagnostic prints + try four fragment-redraw strategies to fix the on-insert underline disappearance — none worked. --- Part 1: recursive cascade fix (kept) --- Setting `textView.isContinuousSpellCheckingEnabled = false` in `scheduleSpellCheck` caused a recursive cascade on macOS 15.x: 1. AppKit synchronously clears `.spellingState` attributes 2. Attribute change triggers `textDidChange` delegate 3. `textDidChange` calls `clearSpellMisspellings` (cache empty) + `scheduleSpellCheck` (cancels the current debounced pass) 4. Original work item is cancelled before it fires. Deletion masked the bug because by the time the user deletes, the flag is already false, so re-setting is a no-op — no recursive trigger. Insert reliably reproduced it on the first keystroke after mount. The system's own continuous spell-check pass is now left enabled. On macOS 15.x it paints on the default fragment (which our custom `MarkdownTextLayoutFragment` replaces), so its `.spellingState` underlines are invisible. Our cache-based drawing in `drawSpellMisspellings` is the sole visible painter. This change is independent of the redraw issue described below; even after removing it, the on-insert disappearance still occurs. --- Part 2: redraw issue (still open) --- Symptom on macOS 15.7.7: - Open a note with misspellings -> underlines drawn correctly on initial layout. - Type any character -> all underlines disappear and never come back until something forces a full layout re-run (panel show/hide, select-all, big scroll, etc.). - Delete a character -> underlines blink and reappear after the 400 ms debounce (works correctly). - The 400 ms-debounced spell-check pipeline itself is verified correct via `[SC]` diagnostic prints (left in for maintainer review): textDidChange -> update was=N now=0 -> schedule -> run -> done -> update was=0 now=N The cache `spellMisspelledRanges` repopulates with the right count after every keystroke. NSSpellChecker returns the same ranges as before the edit. Root-cause hypothesis: TextKit 2 maintains a per-fragment rendering cache. After an insert, only the edited paragraph's fragments redraw on the next display pass; the other fragments containing misspellings reuse their pre-update imagery (no underlines) even though `coordinator.spellMisspelledRanges` is correct. Attempted fixes (all rebuilt and tested — none worked for insert): 1. `textView.needsDisplay = true` (original) -> only edited fragment redraws. 2. `setNeedsDisplay(fragment.layoutFragmentFrame)` per overlapping fragment -> rect goes stale during typing bursts as fragments shift. 3. `tlm.invalidateRenderingAttributes(for: textRange)` per misspelled range -> silently no-ops; no `draw(at:in:)` follows. 4. `tlm.invalidateLayout(for: textRange)` per misspelled range -> only the range overlapping the edited fragment redraws (e.g. {349,7} drawing 54 times in our log); other fragments still reuse cached imagery. In all four attempts, the diagnostic confirms `coordinator.spellMisspelledRanges` is correctly repopulated; the failure is purely in the redraw pipeline. Diagnostics added (kept for maintainer review): - NativeTextViewCoordinator+TextDelegate.swift: `[SC] textDidChange:` entry - NativeTextViewCoordinator+SpellCheck.swift: `[SC] schedule:` (queued / toggle-off-clearing) `[SC] run: start | empty | done rawHits=N kept=N tag=T` `[SC] update: was=N now=N` - MarkdownTextLayoutFragment.swift: `[SC] draw frag={loc,len} cacheCount=N` Repro recipe: 1. Build EdgeMark (or any TextKit 2 host) against this branch on macOS 15.x. 2. Run with Console filtered by `[SC]`. 3. Open a note with several misspellings spread across paragraphs. 4. Insert one character; observe `[SC] update: was=0 now=N` followed by draws covering only the edited fragment (and possibly the prev paragraph from restyle scope), not the fragments holding the other misspellings. Visually all underlines vanish. 5. Select-all (or hide/show side panel). Logs show all relevant fragments redraw; underlines reappear. Open question for engine authors: Is there a TextKit 2 invalidation API that reliably forces fragments overlapping an arbitrary document range to re-execute `draw(at:in:)` without re-laying out (which would clobber the styler's `.spellingState: 0` stamps the styler just put down)? Or is the recommended pattern to drive fragment redraw via `setRenderingAttributes` (and have the spell-check underline live as a rendering attribute rather than custom drawing)? Local SPM dependency note: This branch is being iterated against EdgeMark via a local SPM path (`XCLocalSwiftPackageReference`); no Package.resolved pin needed.
1 parent 083ec31 commit bb10a96

3 files changed

Lines changed: 63 additions & 17 deletions

File tree

Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,7 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment {
628628
guard let fragRange = fragmentNSRange, fragRange.length > 0 else { return }
629629
let fragStart = fragRange.location
630630
let fragEnd = NSMaxRange(fragRange)
631+
print("[SC] draw frag={\(fragStart),\(fragRange.length)} cacheCount=\(coordinator.spellMisspelledRanges.count)")
631632

632633
let theme = textView.configuration.theme
633634
NSGraphicsContext.saveGraphicsState()

Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+SpellCheck.swift

Lines changed: 59 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,16 @@
1111
// `MarkdownTextLayoutFragment.draw(at:in:)`.
1212
//
1313
// Design (see devlog-0610-spellcheck-15x-fallback-design.md):
14-
// - Synchronous `checkSpelling(of:startingAt:)` walk on a background
15-
// queue — deterministic, no unreliable completion handlers.
14+
// - Synchronous `checkSpelling(of:startingAt:)` walk on the main
15+
// thread — deterministic, no background-queue data races.
1616
// - 400 ms debounce. Cache is cleared **synchronously** on every
1717
// `textDidChange` before the next pass is scheduled, so the stale-
1818
// offset bug class never surfaces.
19-
// - System's own continuous-spell-check pass is disabled on 15.x so it
20-
// can't race with the engine's driver (the unreliable underlines that
21-
// sometimes appeared after selection changes were from the system's
22-
// pass, not ours).
19+
// - The system's own continuous spell-check pass is left enabled.
20+
// On macOS 15.x it paints on the default fragment (which our custom
21+
// fragment replaces), so it can't interfere with our cache-based
22+
// drawing. Disabling it was tried and caused a recursive
23+
// textDidChange cascade that cancelled the debounced pass.
2324
// - Reuses existing zone helpers (`isInsideCode`, `isInsideLatex`,
2425
// `isInsideSpellcheckSuppressedToken`) — same gates as
2526
// `NativeTextView+SpellingPolicy`. Belt-and-suspenders with the
@@ -58,16 +59,21 @@ extension NativeTextViewCoordinator {
5859
}
5960
guard userPrefersContinuousSpellChecking else {
6061
// Toggle is off — make sure stale marks aren't left around.
62+
print("[SC] schedule: toggle OFF, clearing")
6163
clearSpellMisspellings(textView: textView)
6264
return
6365
}
64-
// Disable the system's own continuous spell-check pass on 15.x
65-
// so it can't race with the engine's driver. On macOS 15.7.7 the
66-
// system's pass paints `.spellingState` unreliably (sometimes
67-
// after mount, sometimes after selection, rarely after edits),
68-
// producing the inconsistent underlines reported in testing.
69-
// The engine is the sole painter on 15.x.
70-
textView.isContinuousSpellCheckingEnabled = false
66+
print("[SC] schedule: queued, len=\((textView.string as NSString).length)")
67+
// NOTE: the system's own continuous spell-check pass is left
68+
// enabled. On macOS 15.x it paints on the default fragment
69+
// (which our custom MarkdownTextLayoutFragment replaces), so
70+
// its `.spellingState` underlines are invisible. Our cache-
71+
// based drawing in drawSpellMisspellings is the sole visible
72+
// painter. Disabling the system pass via
73+
// `textView.isContinuousSpellCheckingEnabled = false` was tried
74+
// but caused a recursive textDidChange cascade (AppKit clears
75+
// .spellingState attributes synchronously, which triggers a
76+
// delegate call that cancels the debounced pass).
7177
let work = DispatchWorkItem { [weak self, weak textView] in
7278
guard let self, let textView else { return }
7379
self.runSpellCheckPass(textView: textView)
@@ -101,13 +107,16 @@ extension NativeTextViewCoordinator {
101107
func runSpellCheckPass(textView: NSTextView) {
102108
guard #unavailable(macOS 26) else { return }
103109
guard userPrefersContinuousSpellChecking else {
110+
print("[SC] run: toggle OFF")
104111
clearSpellMisspellings(textView: textView)
105112
return
106113
}
107114
let string = textView.string
108115
let ns = string as NSString
109116
let length = ns.length
117+
print("[SC] run: start len=\(length)")
110118
guard length > 0 else {
119+
print("[SC] run: empty doc")
111120
updateSpellMisspelledRanges([], textView: textView)
112121
return
113122
}
@@ -118,6 +127,7 @@ extension NativeTextViewCoordinator {
118127
// Synchronous walk on the main thread — avoids all threading
119128
// issues with NSTextView.string and the parsedDocument cache.
120129
var misspelledRanges: [NSRange] = []
130+
var rawHits = 0
121131
var searchStart = 0
122132
while searchStart < length {
123133
let misspelled = checker.checkSpelling(
@@ -129,11 +139,13 @@ extension NativeTextViewCoordinator {
129139
wordCount: nil
130140
)
131141
guard misspelled.location != NSNotFound else { break }
142+
rawHits += 1
132143
if !shouldSuppressSpellMark(range: misspelled, in: string) {
133144
misspelledRanges.append(misspelled)
134145
}
135146
searchStart = NSMaxRange(misspelled)
136147
}
148+
print("[SC] run: done rawHits=\(rawHits) kept=\(misspelledRanges.count) tag=\(docTag)")
137149
updateSpellMisspelledRanges(misspelledRanges, textView: textView)
138150
}
139151

@@ -145,12 +157,42 @@ extension NativeTextViewCoordinator {
145157
updateSpellMisspelledRanges([], textView: textView)
146158
}
147159

148-
/// Replaces the stored set and asks the text view to redraw. The
149-
/// fragment's `draw(at:in:)` reads from `spellMisspelledRanges` on
150-
/// each repaint, so a simple `needsDisplay = true` is enough — no
151-
/// layout invalidation required.
160+
/// Replaces the stored set and forces every fragment overlapping an
161+
/// affected range to re-execute its draw. `textView.needsDisplay = true`
162+
/// alone is insufficient on macOS 15.x because TextKit 2 maintains
163+
/// per-fragment rendering caches: a paragraph that wasn't restyled or
164+
/// re-laid-out simply re-blits its old imagery, so newly-discovered
165+
/// misspellings outside the edited paragraph never paint until
166+
/// something forces a full re-layout (panel show/hide, selection
167+
/// change, etc.).
168+
///
169+
/// `invalidateRenderingAttributes(for:)` is too soft — the layout
170+
/// manager treats it as a hint and may silently no-op when no
171+
/// rendering-attribute spans actually changed in the range. We saw
172+
/// this in repro logs: the call returned, no `draw(at:in:)` followed,
173+
/// and underlines stayed gone until a selection change forced a
174+
/// fresh display pass.
175+
///
176+
/// `invalidateLayout(for:)` always busts the fragment's drawing
177+
/// cache and re-runs `draw(at:in:)` on the next display tick. Layout
178+
/// re-runs only over the small misspelling ranges, so cost is
179+
/// bounded. We pair it with `textView.needsDisplay = true` so the
180+
/// containing view actually schedules the next display pass.
152181
private func updateSpellMisspelledRanges(_ ranges: [NSRange], textView: NSTextView) {
182+
print("[SC] update: was=\(spellMisspelledRanges.count) now=\(ranges.count)")
183+
let previous = spellMisspelledRanges
153184
spellMisspelledRanges = ranges
185+
186+
guard let tlm = textView.textLayoutManager,
187+
let tcs = tlm.textContentManager as? NSTextContentStorage else {
188+
textView.needsDisplay = true
189+
return
190+
}
191+
192+
for nsRange in (previous + ranges) where nsRange.length > 0 {
193+
guard let textRange = TextStylingService.textRange(from: nsRange, in: tcs) else { continue }
194+
tlm.invalidateLayout(for: textRange)
195+
}
154196
textView.needsDisplay = true
155197
}
156198

Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ extension NativeTextViewCoordinator {
5959
// so no stale-offset underlines paint during the debounce window.
6060
// The next `scheduleSpellCheck` call (at the end of this method)
6161
// will recompute fresh ranges once the user stops typing.
62+
let editedLen = tv.textStorage?.editedRange.length ?? 0
63+
let editedLoc = tv.textStorage?.editedRange.location ?? -1
64+
print("[SC] textDidChange: editedRange={\(editedLoc),\(editedLen)} fullLen=\(fullLength) hasMarked=\(tv.hasMarkedText())")
6265
clearSpellMisspellings(textView: tv)
6366
let safeLocation = min(rawSelRange.location, fullLength)
6467
let safeSelRange = NSRange(location: safeLocation, length: 0)

0 commit comments

Comments
 (0)