mirror of
https://github.com/BigMarc/grammargem.git
synced 2026-10-13 17:01:55 +02:00
- 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.
221 lines
9.6 KiB
Swift
221 lines
9.6 KiB
Swift
import SwiftUI
|
||
|
||
/// First-run onboarding: explains why GrammaGem needs Accessibility, deep-links
|
||
/// to System Settings, and advances automatically the moment permission is
|
||
/// granted (even if granted directly in System Settings).
|
||
struct OnboardingView: View {
|
||
@EnvironmentObject private var app: AppState
|
||
@ObservedObject private var permissions: Permissions
|
||
@ObservedObject private var model: ModelManager
|
||
|
||
init() {
|
||
_permissions = ObservedObject(wrappedValue: AppState.shared.permissions)
|
||
_model = ObservedObject(wrappedValue: AppState.shared.model)
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 18) {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
Text("Welcome to GrammaGem")
|
||
.font(.largeTitle.bold())
|
||
Text("A private, on-device writing assistant that works in every app.")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
// The rigged win: prove the value in one click, before any ask.
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
Label("See it work", systemImage: "wand.and.stars").font(.headline)
|
||
DemoStep()
|
||
}
|
||
.padding(16)
|
||
.background(GG.emerald.opacity(0.06), in: RoundedRectangle(cornerRadius: 12))
|
||
.overlay(RoundedRectangle(cornerRadius: 12).stroke(GG.emerald.opacity(0.25)))
|
||
|
||
stepCard(
|
||
number: 1,
|
||
title: "Turn it on everywhere",
|
||
body: "GrammaGem reads the text you select and writes the correction back. macOS requires Accessibility permission for this. Your text is processed on-device and never leaves your Mac.",
|
||
granted: permissions.accessibilityTrusted
|
||
) {
|
||
if permissions.accessibilityTrusted {
|
||
Label("Permission granted", systemImage: "checkmark.circle.fill")
|
||
.foregroundStyle(.green)
|
||
} else {
|
||
HStack {
|
||
Button("Open System Settings") { permissions.openAccessibilitySettings() }
|
||
Button("Request permission") { permissions.requestAccessibility() }
|
||
}
|
||
Text("This updates automatically once you flip GrammaGem on in Accessibility.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
stepCard(
|
||
number: 2,
|
||
title: "Download the on-device model (optional)",
|
||
body: "Grammar & spelling work instantly with no download. The AI rewrite/tone/Ask features use a small model downloaded once from Hugging Face — then everything runs offline.",
|
||
granted: model.state == .ready
|
||
) {
|
||
ModelDownloadControls()
|
||
}
|
||
}
|
||
.padding(28)
|
||
}
|
||
|
||
Divider()
|
||
|
||
HStack {
|
||
Label("Yours once. No subscription, nothing leaves your Mac.", systemImage: "lock.shield")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
Spacer()
|
||
Button(permissions.accessibilityTrusted ? "Done" : "Continue without permission") {
|
||
app.showOnboardingDismiss()
|
||
}
|
||
.keyboardShortcut(.defaultAction)
|
||
}
|
||
.padding(.horizontal, 28).padding(.vertical, 14)
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||
.onAppear {
|
||
permissions.startPollingUntilGranted()
|
||
}
|
||
.onDisappear {
|
||
permissions.stopPolling()
|
||
}
|
||
}
|
||
|
||
private func stepCard<Controls: View>(
|
||
number: Int, title: String, body: String, granted: Bool,
|
||
@ViewBuilder controls: () -> Controls
|
||
) -> some View {
|
||
HStack(alignment: .top, spacing: 14) {
|
||
ZStack {
|
||
Circle().fill(granted ? Color.green : Color.accentColor.opacity(0.15))
|
||
.frame(width: 30, height: 30)
|
||
if granted {
|
||
Image(systemName: "checkmark").foregroundStyle(.white).font(.caption.bold())
|
||
} else {
|
||
Text("\(number)").font(.callout.bold()).foregroundStyle(.tint)
|
||
}
|
||
}
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text(title).font(.headline)
|
||
Text(body).font(.callout).foregroundStyle(.secondary)
|
||
controls()
|
||
}
|
||
}
|
||
.padding(16)
|
||
.background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 12))
|
||
}
|
||
}
|
||
|
||
/// A safe, on-rails first-run demo: a local sample with planted mistakes that
|
||
/// the REAL grammar engine fixes in one click. It operates on a plain string
|
||
/// (never a live Accessibility element), so it's completely risk-free — the
|
||
/// "guaranteed visible win" before GrammaGem asks for anything.
|
||
private struct DemoStep: View {
|
||
@State private var text = "i has wrote alot of mistakes here, and it dont look profesional"
|
||
@State private var issues: [Suggestion] = []
|
||
@State private var cleaned = false
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
Text(headline)
|
||
.font(.subheadline.weight(.semibold))
|
||
.foregroundStyle(cleaned ? GG.emerald : .primary)
|
||
Text(highlighted)
|
||
.font(.callout)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(10)
|
||
.background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 8))
|
||
.animation(.easeInOut(duration: 0.35), value: text)
|
||
Button {
|
||
let fixed = AppState.shared.grammar.correct(text)
|
||
withAnimation(.easeOut(duration: 0.4)) {
|
||
text = fixed
|
||
issues = []
|
||
cleaned = true
|
||
}
|
||
} label: {
|
||
Label(cleaned ? "All clean" : "Clean it up",
|
||
systemImage: cleaned ? "checkmark.circle.fill" : "wand.and.stars")
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.disabled(cleaned || issues.isEmpty)
|
||
}
|
||
.onAppear {
|
||
if issues.isEmpty && !cleaned { issues = AppState.shared.grammar.check(text) }
|
||
}
|
||
}
|
||
|
||
private var headline: String {
|
||
if cleaned { return "Clean ✨ — GrammaGem does this in every app you type." }
|
||
if issues.isEmpty { return "Checking…" }
|
||
return "We spotted \(issues.count) thing\(issues.count == 1 ? "" : "s") to tidy up — one click fixes them."
|
||
}
|
||
|
||
private var highlighted: AttributedString {
|
||
let ns = text as NSString
|
||
var result = AttributedString()
|
||
var cursor = 0
|
||
for s in issues.sorted(by: { $0.location < $1.location }) {
|
||
guard s.location >= cursor, s.length > 0, s.location + s.length <= ns.length else { continue }
|
||
if s.location > cursor {
|
||
result += AttributedString(ns.substring(with: NSRange(location: cursor, length: s.location - cursor)))
|
||
}
|
||
var run = AttributedString(ns.substring(with: s.range))
|
||
let tint: Color = s.kind == .spelling ? .red : .orange
|
||
run.backgroundColor = tint.opacity(0.18)
|
||
run.foregroundColor = tint
|
||
run.underlineStyle = .single
|
||
result += run
|
||
cursor = s.location + s.length
|
||
}
|
||
if cursor < ns.length {
|
||
result += AttributedString(ns.substring(with: NSRange(location: cursor, length: ns.length - cursor)))
|
||
}
|
||
return result
|
||
}
|
||
}
|
||
|
||
/// Real model download progress + controls, shared by onboarding and settings.
|
||
struct ModelDownloadControls: View {
|
||
@ObservedObject private var model: ModelManager
|
||
|
||
init() {
|
||
_model = ObservedObject(wrappedValue: AppState.shared.model)
|
||
}
|
||
|
||
var body: some View {
|
||
switch model.state {
|
||
case .notDownloaded:
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Button("Download model now") { model.startDownload() }
|
||
Text("Installs automatically in the background on first launch (about 1–2 GB from Hugging Face). One time, then fully offline.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
case .downloading(let progress):
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
ProgressView(value: progress) {
|
||
Text("Downloading… \(Int(progress * 100))%")
|
||
}
|
||
.frame(maxWidth: 320)
|
||
if !model.statusText.isEmpty {
|
||
Text(model.statusText).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||
}
|
||
Button("Cancel") { model.cancel() }.controlSize(.small)
|
||
}
|
||
case .ready:
|
||
Label("Model ready", systemImage: "checkmark.seal.fill").foregroundStyle(.green)
|
||
case .failed(let message):
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Label("Download failed", systemImage: "exclamationmark.triangle").foregroundStyle(.orange)
|
||
Text(message).font(.caption).foregroundStyle(.secondary).lineLimit(2)
|
||
Button("Retry") { model.startDownload() }
|
||
}
|
||
}
|
||
}
|
||
}
|