diff --git a/Sources/GrammaGem/App/AppState.swift b/Sources/GrammaGem/App/AppState.swift index d03779f..e36b473 100644 --- a/Sources/GrammaGem/App/AppState.swift +++ b/Sources/GrammaGem/App/AppState.swift @@ -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() 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 { diff --git a/Sources/GrammaGem/App/MainWindow/LiveCheckView.swift b/Sources/GrammaGem/App/MainWindow/LiveCheckView.swift new file mode 100644 index 0000000..7767c5d --- /dev/null +++ b/Sources/GrammaGem/App/MainWindow/LiveCheckView.swift @@ -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 + } +} diff --git a/Sources/GrammaGem/App/MenuBarContent.swift b/Sources/GrammaGem/App/MenuBarContent.swift index 641c1bd..ad71c13 100644 --- a/Sources/GrammaGem/App/MenuBarContent.swift +++ b/Sources/GrammaGem/App/MenuBarContent.swift @@ -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,23 +103,30 @@ 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: { - Label("Fix all", systemImage: "checkmark.circle.fill") + 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) } - .buttonStyle(.borderedProminent).controlSize(.small) } } else if monitor.running { Label(monitor.activeAppName.isEmpty ? "Monitoring — looks clean" diff --git a/Sources/GrammaGem/Grammar/SystemGrammarEngine.swift b/Sources/GrammaGem/Grammar/SystemGrammarEngine.swift new file mode 100644 index 0000000..2854b78 --- /dev/null +++ b/Sources/GrammaGem/Grammar/SystemGrammarEngine.swift @@ -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 = [] + + 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(_ work: @escaping () -> T) -> T { + Thread.isMainThread ? work() : DispatchQueue.main.sync(execute: work) + } +} diff --git a/Sources/GrammaGem/System/LiveMonitor.swift b/Sources/GrammaGem/System/LiveMonitor.swift index 2c7903e..cb717a1 100644 --- a/Sources/GrammaGem/System/LiveMonitor.swift +++ b/Sources/GrammaGem/System/LiveMonitor.swift @@ -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 } } diff --git a/Sources/GrammaGem/System/TextFieldScanner.swift b/Sources/GrammaGem/System/TextFieldScanner.swift new file mode 100644 index 0000000..68f6dca --- /dev/null +++ b/Sources/GrammaGem/System/TextFieldScanner.swift @@ -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" + } + } +}