mirror of
https://github.com/BigMarc/grammargem.git
synced 2026-10-13 17:01:55 +02:00
- SystemGrammarEngine: on-device spelling + grammar via NSSpellChecker (no deps, no network), augmented with deterministic Harper-style rules. Now the default. - TextFieldScanner: walks the frontmost window's Accessibility tree to find every editable text area (bounded for huge web/Electron trees), not just the focused one. - LiveMonitor now publishes per-field issues across the whole window + fix methods (apply one suggestion, fix a field, fix everything) via AX value writes. - LiveCheckView: a panel listing each text area with errors highlighted inline and one-click fixes; opened from the menu bar (Show live issues / Show issues / Fix all). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
94 lines
4.1 KiB
Swift
94 lines
4.1 KiB
Swift
import Foundation
|
|
import AppKit
|
|
|
|
/// Real, on-device grammar + spelling using macOS's built-in `NSSpellChecker`
|
|
/// (the same engine TextEdit/Mail use). No network, no model — fully local,
|
|
/// matching the privacy promise. Augmented with a few deterministic Harper-style
|
|
/// rules (repeated words, lowercase "i", common expansions) the system checker
|
|
/// doesn't surface.
|
|
final class SystemGrammarEngine: GrammarEngine {
|
|
var ignoreList: Set<String> = []
|
|
|
|
private let checker = NSSpellChecker.shared
|
|
private let tag = NSSpellChecker.uniqueSpellDocumentTag()
|
|
private let extras = HarperEngine()
|
|
|
|
func check(_ text: String) -> [Suggestion] {
|
|
guard text.count >= 2 else { return [] }
|
|
return onMain { self.checkOnMain(text) }
|
|
}
|
|
|
|
private func checkOnMain(_ text: String) -> [Suggestion] {
|
|
let ns = text as NSString
|
|
let full = NSRange(location: 0, length: ns.length)
|
|
let language = checker.language()
|
|
var out: [Suggestion] = []
|
|
|
|
let types = NSTextCheckingResult.CheckingType([.spelling, .grammar]).rawValue
|
|
let results = checker.check(
|
|
text, range: full, types: types, options: nil,
|
|
inSpellDocumentWithTag: tag, orthography: nil, wordCount: nil)
|
|
|
|
for r in results {
|
|
switch r.resultType {
|
|
case .spelling:
|
|
let word = ns.substring(with: r.range)
|
|
if ignoreList.contains(word.lowercased()) { continue }
|
|
let fix = checker.correction(
|
|
forWordRange: r.range, in: text, language: language, inSpellDocumentWithTag: tag)
|
|
?? checker.guesses(
|
|
forWordRange: r.range, in: text, language: language,
|
|
inSpellDocumentWithTag: tag)?.first
|
|
out.append(Suggestion(
|
|
location: r.range.location, length: r.range.length,
|
|
original: word, replacement: fix ?? word,
|
|
kind: .spelling, message: "Possible spelling mistake"))
|
|
|
|
case .grammar:
|
|
let details = r.grammarDetails ?? []
|
|
if details.isEmpty {
|
|
let phrase = ns.substring(with: r.range)
|
|
out.append(Suggestion(
|
|
location: r.range.location, length: r.range.length,
|
|
original: phrase, replacement: phrase,
|
|
kind: .grammar, message: "Grammar issue"))
|
|
} else {
|
|
for d in details {
|
|
var loc = r.range.location, len = r.range.length
|
|
if let rv = d[NSGrammarRange] as? NSValue {
|
|
let sub = rv.rangeValue
|
|
loc = r.range.location + sub.location
|
|
len = sub.length
|
|
}
|
|
guard len > 0, loc + len <= ns.length else { continue }
|
|
let phrase = ns.substring(with: NSRange(location: loc, length: len))
|
|
let message = (d[NSGrammarUserDescription] as? String) ?? "Grammar issue"
|
|
let corrections = (d[NSGrammarCorrections] as? [String]) ?? []
|
|
out.append(Suggestion(
|
|
location: loc, length: len,
|
|
original: phrase, replacement: corrections.first ?? phrase,
|
|
kind: .grammar, message: message))
|
|
}
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
// Deterministic extras, skipping anything overlapping a system result.
|
|
extras.ignoreList = ignoreList
|
|
for h in extras.check(text) where !out.contains(where: { overlaps($0.range, h.range) }) {
|
|
out.append(h)
|
|
}
|
|
return out.sorted { $0.location < $1.location }
|
|
}
|
|
|
|
private func overlaps(_ a: NSRange, _ b: NSRange) -> Bool {
|
|
NSIntersectionRange(a, b).length > 0
|
|
}
|
|
|
|
private func onMain<T>(_ work: @escaping () -> T) -> T {
|
|
Thread.isMainThread ? work() : DispatchQueue.main.sync(execute: work)
|
|
}
|
|
}
|