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>
170 lines
6.8 KiB
Swift
170 lines
6.8 KiB
Swift
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
|
|
}
|
|
}
|