mirror of
https://github.com/BigMarc/grammargem.git
synced 2026-10-13 17:01:55 +02:00
Implements the highest-leverage items from docs/grammarly-value-plan.md: - Live Check: on-device 0-100 writing score, Correctness/Clarity/Polish count chips, and per-suggestion Dismiss / Add-to-dictionary (a cheap, always-available 'no' is what makes 'Apply' feel safe). - Value loop: day-streak, correction milestones and a 'subscription you didn't pay' figure on the dashboard + a streak line in the menu bar (all local, self-vs-self, no telemetry). - First-run demo: a planted sample the real engine cleans in one click, before any permission ask. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
243 lines
9.7 KiB
Swift
243 lines
9.7 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 {
|
||
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())
|
||
}
|
||
}
|