BigMarc 3d749e8139 feat: Implement LiveMonitor for real-time grammar and spelling checks across applications
- Added LiveMonitor class to scan text areas in the frontmost window for grammar and spelling issues.
- Introduced DetectedField struct to represent text areas with issues, including suggestions for corrections.
- Implemented functionality to apply corrections and dismiss suggestions.
- Added Permissions class to manage Accessibility permissions required for monitoring.
- Created SnippetStore for managing text-expansion snippets, including persistence to UserDefaults.
- Developed TextCapture class to read and write text from the frontmost application using Accessibility API and clipboard fallback.
- Implemented TextFieldScanner to traverse the Accessibility tree and collect editable text elements.
- Added TextReplacementCoordinator to orchestrate the text correction process.
- Integrated UpdaterManager for handling application updates using Sparkle.
- Created comprehensive unit tests for various components, including grammar corrections and entitlement checks.
2026-06-18 16:49:55 +02:00

243 lines
9.7 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 12) {
ScoreRing(score: monitor.writingScore)
VStack(alignment: .leading, spacing: 2) {
Text(summary).font(.headline)
Text(contextLine).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)
}
}
if monitor.issueCount > 0 {
HStack(spacing: 8) {
ForEach(IssueBucket.allCases) { bucket in
if let n = monitor.bucketCounts[bucket], n > 0 {
BucketChip(bucket: bucket, count: n)
}
}
Spacer()
}
}
}
.padding(16)
}
private var summary: String {
let issues = monitor.issueCount
if issues == 0 { return "Looks clean" }
return "\(issues) issue\(issues == 1 ? "" : "s") across \(monitor.fields.count) text area\(monitor.fields.count == 1 ? "" : "s")"
}
private var contextLine: String {
monitor.activeAppName.isEmpty
? "Checking text areas on screen"
: "In \(monitor.activeAppName) · \(monitor.scannedCount) text area\(monitor.scannedCount == 1 ? "" : "s") checked"
}
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.kind == .spelling {
Button { app.addWordToDictionary(s.original) } label: {
Image(systemName: "book.closed")
}
.controlSize(.small).buttonStyle(.borderless)
.help("Add “\(s.original)” to your dictionary")
}
Button { app.dismissSuggestion(s, in: field) } label: {
Image(systemName: "xmark")
}
.controlSize(.small).buttonStyle(.borderless)
.help("Dismiss this suggestion")
if s.replacement != s.original && !s.replacement.isEmpty {
Button("Apply") { app.applySuggestion(s, in: field) }
.controlSize(.small).buttonStyle(.borderedProminent)
}
}
}
}
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
}
}
fileprivate static func color(for bucket: IssueBucket) -> Color {
switch bucket {
case .correctness: return .red
case .clarity: return .blue
case .polish: 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
}
}
/// A compact 0–100 cleanliness gauge. Green when clean, amber/orange as issues
/// pile up — a meter the user can watch climb as they accept fixes.
private struct ScoreRing: View {
let score: Int
var body: some View {
ZStack {
Circle().stroke(Color.primary.opacity(0.1), lineWidth: 5)
Circle()
.trim(from: 0, to: CGFloat(max(0, min(100, score))) / 100)
.stroke(color, style: StrokeStyle(lineWidth: 5, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(.easeOut(duration: 0.35), value: score)
Text("\(score)").font(.system(size: 15, weight: .bold, design: .rounded))
}
.frame(width: 44, height: 44)
.help("Writing score for the text on screen")
}
private var color: Color {
score >= 90 ? GG.emerald : (score >= 70 ? GG.gold : .orange)
}
}
/// A tappable-looking count chip per issue bucket (Correctness / Clarity / Polish).
private struct BucketChip: View {
let bucket: IssueBucket
let count: Int
var body: some View {
HStack(spacing: 5) {
Circle().fill(FieldCard.color(for: bucket)).frame(width: 7, height: 7)
Text("\(bucket.title) \(count)").font(.caption.weight(.medium))
}
.padding(.horizontal, 9).padding(.vertical, 4)
.background(FieldCard.color(for: bucket).opacity(0.12), in: Capsule())
}
}