grammargem/Sources/GrammaGem/System/TextFieldScanner.swift
BigMarc 5d1f152fc7 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>
2026-06-17 15:36:24 +02:00

92 lines
3.6 KiB
Swift

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"
}
}
}