diff --git a/AppSupport/AppIcon.icns b/AppSupport/AppIcon.icns new file mode 100644 index 0000000..f05354e Binary files /dev/null and b/AppSupport/AppIcon.icns differ diff --git a/AppSupport/Info.plist b/AppSupport/Info.plist index dd71d27..8b4ef3c 100644 --- a/AppSupport/Info.plist +++ b/AppSupport/Info.plist @@ -25,6 +25,10 @@ com.grammagem.app CFBundleExecutable GrammaGem + CFBundleIconFile + AppIcon + CFBundleIconName + AppIcon CFBundlePackageType APPL CFBundleShortVersionString diff --git a/Sources/GrammaGem/App/AppState.swift b/Sources/GrammaGem/App/AppState.swift index fbd5219..0c131c5 100644 --- a/Sources/GrammaGem/App/AppState.swift +++ b/Sources/GrammaGem/App/AppState.swift @@ -1,6 +1,7 @@ import Foundation import AppKit import SwiftUI +import Combine /// Root application model. Owns the engines, managers, and the system-wide /// coordinator, wires the global hotkeys, and presents the on-demand windows. @@ -35,6 +36,7 @@ final class AppState: ObservableObject { private var onboardingWindow: NSWindow? private var askWindow: NSWindow? private var mainWindow: NSWindow? + private var cancellables = Set() private init() { let harper = HarperEngine() @@ -64,7 +66,19 @@ final class AppState: ObservableObject { hotkeys.onAction = { [weak self] action in Task { await self?.handle(action) } } - hotkeys.registerDefaults() + reregisterHotkeys() + + // Live-apply preference changes. + capture.restoreClipboard = preferences.restoreClipboard + preferences.$restoreClipboard + .sink { [weak self] value in self?.capture.restoreClipboard = value } + .store(in: &cancellables) + preferences.$fixHotkey.dropFirst() + .sink { [weak self] _ in self?.reregisterHotkeys() } + .store(in: &cancellables) + preferences.$askHotkey.dropFirst() + .sink { [weak self] _ in self?.reregisterHotkeys() } + .store(in: &cancellables) permissions.refresh() appAwareEnabled = gate.appAwareEnabled @@ -80,6 +94,15 @@ final class AppState: ObservableObject { // MARK: - Hotkey handling + /// (Re)register the global hotkeys from the current preferences. + func reregisterHotkeys() { + hotkeys.unregisterAll() + hotkeys.register(id: 1, keyCode: preferences.fixHotkey.keyCode, + modifiers: preferences.fixHotkey.carbonModifiers, action: .fix) + hotkeys.register(id: 2, keyCode: preferences.askHotkey.keyCode, + modifiers: preferences.askHotkey.carbonModifiers, action: .ask) + } + func handle(_ action: HotkeyManager.Action) async { switch action { case .fix: diff --git a/Sources/GrammaGem/App/MainWindow/SystemViews.swift b/Sources/GrammaGem/App/MainWindow/SystemViews.swift index a3f9707..9ee64af 100644 --- a/Sources/GrammaGem/App/MainWindow/SystemViews.swift +++ b/Sources/GrammaGem/App/MainWindow/SystemViews.swift @@ -3,45 +3,85 @@ import SwiftUI // MARK: - Shortcuts struct ShortcutsView: View { + @EnvironmentObject private var prefs: Preferences + var body: some View { DetailScaffold( title: "Shortcuts", - subtitle: "Global hotkeys that work in every app." + subtitle: "Global hotkeys that work in every app. Click a shortcut to change it." ) { Card { - shortcutRow(name: "Fix selection", keys: ["⌘", ";"], detail: "Grammar + clarity, in place") + shortcutRow( + name: "Fix selection", + detail: "Grammar + clarity, in place", + combo: $prefs.fixHotkey) Divider() - shortcutRow(name: "Ask GrammaGem", keys: ["⌘", "'"], detail: "Rewrite with an instruction") - } - Card { - Label("Custom shortcuts", systemImage: "wand.and.stars") - .font(.headline) - Text("A built-in recorder for fully customizable shortcuts is on the roadmap. For now the defaults above are active system-wide.") - .font(.callout).foregroundStyle(.secondary) + shortcutRow( + name: "Ask GrammaGem", + detail: "Rewrite with an instruction", + combo: $prefs.askHotkey) } + Text("Tip: include at least one modifier (⌘, ⌥, ⌃, ⇧). Press Esc while recording to cancel.") + .font(.caption).foregroundStyle(.secondary) } } - private func shortcutRow(name: String, keys: [String], detail: String) -> some View { + private func shortcutRow(name: String, detail: String, combo: Binding) -> some View { HStack { VStack(alignment: .leading, spacing: 2) { Text(name).fontWeight(.medium) Text(detail).font(.caption).foregroundStyle(.secondary) } Spacer() - HStack(spacing: 4) { - ForEach(keys, id: \.self) { k in - Text(k) - .font(.system(.body, design: .monospaced)) - .padding(.horizontal, 8).padding(.vertical, 4) - .background(Color(nsColor: .controlColor), in: RoundedRectangle(cornerRadius: 6)) - .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.primary.opacity(0.12))) - } - } + ShortcutRecorder(combo: combo) } } } +/// A small click-to-record control that captures the next modifier+key combo. +struct ShortcutRecorder: View { + @Binding var combo: HotkeyCombo + @State private var recording = false + @State private var monitor: Any? + + var body: some View { + Button { + recording ? stop() : start() + } label: { + Text(recording ? "Press keys…" : combo.display) + .font(.system(.body, design: .monospaced)) + .frame(minWidth: 76) + .padding(.horizontal, 10).padding(.vertical, 5) + .background( + RoundedRectangle(cornerRadius: 7) + .fill(recording ? GG.emerald.opacity(0.15) : Color(nsColor: .controlColor))) + .overlay( + RoundedRectangle(cornerRadius: 7) + .stroke(recording ? GG.emerald : Color.primary.opacity(0.12))) + } + .buttonStyle(.plain) + .onDisappear(perform: stop) + } + + private func start() { + recording = true + monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + if event.keyCode == 53 { stop(); return nil } // Esc cancels + if let captured = HotkeyCombo.from(event: event) { + combo = captured + stop() + return nil + } + return event // ignore bare keys (no modifier) + } + } + + private func stop() { + recording = false + if let monitor { NSEvent.removeMonitor(monitor); self.monitor = nil } + } +} + // MARK: - AI Model struct ModelView: View { diff --git a/Sources/GrammaGem/Preferences/Preferences.swift b/Sources/GrammaGem/Preferences/Preferences.swift index 9bb7700..e685d1b 100644 --- a/Sources/GrammaGem/Preferences/Preferences.swift +++ b/Sources/GrammaGem/Preferences/Preferences.swift @@ -27,6 +27,9 @@ final class Preferences: ObservableObject { @Published var appearance: Appearance { didSet { d.set(appearance.rawValue, forKey: "pref.appearance"); applyAppearance() } } @Published var menuBarStyle: MenuBarStyle { didSet { d.set(menuBarStyle.rawValue, forKey: "pref.menuBarStyle") } } + @Published var fixHotkey: HotkeyCombo { didSet { persist(fixHotkey, "pref.fixHotkey") } } + @Published var askHotkey: HotkeyCombo { didSet { persist(askHotkey, "pref.askHotkey") } } + init() { launchAtLogin = SMAppService.mainApp.status == .enabled playSounds = d.object(forKey: "pref.playSounds") as? Bool ?? true @@ -34,6 +37,17 @@ final class Preferences: ObservableObject { restoreClipboard = d.object(forKey: "pref.restoreClipboard") as? Bool ?? true appearance = Appearance(rawValue: d.string(forKey: "pref.appearance") ?? "") ?? .system menuBarStyle = MenuBarStyle(rawValue: d.string(forKey: "pref.menuBarStyle") ?? "") ?? .icon + fixHotkey = Preferences.load("pref.fixHotkey") ?? .defaultFix + askHotkey = Preferences.load("pref.askHotkey") ?? .defaultAsk + } + + private func persist(_ combo: HotkeyCombo, _ key: String) { + if let raw = try? JSONEncoder().encode(combo) { d.set(raw, forKey: key) } + } + + private static func load(_ key: String) -> HotkeyCombo? { + guard let raw = UserDefaults.standard.data(forKey: key) else { return nil } + return try? JSONDecoder().decode(HotkeyCombo.self, from: raw) } private func applyLaunchAtLogin() { diff --git a/Sources/GrammaGem/System/HotkeyCombo.swift b/Sources/GrammaGem/System/HotkeyCombo.swift new file mode 100644 index 0000000..f0561c9 --- /dev/null +++ b/Sources/GrammaGem/System/HotkeyCombo.swift @@ -0,0 +1,61 @@ +import Foundation +import AppKit +import Carbon.HIToolbox + +/// A user-configurable keyboard shortcut: a key code plus Carbon modifier mask, +/// with a human-readable display string (e.g. "⌘;"). +struct HotkeyCombo: Codable, Equatable { + var keyCode: UInt32 + var carbonModifiers: UInt32 + var display: String + + static let defaultFix = HotkeyCombo( + keyCode: AppConfig.Hotkey.defaultKeyCode, + carbonModifiers: AppConfig.Hotkey.defaultModifiers, + display: "⌘;") + + static let defaultAsk = HotkeyCombo( + keyCode: AppConfig.Hotkey.askKeyCode, + carbonModifiers: AppConfig.Hotkey.askModifiers, + display: "⌘'") + + /// Build a combo from a recorded key-down event. Requires at least one + /// modifier so the shortcut is safe to register globally. + static func from(event: NSEvent) -> HotkeyCombo? { + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + var carbon: UInt32 = 0 + if flags.contains(.command) { carbon |= UInt32(cmdKey) } + if flags.contains(.shift) { carbon |= UInt32(shiftKey) } + if flags.contains(.option) { carbon |= UInt32(optionKey) } + if flags.contains(.control) { carbon |= UInt32(controlKey) } + guard carbon != 0 else { return nil } + + let symbol = keySymbol(for: event) + var display = "" + if flags.contains(.control) { display += "⌃" } + if flags.contains(.option) { display += "⌥" } + if flags.contains(.shift) { display += "⇧" } + if flags.contains(.command) { display += "⌘" } + display += symbol + return HotkeyCombo(keyCode: UInt32(event.keyCode), carbonModifiers: carbon, display: display) + } + + private static func keySymbol(for event: NSEvent) -> String { + switch Int(event.keyCode) { + case kVK_Space: return "Space" + case kVK_Return: return "↩" + case kVK_Tab: return "⇥" + case kVK_Delete: return "⌫" + case kVK_Escape: return "⎋" + case kVK_LeftArrow: return "←" + case kVK_RightArrow: return "→" + case kVK_UpArrow: return "↑" + case kVK_DownArrow: return "↓" + default: + if let chars = event.charactersIgnoringModifiers, !chars.isEmpty { + return chars.uppercased() + } + return "key" + } + } +} diff --git a/Sources/GrammaGem/System/TextCapture.swift b/Sources/GrammaGem/System/TextCapture.swift index 71821f4..eeb0cec 100644 --- a/Sources/GrammaGem/System/TextCapture.swift +++ b/Sources/GrammaGem/System/TextCapture.swift @@ -34,6 +34,10 @@ final class TextCapture { private let pasteboard = NSPasteboard.general + /// When true (default), the clipboard-fallback path restores the user's + /// previous clipboard after pasting the correction. Driven by Preferences. + var restoreClipboard = true + // MARK: - Capture /// Try AX first; on empty/unavailable, fall back to the clipboard path. @@ -104,8 +108,8 @@ final class TextCapture { writeClipboard(newText) sendCommandKey(CGKeyCode(kVK_ANSI_V)) Thread.sleep(forTimeInterval: 0.10) - // Restore the user's previous clipboard contents. - if let saved = savedClipboard { + // Restore the user's previous clipboard contents (if they want that). + if restoreClipboard, let saved = savedClipboard { Thread.sleep(forTimeInterval: 0.05) writeClipboard(saved) } diff --git a/scripts/build.sh b/scripts/build.sh index 6367ce7..2672e5f 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -20,7 +20,7 @@ mkdir -p "${DIST}/${APP}/Contents/MacOS" "${DIST}/${APP}/Contents/Resources" cp "${BUILD_DIR}/GrammaGem" "${DIST}/${APP}/Contents/MacOS/GrammaGem" cp "AppSupport/Info.plist" "${DIST}/${APP}/Contents/Info.plist" -# cp "AppSupport/AppIcon.icns" "${DIST}/${APP}/Contents/Resources/" # TODO: add an icon +cp "AppSupport/AppIcon.icns" "${DIST}/${APP}/Contents/Resources/AppIcon.icns" echo "==> Built ${DIST}/${APP}" echo " Run with: open \"${DIST}/${APP}\" (grant Accessibility on first launch)" diff --git a/scripts/make-icon.swift b/scripts/make-icon.swift new file mode 100644 index 0000000..81c0e3c --- /dev/null +++ b/scripts/make-icon.swift @@ -0,0 +1,67 @@ +#!/usr/bin/env swift +// Generates AppIcon.iconset (PNGs) for GrammaGem — a gold pen-nib on the ink +// brand square. Run from mac/: swift scripts/make-icon.swift +// Then: iconutil -c icns AppIcon.iconset -o AppSupport/AppIcon.icns +import AppKit +import Foundation + +let ink = NSColor(red: 0x10 / 255.0, green: 0x23 / 255.0, blue: 0x1B / 255.0, alpha: 1) +let gold = NSColor(red: 0xC9 / 255.0, green: 0xA2 / 255.0, blue: 0x4B / 255.0, alpha: 1) + +func png(size: Int) -> Data { + let S = CGFloat(size) + let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, pixelsWide: size, pixelsHigh: size, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)! + let ctx = NSGraphicsContext(bitmapImageRep: rep)! + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = ctx + let g = ctx.cgContext + + // Brand square (rounded). + let rrect = CGPath(roundedRect: CGRect(x: 0, y: 0, width: S, height: S), + cornerWidth: S * 0.22, cornerHeight: S * 0.22, transform: nil) + g.addPath(rrect); g.setFillColor(ink.cgColor); g.fillPath() + + // Gold nib (downward triangle), coordinates are y-up. + let tri = CGMutablePath() + tri.move(to: CGPoint(x: 0.18 * S, y: 0.78 * S)) + tri.addLine(to: CGPoint(x: 0.82 * S, y: 0.78 * S)) + tri.addLine(to: CGPoint(x: 0.50 * S, y: 0.10 * S)) + tri.closeSubpath() + g.addPath(tri); g.setFillColor(gold.cgColor); g.fillPath() + + // Center slit. + g.setStrokeColor(ink.cgColor) + g.setLineWidth(max(1, S * 0.05)); g.setLineCap(.round) + g.move(to: CGPoint(x: 0.50 * S, y: 0.20 * S)) + g.addLine(to: CGPoint(x: 0.50 * S, y: 0.60 * S)) + g.strokePath() + + // Vent hole. + g.setFillColor(ink.cgColor) + let r = S * 0.055 + g.addEllipse(in: CGRect(x: 0.50 * S - r, y: 0.64 * S - r, width: 2 * r, height: 2 * r)) + g.fillPath() + + NSGraphicsContext.restoreGraphicsState() + return rep.representation(using: .png, properties: [:])! +} + +let fm = FileManager.default +let dir = "AppIcon.iconset" +try? fm.removeItem(atPath: dir) +try! fm.createDirectory(atPath: dir, withIntermediateDirectories: true) + +let specs: [(String, Int)] = [ + ("icon_16x16", 16), ("icon_16x16@2x", 32), + ("icon_32x32", 32), ("icon_32x32@2x", 64), + ("icon_128x128", 128), ("icon_128x128@2x", 256), + ("icon_256x256", 256), ("icon_256x256@2x", 512), + ("icon_512x512", 512), ("icon_512x512@2x", 1024), +] +for (name, size) in specs { + try! png(size: size).write(to: URL(fileURLWithPath: "\(dir)/\(name).png")) +} +print("wrote \(specs.count) icon images to \(dir)")