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)")