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 onboardingWindow: NSWindow?
|
||||||
private var askWindow: NSWindow?
|
private var askWindow: NSWindow?
|
||||||
private var mainWindow: NSWindow?
|
private var mainWindow: NSWindow?
|
||||||
|
private var liveCheckWindow: NSWindow?
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
let harper = HarperEngine()
|
let engine = SystemGrammarEngine()
|
||||||
grammar = harper
|
grammar = engine
|
||||||
ai = MLXEngine(ready: true)
|
ai = MLXEngine(ready: true)
|
||||||
permissions = Permissions()
|
permissions = Permissions()
|
||||||
model = ModelManager()
|
model = ModelManager()
|
||||||
@ -59,9 +60,9 @@ final class AppState: ObservableObject {
|
|||||||
snippets = SnippetStore()
|
snippets = SnippetStore()
|
||||||
let excl = Exclusions()
|
let excl = Exclusions()
|
||||||
exclusions = excl
|
exclusions = excl
|
||||||
liveMonitor = LiveMonitor(grammar: harper, detector: detector, exclusions: excl, capture: capture)
|
liveMonitor = LiveMonitor(grammar: engine, detector: detector, exclusions: excl, capture: capture)
|
||||||
coordinator = TextReplacementCoordinator(
|
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).
|
/// Called once at launch (from the AppDelegate).
|
||||||
@ -140,6 +141,27 @@ final class AppState: ObservableObject {
|
|||||||
liveMonitor.refreshSoon()
|
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).
|
/// Pause/resume all of GrammaGem (hotkeys + live monitoring).
|
||||||
func togglePause() {
|
func togglePause() {
|
||||||
isPaused.toggle()
|
isPaused.toggle()
|
||||||
@ -258,6 +280,27 @@ final class AppState: ObservableObject {
|
|||||||
mainWindow?.makeKeyAndOrderFront(nil)
|
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() {
|
func showAsk() {
|
||||||
askText = ""
|
askText = ""
|
||||||
if askWindow == nil {
|
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()
|
Divider()
|
||||||
|
|
||||||
|
actionRow(title: "Show live issues…", shortcut: nil) { app.showLiveCheck() }
|
||||||
actionRow(title: "Open GrammaGem…", shortcut: nil) { app.showMainWindow() }
|
actionRow(title: "Open GrammaGem…", shortcut: nil) { app.showMainWindow() }
|
||||||
actionRow(title: "Manage devices…", shortcut: nil) { app.showMainWindow(select: .devices) }
|
actionRow(title: "Manage devices…", shortcut: nil) { app.showMainWindow(select: .devices) }
|
||||||
actionRow(title: "Page blocker…", shortcut: nil) { app.showMainWindow(select: .exclusions) }
|
actionRow(title: "Page blocker…", shortcut: nil) { app.showMainWindow(select: .exclusions) }
|
||||||
@ -102,23 +103,30 @@ struct MenuBarContent: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var liveStatus: some View {
|
private var liveStatus: some View {
|
||||||
|
let inApp = monitor.activeAppName.isEmpty ? "" : " in \(monitor.activeAppName)"
|
||||||
if app.isPaused {
|
if app.isPaused {
|
||||||
Label("Paused — not monitoring", systemImage: "pause.circle")
|
Label("Paused — not monitoring", systemImage: "pause.circle")
|
||||||
.font(.callout).foregroundStyle(.secondary)
|
.font(.callout).foregroundStyle(.secondary)
|
||||||
} else if monitor.issueCount > 0 {
|
} else if monitor.issueCount > 0 {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Label("\(monitor.issueCount) suggestion\(monitor.issueCount == 1 ? "" : "s")"
|
Label("\(monitor.issueCount) issue\(monitor.issueCount == 1 ? "" : "s") in "
|
||||||
+ (monitor.activeAppName.isEmpty ? "" : " in \(monitor.activeAppName)"),
|
+ "\(monitor.fields.count) text area\(monitor.fields.count == 1 ? "" : "s")" + inApp,
|
||||||
systemImage: "text.badge.checkmark")
|
systemImage: "text.badge.xmark")
|
||||||
.font(.callout).foregroundStyle(.primary)
|
.font(.callout).foregroundStyle(.primary)
|
||||||
ForEach(monitor.suggestions.prefix(3)) { s in
|
ForEach(monitor.fields.prefix(2)) { f in
|
||||||
Text("• \(s.message): “\(s.original)” → “\(s.replacement)”")
|
Text("• \(f.label): \(f.suggestions.count) issue\(f.suggestions.count == 1 ? "" : "s")")
|
||||||
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||||
}
|
}
|
||||||
Button { Task { await app.fixFocusedField() } } label: {
|
HStack {
|
||||||
Label("Fix all", systemImage: "checkmark.circle.fill")
|
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)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent).controlSize(.small)
|
|
||||||
}
|
}
|
||||||
} else if monitor.running {
|
} else if monitor.running {
|
||||||
Label(monitor.activeAppName.isEmpty ? "Monitoring — looks clean"
|
Label(monitor.activeAppName.isEmpty ? "Monitoring — 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 AppKit
|
||||||
import ApplicationServices
|
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
|
/// The always-on, menu-bar–resident monitor. While enabled, it periodically
|
||||||
/// reads the focused text field (Accessibility API), runs the offline grammar
|
/// scans **every** text area in the frontmost window (Accessibility API), runs
|
||||||
/// engine, and publishes live suggestions — so the menu bar reflects issues as
|
/// the offline grammar engine on each, and publishes the ones with issues — so
|
||||||
/// you type. It stays out of excluded apps/domains and never sends text anywhere.
|
/// 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
|
@MainActor
|
||||||
final class LiveMonitor: ObservableObject {
|
final class LiveMonitor: ObservableObject {
|
||||||
@Published private(set) var suggestions: [Suggestion] = []
|
@Published private(set) var fields: [DetectedField] = []
|
||||||
@Published private(set) var activeAppName: String = ""
|
@Published private(set) var scannedCount = 0
|
||||||
|
@Published private(set) var activeAppName = ""
|
||||||
@Published private(set) var running = false
|
@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 grammar: GrammarEngine
|
||||||
private let detector: AppDetector
|
private let detector: AppDetector
|
||||||
@ -23,7 +37,7 @@ final class LiveMonitor: ObservableObject {
|
|||||||
private var enabled = false
|
private var enabled = false
|
||||||
private var paused = false
|
private var paused = false
|
||||||
private var lastHash = 0
|
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) {
|
init(grammar: GrammarEngine, detector: AppDetector, exclusions: Exclusions, capture: TextCapture) {
|
||||||
self.grammar = grammar
|
self.grammar = grammar
|
||||||
@ -32,24 +46,49 @@ final class LiveMonitor: ObservableObject {
|
|||||||
self.capture = capture
|
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) {
|
func setPaused(_ p: Bool) { paused = p; reconcile() }
|
||||||
enabled = on
|
|
||||||
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).
|
@discardableResult
|
||||||
func setPaused(_ p: Bool) {
|
func fix(_ field: DetectedField) -> Int {
|
||||||
paused = p
|
let n = applyCorrection(to: field)
|
||||||
reconcile()
|
refreshSoon()
|
||||||
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-run immediately (e.g. after a "fix all").
|
func apply(_ suggestion: Suggestion, in field: DetectedField) {
|
||||||
func refreshSoon() {
|
let ns = NSMutableString(string: field.text)
|
||||||
lastHash = 0
|
guard suggestion.location + suggestion.length <= ns.length else { return }
|
||||||
tick()
|
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() {
|
private func reconcile() {
|
||||||
let shouldRun = enabled && !paused
|
let shouldRun = enabled && !paused
|
||||||
if shouldRun && timer == nil {
|
if shouldRun && timer == nil {
|
||||||
@ -58,7 +97,7 @@ final class LiveMonitor: ObservableObject {
|
|||||||
let t = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
let t = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||||
Task { @MainActor in self?.tick() }
|
Task { @MainActor in self?.tick() }
|
||||||
}
|
}
|
||||||
t.tolerance = 0.4
|
t.tolerance = 0.5
|
||||||
timer = t
|
timer = t
|
||||||
} else if !shouldRun {
|
} else if !shouldRun {
|
||||||
running = false
|
running = false
|
||||||
@ -70,29 +109,52 @@ final class LiveMonitor: ObservableObject {
|
|||||||
|
|
||||||
private func tick() {
|
private func tick() {
|
||||||
guard enabled, !paused, AXIsProcessTrusted() else { clear(); return }
|
guard enabled, !paused, AXIsProcessTrusted() else { clear(); return }
|
||||||
|
guard let app = NSWorkspace.shared.frontmostApplication else { clear(); return }
|
||||||
|
activeAppName = app.localizedName ?? ""
|
||||||
|
|
||||||
let front = detector.frontmost()
|
if exclusions.isBlocked(bundleID: app.bundleIdentifier, domain: detector.frontmostDomain()) {
|
||||||
activeAppName = front?.name ?? ""
|
|
||||||
|
|
||||||
// Respect the page blocker.
|
|
||||||
if exclusions.isBlocked(bundleID: front?.bundleID, domain: detector.frontmostDomain()) {
|
|
||||||
clear()
|
clear()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let field = capture.focusedFieldText() else { clear(); return }
|
let focused = systemFocusedElement()
|
||||||
let text = field.text
|
let scanned = TextFieldScanner.scan(pid: app.processIdentifier, focused: focused)
|
||||||
guard text.count >= 3 else { clear(); return }
|
guard !scanned.isEmpty else { clear(); return }
|
||||||
|
|
||||||
let h = text.hashValue
|
// Skip re-checking if nothing on screen changed.
|
||||||
if h == lastHash { return } // unchanged since last check
|
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
|
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() {
|
private func clear() {
|
||||||
if !suggestions.isEmpty { suggestions = [] }
|
if !fields.isEmpty { fields = [] }
|
||||||
|
scannedCount = 0
|
||||||
lastHash = 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