Skip to content

feat: macOS 15.x spell-check fallback — engine-driven underlines#66

Draft
Ender-Wang wants to merge 6 commits into
nodes-app:mainfrom
Ender-Wang:feature/spellcheck-15x-fallback
Draft

feat: macOS 15.x spell-check fallback — engine-driven underlines#66
Ender-Wang wants to merge 6 commits into
nodes-app:mainfrom
Ender-Wang:feature/spellcheck-15x-fallback

Conversation

@Ender-Wang

@Ender-Wang Ender-Wang commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Close #58

On macOS 15.x the system's own TextKit 2 continuous-spell-check pass neither writes nor paints .spellingState rendering attributes on custom NSTextLayoutFragment subclasses. The harness attached to #59 confirmed this is a system-wide TextKit 2 issue (red=0 on both arms, byte-identical PNGs for vanilla TK2 NSTextView and the engine's custom fragment), not something the subclass causes.

This PR adds a version-gated engine-side fallback:

  • Synchronous main-thread driver: NSSpellChecker.checkSpelling(of:startingAt:…) walked on the main thread with a 400 ms debounce. Replaced the original async requestChecking(of:…) after testing surfaced data races between the completion handler and the AST cache. Scheduled on text change, initial mount, node switch, and toggle-on.
  • Custom painting: drawSpellMisspellings(at:in:) as step 7 of MarkdownTextLayoutFragment.draw(at:in:) — dotted red, RTL-safe, print-excluded, theme-colored.
  • Stale-offset prevention: the misspelling cache is cleared synchronously on every textDidChange BEFORE the debounced pass is scheduled, eliminating the bug class from Fix: Misspelling underlines never appear in the editor #59.
  • No isContinuousSpellCheckingEnabled toggle: the system pass is left enabled. On 15.x it paints on the default fragment (which our custom fragment replaces), so its .spellingState underlines are invisible. Disabling it was tried and caused a recursive textDidChange cascade (AppKit clears .spellingState synchronously → triggers delegate → cancels the debounced pass).
  • Version gate: #unavailable(macOS 26) at coordinator entry points. On 26+ the driver is a no-op (AppKit's native pass handles everything) — no double underlines.
  • System integration: textView.spellCheckerDocumentTag so "Ignore Spelling" works; respects SpellCheckingPolicy toggles from Persist user's spell/grammar toggle across caret movement #36.
  • Zone suppression: filtered through isInsideCode / isInsideLatex / isInsideSpellcheckSuppressedToken + .spellingState: 0 styler stamps on fenced code blocks and inline code spans (completing the convention from feat: stamp .spellingState: 0 on fenced code blocks and inline code #64).

⚠️ Open issue — needs maintainer hint before merge

TextKit 2 fragment redraw on text insert. The 400 ms pipeline itself is verified correct via [SC] diagnostic prints (kept on the bb10a96 tip for review): on every keystroke the cache spellMisspelledRanges is correctly cleared and repopulated by NSSpellChecker. But after an insert, only the edited paragraph's fragments re-execute draw(at:in:). Fragments containing the other misspellings reuse their pre-update cached imagery (no underlines), so all visible underlines vanish until something forces a full layout pass (panel show/hide, select-all, large scroll). Delete works because the engine's restyle scope happens to overlap the misspelling fragments.

Tried four fragment-redraw strategies, all on bb10a96, all fail for the insert path:

# Strategy Result
1 textView.needsDisplay = true only the 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 ({349,7} drew 54× in the log); other fragments still serve cached imagery

Open question for maintainers: Is there a TextKit 2 invalidation API that reliably forces every fragment overlapping an arbitrary document range to re-execute draw(at:in:) without re-laying out (which would clobber the styler's .spellingState: 0 stamps and trigger an unwanted restyle)? Or should spell underlines live as a rendering attribute via setRenderingAttributes(_:for:) rather than custom drawing — letting AppKit own the dirty-tracking? Any guidance on how the rendering-attribute path interacts with MarkdownTextLayoutFragment overrides would be very helpful.

Files

  • Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift — new misspellingUnderlineColor: NSColor (default .systemRed).
  • Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+SpellCheck.swiftnew file. scheduleSpellCheck (400 ms debounce, version gate) + runSpellCheckPass (synchronous main-thread checkSpelling(of:startingAt:…) walk, zone filter). clearSpellMisspellings resets the cache. Currently includes [SC] diagnostic prints + four-strategy updateSpellMisspelledRanges for maintainer review (will be stripped before final merge).
  • Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swiftspellMisspelledRanges cache, spellCheckWorkItem, didToggleSpellCheckingPolicy cancel/schedule hook.
  • Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift — synchronous cache-clear at top of textDidChange, scheduleSpellCheck at end. [SC] textDidChange: diagnostic print at the top.
  • Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swiftdrawSpellMisspellings(at:in:) as draw step 7. [SC] draw frag={loc,len} cacheCount=N diagnostic print.
  • Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift — initial scheduleSpellCheck in makeNSView, re-schedule on node switch in updateNSView.
  • Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift.spellingState: 0 on fenced code blocks and inline code spans.
  • Tests/MarkdownEngineTests/MarkdownASTStylerTests.swiftcodeRegionsSuppressSpellCheck @test.
  • CHANGELOG.md — 3 bullets under [Unreleased]/Added.

Commits

  • 08bcffa feat: macOS 15.x spell-check fallback — engine-driven underlines — initial fallback
  • af2dc56 fix: dispatch spell-check completion to main queue
  • 6ddf1bc chore: exclude .tmp/ from tracking (diagnostic harness artifacts)
  • 3dea6c8 fix: switch to synchronous checkSpelling + disable system pass on 15.x
  • 083ec31 fix: run spell-check pass on main thread to eliminate data races
  • bb10a96 wip: TextKit 2 fragment redraw issue on insert (diagnostics included) — recursive-cascade fix + four invalidation attempts + [SC] diagnostic prints. Latest tip; needs maintainer input on the open issue above.

Test plan

  • swift build — clean.
  • swift test — 70 tests in 7 suites pass, including codeRegionsSuppressSpellCheck.
  • Open a doc with a pre-existing misspelling (paragrahh). A dotted red underline appears within ~400 ms. ✅
  • Toggle "Check Spelling While Typing" off — underlines clear immediately. Toggle on — they return within ~400 ms. ✅
  • Misspellings inside fenced blocks, inline code, $latex$, [link](url), ![[image]] are NOT marked. ✅
  • Switch between two notes — new note's marks appear; previous note's cached ranges don't bleed through. ✅
  • Export to PDF — no underlines in the output. ✅
  • Type a fresh misspelling. After pausing ~400 ms, the cache repopulates with the right range (verified via [SC] log) but the underline doesn't paint until the next layout-forcing event. See the open-issue section above. ❌

Out of scope

  • Per-language hints. checkSpelling(of:startingAt:…) passes language: nil, so NSSpellChecker uses the user's preferred languages.
  • Per-paragraph incremental scans. The pass re-checks the whole document on each debounced trigger. Cheap for typical notes; can be tightened if profiling on very large docs shows it.
  • macOS 26+ behavior. AppKit's native TextKit 2 pass handles underlines there; the engine driver stays a no-op.

Supersedes the render-pass approach in #59 (abandoned after the harness confirmed the issue is system-wide).
Closes #58.

## Summary

- On macOS 15.x the system's own TextKit 2 continuous-spell-check pass
  neither writes nor paints `.spellingState` rendering attributes on
  custom `NSTextLayoutFragment` subclasses (confirmed by the harness
  attached to nodes-app#59: red=0 on both arms, byte-identical PNGs).
  This left `isContinuousSpellCheckingEnabled` silently no-op in the
  editor on 15.x.
- Drive `NSSpellChecker.requestChecking(of:…)` asynchronously on the
  coordinator (400 ms debounce, no main-thread XPC stalls). Hits are
  filtered through the existing zone helpers (`isInsideCode`,
  `isInsideLatex`, `isInsideSpellcheckSuppressedToken`) so code,
  LaTeX, links, and image embeds stay clean.
- Paint dotted-red underlines in
  `MarkdownTextLayoutFragment.draw(at:in:)` as step 7 of the draw
  pipeline. The cache is cleared **synchronously** on every
  `textDidChange` so no stale-offset underlines paint during the
  debounce window.
- Version-gated: `#unavailable(macOS 26)` at the coordinator entry
  points keeps the driver a no-op on 26+ where AppKit's native pass
  works. No double underlines.
- Uses `textView.spellCheckerDocumentTag` (not a private tag) so the
  system "Ignore Spelling" action clears the underline.
- Print/PDF exclusion via `NSPrintOperation.current` — underlines are
  for on-screen editing only.
- RTL-safe: computes absolute x1/x2, swaps when the text direction
  flips the order.
- Theme integration: `MarkdownEditorTheme.misspellingUnderlineColor`
  (default `.systemRed`) so apps with a custom palette can override.
- `MarkdownASTStyler` stamps `.spellingState: 0` on fenced code blocks
  and inline `code` spans, completing the existing suppression
  convention (belt-and-suspenders with the coordinator-side filter).

Behavior toggles from nodes-app#36 (`SpellCheckingPolicy`, context-menu
overrides, `userPrefersContinuousSpellChecking`) are unchanged — this PR
only adds the missing render pass and routes through the same preference.

Supersedes the render-pass portion of nodes-app#59.
NSSpellChecker.requestChecking calls back on NSTextCheckingOperationQueue
(a background queue), not the main thread. The completion handler was
setting textView.needsDisplay = true directly, which:

1. Triggered Main Thread Checker (UI API on background thread)
2. Silently failed to redraw, so underlines never reappeared after edits

Wrap updateSpellMisspelledRanges in DispatchQueue.main.async. The
filtering (pure computation over results) stays on the background queue;
only the cache assignment + needsDisplay hop to main.
@Ender-Wang Ender-Wang marked this pull request as draft June 11, 2026 13:57
Two issues causing underlines to disappear after edits and reappear
inconsistently:

1. requestChecking(of:…) completion handler unreliable on macOS 15.x —
   after the first call, subsequent completions on
   NSTextCheckingOperationQueue sometimes never fire, leaving the cache
   permanently empty after edits. Switch to synchronous
   checkSpelling(of:startingAt:) walk on a background queue — fast for
   typical notes and deterministically returns results.

2. System's own continuous spell-check pass races with the engine's
   driver — on macOS 15.7.7 the system paints .spellingState
   unreliably (sometimes after mount, sometimes after selection, rarely
   after edits), producing the inconsistent underlines seen in testing.
   Disable textView.isContinuousSpellCheckingEnabled when scheduling
   the engine's pass on 15.x so the engine is the sole painter.
Background-queue approaches had two fatal flaws on macOS 15.x:

1. requestChecking(of:…) completion handler sometimes never fired
   after the first call, leaving cache permanently empty.
2. DispatchQueue.global approach read textView.string (not thread-
   safe) and called shouldSuppressSpellMark which accesses
   cachedParsedDocument — a property the main thread mutates during
   restyles. This data race silently produced empty results after
   edits (underlines disappeared and never returned on insert).

Run the entire checkSpelling(of:startingAt:) walk synchronously on
the main thread. For typical notes (hundreds of words, a handful of
misspellings) this takes single-digit milliseconds — well within the
400ms debounce window. Eliminates all threading issues.
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.
@Ender-Wang Ender-Wang force-pushed the feature/spellcheck-15x-fallback branch from 017c9f9 to bb10a96 Compare June 11, 2026 15:11
@Ender-Wang

Ender-Wang commented Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

🐛 Open issue on this branch: TextKit 2 fragment redraw on text insert

Hi @luca-chen198 @Nicolas-Py — before this branch is ready for review, I'm stuck on one rendering issue and would appreciate a hint on the right TextKit 2 invalidation API. The 400 ms-debounced spell-check pipeline itself is fully verified; the failure is purely in the redraw path.

Repro environment: macOS 15.7.7, EdgeMark host app, this branch's MarkdownTextLayoutFragment + NativeTextViewCoordinator+SpellCheck.

Symptom

  • Open a note with several misspellings spread across paragraphs → all underlines drawn correctly on initial layout. ✅
  • Type any one character → all underlines disappear and never come back, until something forces a full layout re-run (panel show/hide, select-all, large scroll). ❌
  • Delete a character → underlines blink and reappear after the 400 ms debounce. ✅
  • Once a "kicker" event happens, underlines reappear at the right ranges; the cache is correct.

Pipeline is verified correct

I added [SC] print diagnostics at every step (kept in bb10a96 for review). On every keystroke the log reads:

[SC] textDidChange: editedRange=… fullLen=N hasMarked=false
[SC] update: was=N now=0          ← cache cleared
[SC] schedule: queued, len=N
[SC] run: start len=N
[SC] run: done rawHits=R kept=K tag=T
[SC] update: was=0 now=K          ← cache repopulated correctly

coordinator.spellMisspelledRanges always ends up with the right count after each insert. NSSpellChecker.checkSpelling(of:startingAt:…) returns the same ranges before and after the edit. So the data side is fine.

What's actually broken

After update: was=0 now=K, only the edited paragraph's fragments re-execute draw(at:in:). Fragments containing the other misspellings reuse their pre-update cached imagery (the imagery from the brief moment when the cache was empty), so all visible underlines vanish. A select-all immediately afterward dumps draws for all 17 fragments and the underlines reappear.

Delete works because the engine's restyle scope happens to overlap more fragments, including the ones holding misspellings.

Strategies I tried (all built, all failed for the insert path)

I rebuilt and tested each one against the same repro:

# Strategy Result
1 textView.needsDisplay = true (original) only the 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} drew 54× in our log); other fragments still serve cached imagery

