mirror of
https://github.com/BigMarc/grammargem.git
synced 2026-10-13 17:01:55 +02:00
Real grammar engine + scan every text area on screen
- 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>
This commit is contained in:
parent
e952eb441c
commit
5d1f152fc7
@ -39,11 +39,12 @@ final class AppState: ObservableObject {
|
||||
private var onboardingWindow: NSWindow?
|
||||
private var askWindow: NSWindow?
|
||||
private var mainWindow: NSWindow?
|
||||
private var liveCheckWindow: NSWindow?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private init() {
|
||||
let harper = HarperEngine()
|
||||
grammar = harper
|
||||
let engine = SystemGrammarEngine()
|
||||
grammar = engine
|
||||
ai = MLXEngine(ready: true)
|
||||
permissions = Permissions()
|
||||
model = ModelManager()
|
||||
@ -59,9 +60,9 @@ final class AppState: ObservableObject {
|
||||
snippets = SnippetStore()
|
||||
let excl = Exclusions()
|
||||
exclusions = excl
|
||||
liveMonitor = LiveMonitor(grammar: harper, detector: detector, exclusions: excl, capture: capture)
|
||||
liveMonitor = LiveMonitor(grammar: engine, detector: detector, exclusions: excl, capture: capture)
|
||||
coordinator = TextReplacementCoordinator(
|
||||
capture: capture, grammar: harper, ai: ai, gate: gate, detector: detector)
|
||||
capture: capture, grammar: engine, ai: ai, gate: gate, detector: detector)
|
||||
}
|
||||
|
||||
/// Called once at launch (from the AppDelegate).
|
||||
@ -140,6 +141,27 @@ final class AppState: ObservableObject {
|
||||
liveMonitor.refreshSoon()
|
||||
}
|
||||
|
||||
// MARK: - Live multi-field fixes (from the Live Check panel)
|
||||
|
||||
func fixAllDetected() {
|
||||
if let reason = blockReason() { lastStatus = reason; return }
|
||||
let n = liveMonitor.fixAll()
|
||||
if n > 0 { usage.recordCorrection(words: n) }
|
||||
lastStatus = n > 0 ? "Fixed \(n) issue\(n == 1 ? "" : "s")" : "Nothing to fix"
|
||||
}
|
||||
|
||||
func fixDetectedField(_ field: DetectedField) {
|
||||
if let reason = blockReason() { lastStatus = reason; return }
|
||||
let n = liveMonitor.fix(field)
|
||||
if n > 0 { usage.recordCorrection(words: n) }
|
||||
}
|
||||
|
||||
func applySuggestion(_ suggestion: Suggestion, in field: DetectedField) {
|
||||
if let reason = blockReason() { lastStatus = reason; return }
|
||||
liveMonitor.apply(suggestion, in: field)
|
||||
usage.recordCorrection(words: 1)
|
||||
}
|
||||
|
||||
/// Pause/resume all of GrammaGem (hotkeys + live monitoring).
|
||||
func togglePause() {
|
||||
isPaused.toggle()
|
||||
@ -258,6 +280,27 @@ final class AppState: ObservableObject {
|
||||
mainWindow?.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
|
||||
/// The Live Check panel: every on-screen text area with grammar issues.
|
||||
func showLiveCheck() {
|
||||
if liveCheckWindow == nil {
|
||||
let root = LiveCheckView()
|
||||
.environmentObject(self)
|
||||
.environmentObject(liveMonitor)
|
||||
let host = NSHostingController(rootView: root)
|
||||
let win = NSWindow(contentViewController: host)
|
||||
win.title = "Live Check"
|
||||
win.styleMask = [.titled, .closable, .miniaturizable, .resizable]
|
||||
win.setContentSize(NSSize(width: 560, height: 620))
|
||||
win.minSize = NSSize(width: 460, height: 420)
|
||||
win.isReleasedWhenClosed = false
|
||||
win.center()
|
||||
liveCheckWindow = win
|
||||
}
|
||||
liveMonitor.refreshSoon()
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
liveCheckWindow?.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
|
||||
func showAsk() {
|
||||
askText = ""
|
||||
if askWindow == nil {
|
||||
|
||||
169
Sources/GrammaGem/App/MainWindow/LiveCheckView.swift
Normal file
169
Sources/GrammaGem/App/MainWindow/LiveCheckView.swift
Normal file
@ -0,0 +1,169 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
/// A floating panel that lists every on-screen text area with grammar/spelling
|
||||
/// issues, shows the offending text with the errors highlighted, and offers
|
||||
/// one-click fixes (per issue, per field, or everything).
|
||||
struct LiveCheckView: View {
|
||||
@EnvironmentObject private var app: AppState
|
||||
@ObservedObject private var monitor: LiveMonitor
|
||||
|
||||
init() {
|
||||
_monitor = ObservedObject(wrappedValue: AppState.shared.liveMonitor)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
Divider()
|
||||
if monitor.fields.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(spacing: 14) {
|
||||
ForEach(monitor.fields) { field in
|
||||
FieldCard(field: field)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.tint(GG.emerald)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(summary).font(.headline)
|
||||
Text(monitor.activeAppName.isEmpty
|
||||
? "Checking text areas on screen"
|
||||
: "In \(monitor.activeAppName) · \(monitor.scannedCount) text area\(monitor.scannedCount == 1 ? "" : "s") checked")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button { monitor.refreshSoon() } label: { Image(systemName: "arrow.clockwise") }
|
||||
.help("Re-scan now")
|
||||
if monitor.issueCount > 0 {
|
||||
Button { app.fixAllDetected() } label: {
|
||||
Label("Fix everything", systemImage: "checkmark.circle.fill")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
|
||||
private var summary: String {
|
||||
let issues = monitor.issueCount
|
||||
if issues == 0 { return "No issues found" }
|
||||
return "\(issues) issue\(issues == 1 ? "" : "s") across \(monitor.fields.count) text area\(monitor.fields.count == 1 ? "" : "s")"
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "checkmark.seal.fill").font(.system(size: 42)).foregroundStyle(GG.emerald)
|
||||
Text("Looks clean").font(.title3.weight(.semibold))
|
||||
Text("No grammar or spelling issues in the text on screen right now.")
|
||||
.font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(40)
|
||||
}
|
||||
}
|
||||
|
||||
/// One detected text area: highlighted text + its individual issues.
|
||||
private struct FieldCard: View {
|
||||
@EnvironmentObject private var app: AppState
|
||||
let field: DetectedField
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: field.isFocused ? "cursorarrow.rays" : "text.cursor")
|
||||
.foregroundStyle(field.isFocused ? GG.emerald : .secondary)
|
||||
Text(field.label).font(.subheadline.weight(.semibold)).lineLimit(1)
|
||||
Text(field.roleName).font(.caption).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text("\(field.suggestions.count)")
|
||||
.font(.caption.weight(.semibold))
|
||||
.padding(.horizontal, 7).padding(.vertical, 2)
|
||||
.background(GG.gold.opacity(0.2), in: Capsule())
|
||||
.foregroundStyle(GG.gold)
|
||||
}
|
||||
|
||||
Text(highlighted(field.text, field.suggestions))
|
||||
.font(.callout)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
VStack(spacing: 6) {
|
||||
ForEach(field.suggestions) { s in
|
||||
HStack(spacing: 8) {
|
||||
Circle().fill(color(for: s.kind)).frame(width: 7, height: 7)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(s.message).font(.caption)
|
||||
if s.replacement != s.original && !s.replacement.isEmpty {
|
||||
Text("“\(s.original)” → “\(s.replacement)”")
|
||||
.font(.caption2.monospaced()).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if s.replacement != s.original && !s.replacement.isEmpty {
|
||||
Button("Apply") { app.applySuggestion(s, in: field) }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button { app.fixDetectedField(field) } label: {
|
||||
Label("Fix this field", systemImage: "checkmark.circle")
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.primary.opacity(0.07)))
|
||||
}
|
||||
|
||||
private func color(for kind: GrammarKind) -> Color {
|
||||
switch kind {
|
||||
case .spelling: return .red
|
||||
case .grammar, .punctuation: return .orange
|
||||
default: return GG.gold
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an attributed string with each issue span tinted.
|
||||
private func highlighted(_ text: String, _ suggestions: [Suggestion]) -> AttributedString {
|
||||
let ns = text as NSString
|
||||
let sorted = suggestions.sorted { $0.location < $1.location }
|
||||
var result = AttributedString()
|
||||
var cursor = 0
|
||||
for s in sorted {
|
||||
let loc = s.location, len = s.length
|
||||
guard loc >= cursor, len > 0, loc + len <= ns.length else { continue }
|
||||
if loc > cursor {
|
||||
result += AttributedString(ns.substring(with: NSRange(location: cursor, length: loc - cursor)))
|
||||
}
|
||||
var run = AttributedString(ns.substring(with: NSRange(location: loc, length: len)))
|
||||
let tint: Color = s.kind == .spelling ? .red : .orange
|
||||
run.backgroundColor = tint.opacity(0.18)
|
||||
run.foregroundColor = tint
|
||||
run.underlineStyle = .single
|
||||
result += run
|
||||
cursor = loc + len
|
||||
}
|
||||
if cursor < ns.length {
|
||||
result += AttributedString(ns.substring(with: NSRange(location: cursor, length: ns.length - cursor)))
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@ -57,6 +57,7 @@ struct MenuBarContent: View {
|
||||
|
||||
Divider()
|
||||
|
||||
actionRow(title: "Show live issues…", shortcut: nil) { app.showLiveCheck() }
|
||||
actionRow(title: "Open GrammaGem…", shortcut: nil) { app.showMainWindow() }
|
||||
actionRow(title: "Manage devices…", shortcut: nil) { app.showMainWindow(select: .devices) }
|
||||
actionRow(title: "Page blocker…", shortcut: nil) { app.showMainWindow(select: .exclusions) }
|
||||
@ -102,24 +103,31 @@ struct MenuBarContent: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var liveStatus: some View {
|
||||
let inApp = monitor.activeAppName.isEmpty ? "" : " in \(monitor.activeAppName)"
|
||||
if app.isPaused {
|
||||
Label("Paused — not monitoring", systemImage: "pause.circle")
|
||||
.font(.callout).foregroundStyle(.secondary)
|
||||
} else if monitor.issueCount > 0 {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label("\(monitor.issueCount) suggestion\(monitor.issueCount == 1 ? "" : "s")"
|
||||
+ (monitor.activeAppName.isEmpty ? "" : " in \(monitor.activeAppName)"),
|
||||
systemImage: "text.badge.checkmark")
|
||||
Label("\(monitor.issueCount) issue\(monitor.issueCount == 1 ? "" : "s") in "
|
||||
+ "\(monitor.fields.count) text area\(monitor.fields.count == 1 ? "" : "s")" + inApp,
|
||||
systemImage: "text.badge.xmark")
|
||||
.font(.callout).foregroundStyle(.primary)
|
||||
ForEach(monitor.suggestions.prefix(3)) { s in
|
||||
Text("• \(s.message): “\(s.original)” → “\(s.replacement)”")
|
||||
ForEach(monitor.fields.prefix(2)) { f in
|
||||
Text("• \(f.label): \(f.suggestions.count) issue\(f.suggestions.count == 1 ? "" : "s")")
|
||||
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
Button { Task { await app.fixFocusedField() } } label: {
|
||||
HStack {
|
||||
Button { app.showLiveCheck() } label: {
|
||||
Label("Show issues", systemImage: "list.bullet.rectangle")
|
||||
}
|
||||
.controlSize(.small)
|
||||
Button { app.fixAllDetected() } label: {
|
||||
Label("Fix all", systemImage: "checkmark.circle.fill")
|
||||
}
|
||||
.buttonStyle(.borderedProminent).controlSize(.small)
|
||||
}
|
||||
}
|
||||
} else if monitor.running {
|
||||
Label(monitor.activeAppName.isEmpty ? "Monitoring — looks clean"
|
||||
: "Watching \(monitor.activeAppName) — looks clean",
|
||||
|
||||
93
Sources/GrammaGem/Grammar/SystemGrammarEngine.swift
Normal file
93
Sources/GrammaGem/Grammar/SystemGrammarEngine.swift
Normal file
@ -0,0 +1,93 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -2,17 +2,31 @@ import Foundation
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
|
||||
/// A text area discovered on screen that has at least one grammar/spelling issue.
|
||||
struct DetectedField: Identifiable {
|
||||
let id: String
|
||||
let label: String
|
||||
let roleName: String
|
||||
let text: String
|
||||
let suggestions: [Suggestion]
|
||||
let element: AXUIElement
|
||||
let isFocused: Bool
|
||||
}
|
||||
|
||||
/// The always-on, menu-bar–resident monitor. While enabled, it periodically
|
||||
/// reads the focused text field (Accessibility API), runs the offline grammar
|
||||
/// engine, and publishes live suggestions — so the menu bar reflects issues as
|
||||
/// you type. It stays out of excluded apps/domains and never sends text anywhere.
|
||||
/// scans **every** text area in the frontmost window (Accessibility API), runs
|
||||
/// the offline grammar engine on each, and publishes the ones with issues — so
|
||||
/// GrammaGem reflects problems across the whole screen, not just the focused
|
||||
/// field. It stays out of excluded apps/domains and never sends text anywhere.
|
||||
@MainActor
|
||||
final class LiveMonitor: ObservableObject {
|
||||
@Published private(set) var suggestions: [Suggestion] = []
|
||||
@Published private(set) var activeAppName: String = ""
|
||||
@Published private(set) var fields: [DetectedField] = []
|
||||
@Published private(set) var scannedCount = 0
|
||||
@Published private(set) var activeAppName = ""
|
||||
@Published private(set) var running = false
|
||||
|
||||
var issueCount: Int { suggestions.count }
|
||||
/// Total number of issues across all detected text areas.
|
||||
var issueCount: Int { fields.reduce(0) { $0 + $1.suggestions.count } }
|
||||
|
||||
private let grammar: GrammarEngine
|
||||
private let detector: AppDetector
|
||||
@ -23,7 +37,7 @@ final class LiveMonitor: ObservableObject {
|
||||
private var enabled = false
|
||||
private var paused = false
|
||||
private var lastHash = 0
|
||||
private let interval: TimeInterval = 1.3
|
||||
private let interval: TimeInterval = 1.4
|
||||
|
||||
init(grammar: GrammarEngine, detector: AppDetector, exclusions: Exclusions, capture: TextCapture) {
|
||||
self.grammar = grammar
|
||||
@ -32,24 +46,49 @@ final class LiveMonitor: ObservableObject {
|
||||
self.capture = capture
|
||||
}
|
||||
|
||||
/// Turn live monitoring on/off (driven by the "live underlines" preference).
|
||||
func setEnabled(_ on: Bool) {
|
||||
enabled = on
|
||||
reconcile()
|
||||
func setEnabled(_ on: Bool) { enabled = on; reconcile() }
|
||||
func setPaused(_ p: Bool) { paused = p; reconcile() }
|
||||
|
||||
/// Re-scan now (e.g. after applying a fix).
|
||||
func refreshSoon() { lastHash = 0; tick() }
|
||||
|
||||
// MARK: - Applying fixes
|
||||
|
||||
@discardableResult
|
||||
func fixAll() -> Int {
|
||||
let total = fields.reduce(0) { $0 + applyCorrection(to: $1) }
|
||||
refreshSoon()
|
||||
return total
|
||||
}
|
||||
|
||||
/// Global pause (also stops the hotkeys, handled in AppState).
|
||||
func setPaused(_ p: Bool) {
|
||||
paused = p
|
||||
reconcile()
|
||||
@discardableResult
|
||||
func fix(_ field: DetectedField) -> Int {
|
||||
let n = applyCorrection(to: field)
|
||||
refreshSoon()
|
||||
return n
|
||||
}
|
||||
|
||||
/// Re-run immediately (e.g. after a "fix all").
|
||||
func refreshSoon() {
|
||||
lastHash = 0
|
||||
tick()
|
||||
func apply(_ suggestion: Suggestion, in field: DetectedField) {
|
||||
let ns = NSMutableString(string: field.text)
|
||||
guard suggestion.location + suggestion.length <= ns.length else { return }
|
||||
ns.replaceCharacters(in: suggestion.range, with: suggestion.replacement)
|
||||
setValue(ns as String, on: field.element)
|
||||
refreshSoon()
|
||||
}
|
||||
|
||||
private func applyCorrection(to field: DetectedField) -> Int {
|
||||
let corrected = grammar.correct(field.text)
|
||||
guard corrected != field.text else { return 0 }
|
||||
setValue(corrected, on: field.element)
|
||||
return field.suggestions.count
|
||||
}
|
||||
|
||||
private func setValue(_ text: String, on element: AXUIElement) {
|
||||
AXUIElementSetAttributeValue(element, kAXValueAttribute as CFString, text as CFTypeRef)
|
||||
}
|
||||
|
||||
// MARK: - Scanning
|
||||
|
||||
private func reconcile() {
|
||||
let shouldRun = enabled && !paused
|
||||
if shouldRun && timer == nil {
|
||||
@ -58,7 +97,7 @@ final class LiveMonitor: ObservableObject {
|
||||
let t = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in self?.tick() }
|
||||
}
|
||||
t.tolerance = 0.4
|
||||
t.tolerance = 0.5
|
||||
timer = t
|
||||
} else if !shouldRun {
|
||||
running = false
|
||||
@ -70,29 +109,52 @@ final class LiveMonitor: ObservableObject {
|
||||
|
||||
private func tick() {
|
||||
guard enabled, !paused, AXIsProcessTrusted() else { clear(); return }
|
||||
guard let app = NSWorkspace.shared.frontmostApplication else { clear(); return }
|
||||
activeAppName = app.localizedName ?? ""
|
||||
|
||||
let front = detector.frontmost()
|
||||
activeAppName = front?.name ?? ""
|
||||
|
||||
// Respect the page blocker.
|
||||
if exclusions.isBlocked(bundleID: front?.bundleID, domain: detector.frontmostDomain()) {
|
||||
if exclusions.isBlocked(bundleID: app.bundleIdentifier, domain: detector.frontmostDomain()) {
|
||||
clear()
|
||||
return
|
||||
}
|
||||
|
||||
guard let field = capture.focusedFieldText() else { clear(); return }
|
||||
let text = field.text
|
||||
guard text.count >= 3 else { clear(); return }
|
||||
let focused = systemFocusedElement()
|
||||
let scanned = TextFieldScanner.scan(pid: app.processIdentifier, focused: focused)
|
||||
guard !scanned.isEmpty else { clear(); return }
|
||||
|
||||
let h = text.hashValue
|
||||
if h == lastHash { return } // unchanged since last check
|
||||
// Skip re-checking if nothing on screen changed.
|
||||
var hasher = Hasher()
|
||||
hasher.combine(app.bundleIdentifier ?? "")
|
||||
for f in scanned { hasher.combine(f.text) }
|
||||
let h = hasher.finalize()
|
||||
if h == lastHash { return }
|
||||
lastHash = h
|
||||
|
||||
suggestions = grammar.check(text)
|
||||
scannedCount = scanned.count
|
||||
var detected: [DetectedField] = []
|
||||
for (i, f) in scanned.enumerated() {
|
||||
let suggestions = grammar.check(f.text)
|
||||
guard !suggestions.isEmpty else { continue }
|
||||
detected.append(DetectedField(
|
||||
id: "\(f.roleName)#\(i)", label: f.label, roleName: f.roleName,
|
||||
text: f.text, suggestions: suggestions, element: f.element, isFocused: f.isFocused))
|
||||
}
|
||||
// Focused field first.
|
||||
detected.sort { ($0.isFocused ? 0 : 1) < ($1.isFocused ? 0 : 1) }
|
||||
fields = detected
|
||||
}
|
||||
|
||||
private func systemFocusedElement() -> AXUIElement? {
|
||||
let system = AXUIElementCreateSystemWide()
|
||||
var ref: CFTypeRef?
|
||||
guard AXUIElementCopyAttributeValue(
|
||||
system, kAXFocusedUIElementAttribute as CFString, &ref) == .success, let ref
|
||||
else { return nil }
|
||||
return (ref as! AXUIElement)
|
||||
}
|
||||
|
||||
private func clear() {
|
||||
if !suggestions.isEmpty { suggestions = [] }
|
||||
if !fields.isEmpty { fields = [] }
|
||||
scannedCount = 0
|
||||
lastHash = 0
|
||||
}
|
||||
}
|
||||
|
||||
91
Sources/GrammaGem/System/TextFieldScanner.swift
Normal file
91
Sources/GrammaGem/System/TextFieldScanner.swift
Normal file
@ -0,0 +1,91 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
|
||||
/// A text element discovered in the frontmost window via the Accessibility tree.
|
||||
struct ScannedField {
|
||||
let element: AXUIElement
|
||||
let roleName: String
|
||||
let label: String
|
||||
let text: String
|
||||
let isFocused: Bool
|
||||
}
|
||||
|
||||
/// Walks the Accessibility tree of the frontmost window and collects every
|
||||
/// editable text element (text fields, text areas, combo/search boxes) with
|
||||
/// content — so GrammaGem can check *all* the text on screen, not just the one
|
||||
/// the cursor is in. Bounded so huge web/Electron trees can't stall the app.
|
||||
enum TextFieldScanner {
|
||||
static func scan(
|
||||
pid: pid_t, focused: AXUIElement?,
|
||||
maxFields: Int = 30, maxVisited: Int = 4000
|
||||
) -> [ScannedField] {
|
||||
let axApp = AXUIElementCreateApplication(pid)
|
||||
guard let root = window(of: axApp) ?? optional(axApp) else { return [] }
|
||||
var out: [ScannedField] = []
|
||||
var visited = 0
|
||||
walk(root, focused: focused, depth: 0, out: &out, visited: &visited,
|
||||
maxFields: maxFields, maxVisited: maxVisited)
|
||||
return out
|
||||
}
|
||||
|
||||
private static func optional(_ el: AXUIElement) -> AXUIElement? { el }
|
||||
|
||||
private static func window(of axApp: AXUIElement) -> AXUIElement? {
|
||||
for attr in [kAXFocusedWindowAttribute, kAXMainWindowAttribute] {
|
||||
var ref: CFTypeRef?
|
||||
if AXUIElementCopyAttributeValue(axApp, attr as CFString, &ref) == .success,
|
||||
let ref { return (ref as! AXUIElement) }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func walk(
|
||||
_ el: AXUIElement, focused: AXUIElement?, depth: Int,
|
||||
out: inout [ScannedField], visited: inout Int, maxFields: Int, maxVisited: Int
|
||||
) {
|
||||
if out.count >= maxFields || visited >= maxVisited || depth > 70 { return }
|
||||
visited += 1
|
||||
|
||||
let role = string(el, kAXRoleAttribute) ?? ""
|
||||
if role == kAXTextFieldRole as String
|
||||
|| role == kAXTextAreaRole as String
|
||||
|| role == kAXComboBoxRole as String {
|
||||
if let value = string(el, kAXValueAttribute),
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines).count >= 3 {
|
||||
let label = string(el, kAXTitleAttribute)
|
||||
?? string(el, kAXDescriptionAttribute)
|
||||
?? string(el, kAXPlaceholderValueAttribute)
|
||||
?? roleLabel(role)
|
||||
let isFocused = focused != nil && CFEqual(el, focused!)
|
||||
out.append(ScannedField(
|
||||
element: el, roleName: roleLabel(role),
|
||||
label: label, text: value, isFocused: isFocused))
|
||||
}
|
||||
}
|
||||
|
||||
var childrenRef: CFTypeRef?
|
||||
if AXUIElementCopyAttributeValue(el, kAXChildrenAttribute as CFString, &childrenRef) == .success,
|
||||
let kids = childrenRef as? [AXUIElement] {
|
||||
for k in kids {
|
||||
if out.count >= maxFields || visited >= maxVisited { break }
|
||||
walk(k, focused: focused, depth: depth + 1, out: &out,
|
||||
visited: &visited, maxFields: maxFields, maxVisited: maxVisited)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func string(_ el: AXUIElement, _ attr: String) -> String? {
|
||||
var ref: CFTypeRef?
|
||||
guard AXUIElementCopyAttributeValue(el, attr as CFString, &ref) == .success else { return nil }
|
||||
return ref as? String
|
||||
}
|
||||
|
||||
private static func roleLabel(_ role: String) -> String {
|
||||
switch role {
|
||||
case kAXTextAreaRole as String: return "Text area"
|
||||
case kAXComboBoxRole as String: return "Combo box"
|
||||
default: return "Text field"
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user