Skip to content

Commit af2dc56

Browse files
committed
fix: dispatch spell-check completion to main queue
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.
1 parent 08bcffa commit af2dc56

3 files changed

Lines changed: 129 additions & 1 deletion

File tree

.tmp/commit-msg.txt

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
feat: stamp .spellingState: 0 on fenced code blocks and inline `code`
2+
3+
Code is not prose. Fenced blocks and inline `code` spans should be left
4+
alone by the system spell-checker, the same way links, wiki-links, LaTeX,
5+
and table cells already are. This completes the engine's existing
6+
spell-check suppression convention with two minimal styler hunks.
7+
8+
- MarkdownASTStyler.styleCodeBlock: append .spellingState: 0 on the
9+
whole fenced codeRange (alongside the existing .font/.background/
10+
.paragraphStyle stamp).
11+
- MarkdownASTStyler inline .code case: append .spellingState: 0 on the
12+
full inline span (markers + content), so the suppression covers the
13+
backticks too.
14+
- MarkdownASTStylerTests: new @Test asserting .spellingState: 0 is
15+
present on a fenced-block content range and on an inline `code` span,
16+
and that plain prose carries no .spellingState attribute.
17+
- CHANGELOG: Unreleased / Added entry framed as completing the existing
18+
suppression convention.
19+
20+
This is the styler portion split out of #59 per review feedback. The
21+
render-pass approach in that PR is being abandoned — the harness
22+
attached to #59 confirms the underline absence on macOS 15.7.7 is a
23+
system-wide TextKit 2 issue, not something the custom NSTextLayoutFragment
24+
subclass causes.
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Two-arm test:
2+
// 1. Does AppKit's continuous spell-check pass WRITE .spellingState to the
3+
// NSTextLayoutManager on macOS 15.7.7? We read renderingAttributes back.
4+
// 2. If we MANUALLY write .spellingState via addRenderingAttribute, does
5+
// the underline appear? This is the viability test for EdgeMark's
6+
// Option A (manual driver) regardless of question 1.
7+
import AppKit
8+
9+
let app = NSApplication.shared
10+
app.setActivationPolicy(.accessory)
11+
12+
func analyze(_ rep: NSBitmapImageRep, label: String) {
13+
var red = 0
14+
for y in 0..<rep.pixelsHigh {
15+
for x in 0..<rep.pixelsWide {
16+
guard let c = rep.colorAt(x: x, y: y)?.usingColorSpace(.sRGB) else { continue }
17+
let r = c.redComponent, g = c.greenComponent, b = c.blueComponent
18+
if r > 0.55 && g < 0.5 && b < 0.5 { red += 1 }
19+
}
20+
}
21+
print("\(label): red=\(red)")
22+
}
23+
24+
func countSystemSpellingStates(_ tlm: NSTextLayoutManager, label: String) {
25+
let documentRange = tlm.documentRange
26+
var states: [(NSRange, Int)] = []
27+
tlm.enumerateRenderingAttributes(from: documentRange.location, reverse: false) { (tlm2, attrs, range) -> Bool in
28+
if let s = attrs[.spellingState] as? Int {
29+
let loc = tlm2.offset(from: documentRange.location, to: range.location)
30+
let len = tlm2.offset(from: range.location, to: range.endLocation)
31+
let nsRange = NSRange(location: loc, length: len)
32+
states.append((nsRange, s))
33+
}
34+
return true
35+
}
36+
print("\(label): system-wrote .spellingState entries = \(states.count)")
37+
for (r, v) in states { print(" range=\(r.location)+\(r.length) value=\(v)") }
38+
}
39+
40+
func run(label: String, insertManual: Bool) {
41+
let storage = NSTextContentStorage()
42+
let tlm = NSTextLayoutManager()
43+
let container = NSTextContainer(size: CGSize(width: 400, height: 200))
44+
tlm.textContainer = container
45+
storage.addTextLayoutManager(tlm)
46+
47+
let tv = NSTextView(frame: NSRect(x: 0, y: 0, width: 400, height: 200), textContainer: container)
48+
tv.isEditable = true
49+
tv.isContinuousSpellCheckingEnabled = true
50+
tv.backgroundColor = .white
51+
tv.textColor = .black
52+
tv.font = NSFont.systemFont(ofSize: 16)
53+
54+
let window = NSWindow(contentRect: NSRect(x: 120, y: 120, width: 400, height: 200),
55+
styleMask: [.titled], backing: .buffered, defer: false)
56+
window.contentView = tv
57+
window.makeKeyAndOrderFront(nil)
58+
window.makeFirstResponder(tv)
59+
tv.insertText("helllo wrold misspeled", replacementRange: NSRange(location: 0, length: 0))
60+
61+
// Give continuous spell-check time to run.
62+
RunLoop.current.run(until: Date().addingTimeInterval(3.0))
63+
tv.needsDisplay = true
64+
tv.displayIfNeeded()
65+
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
66+
67+
// Question 1: did the system write .spellingState?
68+
countSystemSpellingStates(tlm, label: "\(label) [after system pass]")
69+
70+
// Question 2: manually write .spellingState on the first misspelled word.
71+
if insertManual {
72+
let docRange = tlm.documentRange
73+
// "helllo" is at offset 0, length 6.
74+
let endLoc = tlm.location(docRange.location, offsetBy: 6)!
75+
let manualRange = NSTextRange(location: docRange.location, end: endLoc)!
76+
tlm.addRenderingAttribute(.spellingState, value: 1, for: manualRange)
77+
print("\(label): manually wrote .spellingState:1 on 0..<6")
78+
}
79+
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
80+
tv.needsDisplay = true
81+
tv.displayIfNeeded()
82+
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
83+
84+
guard let rep = tv.bitmapImageRepForCachingDisplay(in: tv.bounds) else {
85+
print("\(label): no bitmap")
86+
window.orderOut(nil)
87+
return
88+
}
89+
tv.cacheDisplay(in: tv.bounds, to: rep)
90+
analyze(rep, label: label)
91+
if let png = rep.representation(using: .png, properties: [:]) {
92+
try? png.write(to: URL(fileURLWithPath: "/tmp/spellcheck_\(label).png"))
93+
}
94+
window.orderOut(nil)
95+
}
96+
97+
// Arm A: system pass only (question 1 only)
98+
run(label: "A_systemOnly", insertManual: false)
99+
// Arm B: system pass + manual write (questions 1 + 2)
100+
run(label: "B_withManual", insertManual: true)

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,11 @@ extension NativeTextViewCoordinator {
101101
? nil
102102
: result.range
103103
}
104-
self.updateSpellMisspelledRanges(filtered, textView: textView)
104+
// NSSpellChecker calls back on NSTextCheckingOperationQueue
105+
// (a background queue). Hop to main before touching UI.
106+
DispatchQueue.main.async {
107+
self.updateSpellMisspelledRanges(filtered, textView: textView)
108+
}
105109
}
106110
)
107111
}

0 commit comments

Comments
 (0)