Skip to content

Commit 08bcffa

Browse files
committed
feat: macOS 15.x spell-check fallback — engine-driven underlines
## 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 #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 #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 #59.
1 parent b6dd7e6 commit 08bcffa

9 files changed

Lines changed: 351 additions & 1 deletion

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2121
```
2222

2323
### Added
24+
- `MarkdownEditorTheme.misspellingUnderlineColor` (default `.systemRed`),
25+
routed through the engine's macOS 15.x spell-check fallback so custom
26+
palettes control the dotted-red underline color.
27+
- macOS 15.x spell-check fallback. On macOS 15.x the system's own
28+
TextKit 2 pass neither writes nor paints `.spellingState` rendering
29+
attributes on custom `NSTextLayoutFragment` subclasses. The engine now
30+
drives `NSSpellChecker.requestChecking(of:…)` itself (400 ms debounce,
31+
async, no main-thread stalls) and paints dotted-red underlines in
32+
`MarkdownTextLayoutFragment.draw(at:in:)`. The cache is cleared
33+
synchronously on every `textDidChange` so no stale-offset underlines
34+
paint during the debounce window. On macOS 26+ the fallback is a
35+
no-op (AppKit's native pass handles everything). Uses
36+
`textView.spellCheckerDocumentTag` so "Ignore Spelling" works,
37+
respects the existing `SpellCheckingPolicy` toggles from #36, and
38+
excludes underlines from print/PDF output via `NSPrintOperation.current`.
39+
- `MarkdownASTStyler` now stamps `.spellingState: 0` on fenced code
40+
blocks and inline `code` spans, completing the engine's existing
41+
spell-check suppression convention (links, wiki-links, LaTeX, and
42+
tables already carry the same attribute). Code regions stay clean
43+
under both the system's native pass (macOS 26+) and the engine's
44+
own 15.x fallback driver.
2445
- `SafeAreaInsets` struct exposing `top` / `leading` / `trailing` / `bottom`
2546
inset knobs for the editor's enclosing scroll view, configurable via
2647
`MarkdownEditorConfiguration.safeAreaInsets`.

Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,14 @@ public struct MarkdownEditorTheme: Sendable {
7474
/// (e.g. completed task list items, horizontal rules).
7575
public var strikethroughColor: NSColor
7676

77+
/// Stroke color used for misspelling underlines drawn by the engine's
78+
/// macOS 15.x fallback (when AppKit's own TextKit 2 pass does not
79+
/// paint `.spellingState` rendering attributes). Defaults to
80+
/// `.systemRed` to match the system's native misspelling marks; apps
81+
/// with a custom palette should override this alongside the rest of
82+
/// the theme.
83+
public var misspellingUnderlineColor: NSColor
84+
7785
// MARK: Init
7886

7987
public init(
@@ -87,7 +95,8 @@ public struct MarkdownEditorTheme: Sendable {
8795
findCurrentMatchHighlight: NSColor = .systemYellow,
8896
latexLightModeText: NSColor = .black,
8997
latexDarkModeText: NSColor = .white,
90-
strikethroughColor: NSColor = .labelColor
98+
strikethroughColor: NSColor = .labelColor,
99+
misspellingUnderlineColor: NSColor = .systemRed
91100
) {
92101
self.bodyText = bodyText
93102
self.mutedText = mutedText
@@ -100,6 +109,7 @@ public struct MarkdownEditorTheme: Sendable {
100109
self.latexLightModeText = latexLightModeText
101110
self.latexDarkModeText = latexDarkModeText
102111
self.strikethroughColor = strikethroughColor
112+
self.misspellingUnderlineColor = misspellingUnderlineColor
103113
}
104114

105115
/// System-native palette built from `NSColor` dynamic system colors.

Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,11 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment {
9494

9595
// 6. Blockquote bars (left gutter, behind nothing — text is indented)
9696
drawBlockquoteBars(at: point, in: context)
97+
98+
// 7. Spell-check underlines (macOS 15.x fallback). On macOS 26+
99+
// AppKit's own TextKit 2 pass paints `.spellingState` via the
100+
// default fragment; the engine's driver is a no-op there.
101+
drawSpellMisspellings(at: point, in: context)
97102
}
98103

99104
// MARK: - Helpers
@@ -589,6 +594,89 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment {
589594
}
590595
}
591596
}
597+
598+
// MARK: - Spell-check underlines (macOS 15.x fallback)
599+
600+
/// Paint dotted red underlines beneath every misspelled range that
601+
/// intersects this fragment. Reads the cache populated by
602+
/// `NativeTextViewCoordinator+SpellCheck` (see
603+
/// `scheduleSpellCheck` / `runSpellCheckPass`).
604+
///
605+
/// Required on macOS 15.x: the system's own continuous-spell-check
606+
/// pass neither writes nor paints `.spellingState` rendering
607+
/// attributes on TextKit 2 custom fragments (confirmed by
608+
/// `spellcheck-pipeline-test.swift` in the fork's `.tmp/`). On
609+
/// macOS 26+ AppKit handles this natively and the cache stays empty.
610+
///
611+
/// Anti-pattern fixes vs. PR #59:
612+
/// - Routes color through `MarkdownEditorTheme.misspellingUnderlineColor`.
613+
/// - RTL-safe: computes absolute `x1`/`x2` and swaps when needed.
614+
/// - Skips when the current graphics context is for print/PDF so
615+
/// misspelling marks don't bleed into exported documents.
616+
/// - No version gate here: on 26+ the cache is empty because the
617+
/// engine's `scheduleSpellCheck` driver is gated in the coordinator.
618+
private func drawSpellMisspellings(at point: CGPoint, in context: CGContext) {
619+
// Print/PDF exclusion: underlines are for on-screen editing only.
620+
// `NSPrintOperation.current` is set while the fragment is being
621+
// drawn into a print/PDF context; screen drawing leaves it nil.
622+
if NSPrintOperation.current != nil { return }
623+
// On macOS 26+ the coordinator keeps this empty.
624+
guard let textView = textLayoutManager?.textContainer?.textView as? NativeTextView,
625+
let coordinator = textView.delegate as? NativeTextViewCoordinator,
626+
!coordinator.spellMisspelledRanges.isEmpty
627+
else { return }
628+
guard let fragRange = fragmentNSRange, fragRange.length > 0 else { return }
629+
let fragStart = fragRange.location
630+
let fragEnd = NSMaxRange(fragRange)
631+
632+
let theme = textView.configuration.theme
633+
NSGraphicsContext.saveGraphicsState()
634+
defer { NSGraphicsContext.restoreGraphicsState() }
635+
let nsContext = NSGraphicsContext(cgContext: context, flipped: true)
636+
NSGraphicsContext.current = nsContext
637+
638+
theme.misspellingUnderlineColor.withAlphaComponent(0.7).setStroke()
639+
let path = NSBezierPath()
640+
path.lineWidth = 1.5
641+
path.lineCapStyle = .round
642+
path.setLineDash([1.5, 1.5], count: 2, phase: 0)
643+
644+
for misspelled in coordinator.spellMisspelledRanges {
645+
let mStart = misspelled.location
646+
let mEnd = NSMaxRange(misspelled)
647+
if mEnd <= fragStart || mStart >= fragEnd { continue }
648+
let docLo = max(mStart, fragStart)
649+
let docHi = min(mEnd, fragEnd)
650+
let localLo = docLo - fragStart
651+
let localHi = docHi - fragStart
652+
653+
for lineFragment in textLineFragments {
654+
let lr = lineFragment.characterRange
655+
let lrLo = lr.location
656+
let lrHi = lr.location + lr.length
657+
let lo = max(localLo, lrLo)
658+
let hi = min(localHi, lrHi)
659+
guard lo < hi else { continue }
660+
661+
let startPos = lineFragment.locationForCharacter(at: lo)
662+
let endPos = lineFragment.locationForCharacter(at: hi)
663+
let tb = lineFragment.typographicBounds
664+
var x1 = point.x + tb.origin.x + startPos.x
665+
var x2 = point.x + tb.origin.x + endPos.x
666+
// RTL-safe: swap if the text direction flipped the order.
667+
if x2 < x1 { swap(&x1, &x2) }
668+
guard x1 != x2 else { continue }
669+
// baselineY = line-top + baseline offset within the line.
670+
let baselineY = point.y + tb.origin.y + startPos.y
671+
// Place the underline ~1.5pt below the baseline, matching
672+
// where AppKit's own misspelling marks typically sit.
673+
let y = baselineY + 1.5
674+
path.move(to: NSPoint(x: x1, y: y))
675+
path.line(to: NSPoint(x: x2, y: y))
676+
}
677+
}
678+
path.stroke()
679+
}
592680
}
593681

594682
// MARK: - Layout Manager Delegate

Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,10 @@ enum MarkdownASTStyler {
367367
attrs.append((parts.codeRange, [
368368
.font: ctx.codeFont, .backgroundColor: ctx.codeBackground, .paragraphStyle: ctx.codeParagraphStyle,
369369
]))
370+
// Suppress spell-check underlines on the whole fenced block —
371+
// code is not prose. Completes the existing link / wiki-link
372+
// convention (see `styleLink` / `styleWikiLink`).
373+
attrs.append((parts.codeRange, [.spellingState: 0]))
370374
let codeContent = ctx.ns.substring(with: parts.content)
371375
if !codeContent.isEmpty,
372376
let highlighted = ctx.config.services.syntaxHighlighter.highlight(code: codeContent, language: parts.language) {
@@ -431,6 +435,10 @@ enum MarkdownASTStyler {
431435

432436
case .code(let range, let contentRange):
433437
attrs.append((contentRange, [.font: ctx.codeFont, .backgroundColor: ctx.codeBackground]))
438+
// Suppress spell-check underlines on inline `code` spans
439+
// (markers + content) — same convention as `styleLink` /
440+
// `styleWikiLink` / `styleTable` / `styleLatex`.
441+
attrs.append((range, [.spellingState: 0]))
434442
let markerAttrs: [NSAttributedString.Key: Any] = ctx.isActive(range)
435443
? [.foregroundColor: ctx.theme.mutedText, .font: ctx.codeFont]
436444
: [.foregroundColor: ctx.theme.mutedText.withAlphaComponent(ctx.config.markers.inlineCodeMarkerAlpha),
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
//
2+
// NativeTextViewCoordinator+SpellCheck.swift
3+
// MarkdownEngine
4+
//
5+
// macOS 15.x fallback spell-check driver. On macOS 26+ AppKit's own
6+
// TextKit 2 pass paints `.spellingState` underlines via the default
7+
// fragment. On 15.x that path is broken (the system neither writes nor
8+
// renders `.spellingState` on custom fragments), so the engine drives
9+
// `NSSpellChecker` itself and paints dotted-red underlines from the
10+
// coordinator's misspelled-range cache in
11+
// `MarkdownTextLayoutFragment.draw(at:in:)`.
12+
//
13+
// Design (see devlog-0610-spellcheck-15x-fallback-design.md):
14+
// - Async `NSSpellChecker.requestChecking(of:…)` — no main-thread XPC.
15+
// - 400 ms debounce. Cache is cleared **synchronously** on every
16+
// `textDidChange` before the next pass is scheduled, so the stale-
17+
// offset bug class never surfaces.
18+
// - Reuses existing zone helpers (`isInsideCode`, `isInsideLatex`,
19+
// `isInsideSpellcheckSuppressedToken`) — same gates as
20+
// `NativeTextView+SpellingPolicy`. Belt-and-suspenders with the
21+
// `.spellingState: 0` stamps added by PR #64.
22+
// - Uses `textView.spellCheckerDocumentTag` (not a private tag) so
23+
// the system "Ignore Spelling" action clears the underline.
24+
// - `didToggleSpellCheckingPolicy` cancels the pending pass on
25+
// toggle-off and schedules an immediate pass on toggle-on.
26+
//
27+
28+
import AppKit
29+
30+
extension NativeTextViewCoordinator {
31+
/// Debounce window between the last edit and the spell-check pass.
32+
/// Long enough to avoid stalling fast typists, short enough that the
33+
/// underline appears almost as soon as the word boundary is crossed.
34+
private static let spellCheckDebounce: TimeInterval = 0.4
35+
36+
/// Schedule a (debounced) spell-check pass over the entire document.
37+
/// Subsequent calls within the debounce window cancel the previous
38+
/// scheduled pass.
39+
///
40+
/// The coordinator's `spellMisspelledRanges` cache is assumed to have
41+
/// been cleared by the caller (typically `textDidChange`) BEFORE this
42+
/// method is invoked — that's what prevents stale-offset underlines
43+
/// from painting during the debounce window.
44+
func scheduleSpellCheck(textView: NSTextView) {
45+
spellCheckWorkItem?.cancel()
46+
// On macOS 26+ AppKit's own TextKit 2 pass paints the underline
47+
// via the default fragment. Running the engine driver here would
48+
// produce double underlines (the system pass + our cache-based
49+
// fragment pass).
50+
guard #unavailable(macOS 26) else {
51+
spellMisspelledRanges.removeAll()
52+
return
53+
}
54+
guard userPrefersContinuousSpellChecking else {
55+
// Toggle is off — make sure stale marks aren't left around.
56+
clearSpellMisspellings(textView: textView)
57+
return
58+
}
59+
let work = DispatchWorkItem { [weak self, weak textView] in
60+
guard let self, let textView else { return }
61+
self.runSpellCheckPass(textView: textView)
62+
}
63+
spellCheckWorkItem = work
64+
DispatchQueue.main.asyncAfter(
65+
deadline: .now() + Self.spellCheckDebounce,
66+
execute: work
67+
)
68+
}
69+
70+
/// Run an async full-document scan via `NSSpellChecker.requestChecking`.
71+
/// Filters results against the engine's existing zone helpers so code,
72+
/// LaTeX, links, and image embeds never end up underlined.
73+
func runSpellCheckPass(textView: NSTextView) {
74+
guard #unavailable(macOS 26) else { return }
75+
guard userPrefersContinuousSpellChecking else {
76+
clearSpellMisspellings(textView: textView)
77+
return
78+
}
79+
let string = textView.string
80+
let length = (string as NSString).length
81+
guard length > 0 else {
82+
updateSpellMisspelledRanges([], textView: textView)
83+
return
84+
}
85+
86+
let checker = NSSpellChecker.shared
87+
let docTag = textView.spellCheckerDocumentTag
88+
let wholeDocument = NSRange(location: 0, length: length)
89+
90+
checker.requestChecking(
91+
of: string,
92+
range: wholeDocument,
93+
types: NSTextCheckingTypes(NSTextCheckingResult.CheckingType.spelling.rawValue),
94+
options: [:],
95+
inSpellDocumentWithTag: docTag,
96+
completionHandler: { [weak self, weak textView] (_ seq: Int, results: [NSTextCheckingResult], _ orthography: NSOrthography, _ wordCount: Int) in
97+
guard let self, let textView else { return }
98+
let filtered = results.compactMap { result -> NSRange? in
99+
guard result.resultType == .spelling else { return nil }
100+
return self.shouldSuppressSpellMark(range: result.range, in: string)
101+
? nil
102+
: result.range
103+
}
104+
self.updateSpellMisspelledRanges(filtered, textView: textView)
105+
}
106+
)
107+
}
108+
109+
/// Empties the misspelling set and triggers a redraw so any leftover
110+
/// underlines disappear immediately. Called synchronously from
111+
/// `textDidChange` and from `didToggleSpellCheckingPolicy` (off).
112+
func clearSpellMisspellings(textView: NSTextView) {
113+
guard !spellMisspelledRanges.isEmpty else { return }
114+
updateSpellMisspelledRanges([], textView: textView)
115+
}
116+
117+
/// Replaces the stored set and asks the text view to redraw. The
118+
/// fragment's `draw(at:in:)` reads from `spellMisspelledRanges` on
119+
/// each repaint, so a simple `needsDisplay = true` is enough — no
120+
/// layout invalidation required.
121+
private func updateSpellMisspelledRanges(_ ranges: [NSRange], textView: NSTextView) {
122+
spellMisspelledRanges = ranges
123+
textView.needsDisplay = true
124+
}
125+
126+
/// True when the candidate misspelling falls inside a zone where the
127+
/// engine deliberately disables spell marks (code, LaTeX, link, embed).
128+
/// Same gates as `NativeTextView+SpellingPolicy`.
129+
private func shouldSuppressSpellMark(range: NSRange, in text: String) -> Bool {
130+
if text.contains("`"), isInsideCode(range: range, in: text) {
131+
return true
132+
}
133+
if text.contains("$"), isInsideLatex(location: range.location, in: text) {
134+
return true
135+
}
136+
return isInsideSpellcheckSuppressedToken(range: range, in: text)
137+
}
138+
}

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ extension NativeTextViewCoordinator {
5555
let rawSelRange = tv.selectedRange()
5656
let fullLength = (tv.string as NSString).length
5757
guard !tv.hasMarkedText() else { return }
58+
// Spell-check fallback: synchronously clear the misspelling cache
59+
// so no stale-offset underlines paint during the debounce window.
60+
// The next `scheduleSpellCheck` call (at the end of this method)
61+
// will recompute fresh ranges once the user stops typing.
62+
clearSpellMisspellings(textView: tv)
5863
let safeLocation = min(rawSelRange.location, fullLength)
5964
let safeSelRange = NSRange(location: safeLocation, length: 0)
6065
previousCaretLocation = safeSelRange.location
@@ -155,6 +160,11 @@ extension NativeTextViewCoordinator {
155160
bottomTextView.recalcOverscroll(for: scrollView, debugTag: "textDidChange")
156161
(scrollView as? ClampedScrollView)?.clampToInsets()
157162
}
163+
// Spell-check fallback: kick off the debounced scan now that the
164+
// edit has settled. The cache is already empty from the top of
165+
// this method, so no stale underlines can paint in the 400 ms
166+
// window before the async pass completes.
167+
scheduleSpellCheck(textView: tv)
158168
previousActiveTokenIndices = activeTokenIndices
159169
}
160170

0 commit comments

Comments
 (0)