mirror of
https://github.com/BigMarc/grammargem.git
synced 2026-10-13 17:01:55 +02:00
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:
parent
a7bf45b466
commit
197df3298f
BIN
AppSupport/AppIcon.icns
Normal file
BIN
AppSupport/AppIcon.icns
Normal file
Binary file not shown.
@ -25,6 +25,10 @@
|
||||
<string>com.grammagem.app</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>GrammaGem</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
<key>CFBundleIconName</key>
|
||||
<string>AppIcon</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
|
||||
@ -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<AnyCancellable>()
|
||||
|
||||
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:
|
||||
|
||||
@ -3,42 +3,82 @@ 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<HotkeyCombo>) -> 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)
|
||||
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))
|
||||
.padding(.horizontal, 8).padding(.vertical, 4)
|
||||
.background(Color(nsColor: .controlColor), in: RoundedRectangle(cornerRadius: 6))
|
||||
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.primary.opacity(0.12)))
|
||||
}
|
||||
.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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
61
Sources/GrammaGem/System/HotkeyCombo.swift
Normal file
61
Sources/GrammaGem/System/HotkeyCombo.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)"
|
||||
|
||||
67
scripts/make-icon.swift
Normal file
67
scripts/make-icon.swift
Normal 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)")
|
||||
Loading…
x
Reference in New Issue
Block a user