In all four, [SC] update: was=0 now=K fires with the right count, but only the edited fragment's [SC] draw frag=… lines follow. The other fragments stay quiet until the layout system is forced to do a full pass.

Commits on this branch

  • 08bcffa feat: macOS 15.x spell-check fallback — engine-driven underlines — initial fallback
  • af2dc56 fix: dispatch spell-check completion to main queue
  • 6ddf1bc chore: exclude .tmp/ from tracking (diagnostic harness artifacts)
  • 3dea6c8 fix: switch to synchronous checkSpelling + disable system pass on 15.x
  • 083ec31 fix: run spell-check pass on main thread to eliminate data races
  • bb10a96 wip: TextKit 2 fragment redraw issue on insert (diagnostics included) — folds the recursive-cascade fix (isContinuousSpellCheckingEnabled = false removal) with the four invalidation attempts and the [SC] diagnostic prints. Latest tip.

Open question 🙏

Is there a TextKit 2 invalidation API that reliably forces every fragment overlapping an arbitrary document range to re-execute draw(at:in:), without re-laying out (which would clobber the styler's .spellingState: 0 stamps and trigger an unwanted restyle)?

Or is the recommended pattern to drive spell underlines via setRenderingAttributes(_:for:) rather than custom drawing in draw(at:in:) — letting AppKit own the dirty-tracking? If so, any guidance on how the rendering-attribute path interacts with MarkdownTextLayoutFragment overrides would be very helpful.

Happy to capture more diagnostics or try anything you'd like to see. The full [SC] logs from the four attempts are in the branch's commit message; I can paste raw log excerpts here if useful.

Thanks for the engine — it's been a pleasure to build on. 🙇

@luca-chen198

luca-chen198 commented Jun 14, 2026

Copy link
Copy Markdown
Member

thx for the kind words means a lot. I will definitely take a look in the next days. Sorry for the wair we currently have much going on in the nodes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Misspelling underlines never appear in the editor — TextKit 2 skips the spell-mark pass on a custom NSTextLayoutFragment subclass

2 participants