Customizable shortcuts, app icon, restore-clipboard preference

- Shortcuts screen now records custom global hotkeys (Fix + Ask), persisted and
  re-registered live via Preferences + Combine.
- Add branded AppIcon.icns (gold nib on ink), wired into Info.plist + build.sh.
- "Restore my clipboard after a fix" preference now drives the clipboard fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BigMarc 2026-06-16 14:59:15 +02:00
parent a7bf45b466
commit 197df3298f
9 changed files with 236 additions and 23 deletions

BIN
AppSupport/AppIcon.icns Normal file

Binary file not shown.

View File

@ -25,6 +25,10 @@
<string>com.grammagem.app</string> <string>com.grammagem.app</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>GrammaGem</string> <string>GrammaGem</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundleIconName</key>
<string>AppIcon</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>

View File

@ -1,6 +1,7 @@
import Foundation import Foundation
import AppKit import AppKit
import SwiftUI import SwiftUI
import Combine
/// Root application model. Owns the engines, managers, and the system-wide /// Root application model. Owns the engines, managers, and the system-wide
/// coordinator, wires the global hotkeys, and presents the on-demand windows. /// 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 onboardingWindow: NSWindow?
private var askWindow: NSWindow? private var askWindow: NSWindow?
private var mainWindow: NSWindow? private var mainWindow: NSWindow?
private var cancellables = Set<AnyCancellable>()
private init() { private init() {
let harper = HarperEngine() let harper = HarperEngine()
@ -64,7 +66,19 @@ final class AppState: ObservableObject {
hotkeys.onAction = { [weak self] action in hotkeys.onAction = { [weak self] action in
Task { await self?.handle(action) } 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() permissions.refresh()
appAwareEnabled = gate.appAwareEnabled appAwareEnabled = gate.appAwareEnabled
@ -80,6 +94,15 @@ final class AppState: ObservableObject {
// MARK: - Hotkey handling // 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 { func handle(_ action: HotkeyManager.Action) async {
switch action { switch action {
case .fix: case .fix:

View File

@ -3,42 +3,82 @@ import SwiftUI
// MARK: - Shortcuts // MARK: - Shortcuts
struct ShortcutsView: View { struct ShortcutsView: View {
@EnvironmentObject private var prefs: Preferences
var body: some View { var body: some View {
DetailScaffold( DetailScaffold(
title: "Shortcuts", 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 { Card {
shortcutRow(name: "Fix selection", keys: ["⌘", ";"], detail: "Grammar + clarity, in place") shortcutRow(
name: "Fix selection",
detail: "Grammar + clarity, in place",
combo: $prefs.fixHotkey)
Divider() Divider()
shortcutRow(name: "Ask GrammaGem", keys: ["⌘", "'"], detail: "Rewrite with an instruction") shortcutRow(
} name: "Ask GrammaGem",
Card { detail: "Rewrite with an instruction",
Label("Custom shortcuts", systemImage: "wand.and.stars") combo: $prefs.askHotkey)
.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)
} }
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<HotkeyCombo>) -> some View {
HStack { HStack {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(name).fontWeight(.medium) Text(name).fontWeight(.medium)
Text(detail).font(.caption).foregroundStyle(.secondary) Text(detail).font(.caption).foregroundStyle(.secondary)
} }
Spacer() Spacer()
HStack(spacing: 4) { ShortcutRecorder(combo: combo)
ForEach(keys, id: \.self) { k in }
Text(k) }
}
/// 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)) .font(.system(.body, design: .monospaced))
.padding(.horizontal, 8).padding(.vertical, 4) .frame(minWidth: 76)
.background(Color(nsColor: .controlColor), in: RoundedRectangle(cornerRadius: 6)) .padding(.horizontal, 10).padding(.vertical, 5)
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.primary.opacity(0.12))) .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 }
} }
} }

View File

@ -27,6 +27,9 @@ final class Preferences: ObservableObject {
@Published var appearance: Appearance { didSet { d.set(appearance.rawValue, forKey: "pref.appearance"); applyAppearance() } } @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 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() { init() {
launchAtLogin = SMAppService.mainApp.status == .enabled launchAtLogin = SMAppService.mainApp.status == .enabled
playSounds = d.object(forKey: "pref.playSounds") as? Bool ?? true 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 restoreClipboard = d.object(forKey: "pref.restoreClipboard") as? Bool ?? true
appearance = Appearance(rawValue: d.string(forKey: "pref.appearance") ?? "") ?? .system appearance = Appearance(rawValue: d.string(forKey: "pref.appearance") ?? "") ?? .system
menuBarStyle = MenuBarStyle(rawValue: d.string(forKey: "pref.menuBarStyle") ?? "") ?? .icon 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() { private func applyLaunchAtLogin() {

View File

@ -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"
}
}
}

View File

@ -34,6 +34,10 @@ final class TextCapture {
private let pasteboard = NSPasteboard.general 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 // MARK: - Capture
/// Try AX first; on empty/unavailable, fall back to the clipboard path. /// Try AX first; on empty/unavailable, fall back to the clipboard path.
@ -104,8 +108,8 @@ final class TextCapture {
writeClipboard(newText) writeClipboard(newText)
sendCommandKey(CGKeyCode(kVK_ANSI_V)) sendCommandKey(CGKeyCode(kVK_ANSI_V))
Thread.sleep(forTimeInterval: 0.10) Thread.sleep(forTimeInterval: 0.10)
// Restore the user's previous clipboard contents. // Restore the user's previous clipboard contents (if they want that).
if let saved = savedClipboard { if restoreClipboard, let saved = savedClipboard {
Thread.sleep(forTimeInterval: 0.05) Thread.sleep(forTimeInterval: 0.05)
writeClipboard(saved) writeClipboard(saved)
} }

View File

@ -20,7 +20,7 @@ mkdir -p "${DIST}/${APP}/Contents/MacOS" "${DIST}/${APP}/Contents/Resources"
cp "${BUILD_DIR}/GrammaGem" "${DIST}/${APP}/Contents/MacOS/GrammaGem" cp "${BUILD_DIR}/GrammaGem" "${DIST}/${APP}/Contents/MacOS/GrammaGem"
cp "AppSupport/Info.plist" "${DIST}/${APP}/Contents/Info.plist" 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 "==> Built ${DIST}/${APP}"
echo " Run with: open \"${DIST}/${APP}\" (grant Accessibility on first launch)" echo " Run with: open \"${DIST}/${APP}\" (grant Accessibility on first launch)"

67
scripts/make-icon.swift Normal file
View File

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