diff --git a/Scripts/dev-run.sh b/Scripts/dev-run.sh index 08b16a6..c6db856 100755 --- a/Scripts/dev-run.sh +++ b/Scripts/dev-run.sh @@ -145,8 +145,22 @@ if [[ "${TAIL}" -eq 1 ]]; then mkdir -p "$(dirname "${LOG}")" # Create the file if the app hasn't written it yet, so tail doesn't error. touch "${LOG}" + + # Ctrl-C (or any exit) means "I'm done dev-running": kill the dev app and + # delete the bundle so nothing lingers. Leaving the dev copy around is a + # real hazard — it shares com.gordonbeeming.vista with the brew-installed + # prod, and a stale dev build silently wins the hotkey on next login. + # Scoped to the tailing branch only — `--no-tail` is fire-and-forget. + cleanup() { + echo "" + echo "🧹 Stopping Vista and removing ${APP}" + pkill -x "Vista" 2>/dev/null || true + rm -rf "${APP}" + } + trap cleanup EXIT + echo "" - echo "📜 Tailing ${LOG} (Ctrl-C to stop — app keeps running)" + echo "📜 Tailing ${LOG} (Ctrl-C to stop the app & remove the dev bundle)" echo "" tail -f "${LOG}" fi diff --git a/Sources/Vista/HotKeyManager.swift b/Sources/Vista/HotKeyManager.swift index ff1d747..19d937a 100644 --- a/Sources/Vista/HotKeyManager.swift +++ b/Sources/Vista/HotKeyManager.swift @@ -28,6 +28,42 @@ public struct HotKeyChord: Equatable, Sendable, Codable { keyCode: UInt32(kVK_ANSI_S), modifiers: UInt32(cmdKey | shiftKey) ) + + /// Translates a Carbon virtual keycode to its display character via + /// the active keyboard layout. Shared between the preferences + /// recorder (which uppercases for the "⌃⌥⇧⌘S" badge) and the menu- + /// bar shortcut mapper (which lowercases for SwiftUI's + /// `KeyEquivalent`). Case is left to the caller so each surface keeps + /// its own rendering contract. Returns nil for keycodes the active + /// layout can't render (dead keys with no visible form, etc.). + public static func layoutCharacter(for keyCode: UInt32) -> String? { + guard let source = TISCopyCurrentKeyboardLayoutInputSource()?.takeRetainedValue() else { return nil } + guard let layoutData = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) else { + return nil + } + let data = Unmanaged.fromOpaque(layoutData).takeUnretainedValue() as Data + return data.withUnsafeBytes { raw -> String? in + guard let ptr = raw.bindMemory(to: UCKeyboardLayout.self).baseAddress else { return nil } + var deadKeyState: UInt32 = 0 + var chars: [UniChar] = Array(repeating: 0, count: 4) + var length = 0 + let status = UCKeyTranslate( + ptr, + UInt16(keyCode), + UInt16(kUCKeyActionDisplay), + 0, + UInt32(LMGetKbdType()), + OptionBits(kUCKeyTranslateNoDeadKeysBit), + &deadKeyState, + chars.count, + &length, + &chars + ) + guard status == noErr, length > 0 else { return nil } + let result = String(utf16CodeUnits: chars, count: length) + return result.isEmpty ? nil : result + } + } } @MainActor diff --git a/Sources/Vista/KeyRecorderView.swift b/Sources/Vista/KeyRecorderView.swift index 40344c8..8cc1934 100644 --- a/Sources/Vista/KeyRecorderView.swift +++ b/Sources/Vista/KeyRecorderView.swift @@ -191,32 +191,7 @@ struct KeyRecorderView: View { } private static func layoutKeyName(for keyCode: UInt32) -> String? { - guard let source = TISCopyCurrentKeyboardLayoutInputSource()?.takeRetainedValue() else { return nil } - guard let layoutData = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) else { - return nil - } - let data = Unmanaged.fromOpaque(layoutData).takeUnretainedValue() as Data - return data.withUnsafeBytes { raw -> String? in - guard let ptr = raw.bindMemory(to: UCKeyboardLayout.self).baseAddress else { return nil } - var deadKeyState: UInt32 = 0 - var chars: [UniChar] = Array(repeating: 0, count: 4) - var length = 0 - let status = UCKeyTranslate( - ptr, - UInt16(keyCode), - UInt16(kUCKeyActionDisplay), - 0, - UInt32(LMGetKbdType()), - OptionBits(kUCKeyTranslateNoDeadKeysBit), - &deadKeyState, - chars.count, - &length, - &chars - ) - guard status == noErr, length > 0 else { return nil } - let result = String(utf16CodeUnits: chars, count: length).uppercased() - return result.isEmpty ? nil : result - } + HotKeyChord.layoutCharacter(for: keyCode)?.uppercased() } /// Translate Cocoa's modifier flags into Carbon's bitmask. diff --git a/Sources/Vista/MenuBarContentView.swift b/Sources/Vista/MenuBarContentView.swift index 172fa39..9ba49fb 100644 --- a/Sources/Vista/MenuBarContentView.swift +++ b/Sources/Vista/MenuBarContentView.swift @@ -2,6 +2,7 @@ import SwiftUI import AppKit +import Carbon.HIToolbox import VistaCore // Explicit MainActor — touches AppState (MainActor) and drives window @@ -22,7 +23,7 @@ struct MenuBarContentView: View { Button("Search Screenshots…") { appState.openPanel() } - .keyboardShortcut("s", modifiers: [.command, .shift]) + .keyboardShortcut(appState.preferences.hotKey.asSwiftUIShortcut) Divider() @@ -123,3 +124,56 @@ struct MenuBarContentView: View { } } } + +extension HotKeyChord { + /// Converts this Carbon-backed chord into the SwiftUI shortcut the + /// menu item renders. Returns nil (no shortcut shown) when the chord + /// is empty or the key doesn't map cleanly to a `KeyEquivalent`, so + /// the menu never shows a stale/wrong glyph — the global Carbon + /// hotkey still fires regardless. + var asSwiftUIShortcut: KeyboardShortcut? { + // Carbon virtual keycode 0 is `kVK_ANSI_A`, so `keyCode == 0` + // alone isn't the "cleared" sentinel — only the pair + // (keyCode: 0, modifiers: 0) is. Treating keyCode 0 on its own + // as empty would silently drop the menu glyph for any chord on + // the A key (e.g. Hyper+A). + guard !(keyCode == 0 && modifiers == 0) else { return nil } + guard let equivalent = Self.keyEquivalent(for: keyCode) else { return nil } + + // Carbon also vends an `EventModifiers` type, so the SwiftUI one + // needs to be fully qualified or the type checker can't pick a + // base for `.command` / `.shift` / `.option` / `.control`. + var mods: SwiftUI.EventModifiers = [] + if modifiers & UInt32(cmdKey) != 0 { mods.insert(.command) } + if modifiers & UInt32(shiftKey) != 0 { mods.insert(.shift) } + if modifiers & UInt32(optionKey) != 0 { mods.insert(.option) } + if modifiers & UInt32(controlKey) != 0 { mods.insert(.control) } + + return KeyboardShortcut(equivalent, modifiers: mods) + } + + private static func keyEquivalent(for keyCode: UInt32) -> KeyEquivalent? { + switch Int(keyCode) { + case kVK_Space: return .space + case kVK_Return: return .return + case kVK_Tab: return .tab + case kVK_Escape: return .escape + case kVK_Delete: return .delete + case kVK_ForwardDelete: return .deleteForward + case kVK_LeftArrow: return .leftArrow + case kVK_RightArrow: return .rightArrow + case kVK_UpArrow: return .upArrow + case kVK_DownArrow: return .downArrow + default: + // Translate via the active keyboard layout so AZERTY/QWERTZ + // users see their own glyph, not a QWERTY guess. Lowercased + // because SwiftUI compares case-insensitively and the menu + // draws the glyph in its own case regardless. + guard let name = HotKeyChord.layoutCharacter(for: keyCode), + let ch = name.first else { + return nil + } + return KeyEquivalent(Character(ch.lowercased())) + } + } +}