From 55f15d506bf7ea1286242bf2ec30a851557ec405 Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Wed, 22 Apr 2026 23:37:47 +1000 Subject: [PATCH 1/3] Menu shortcut now tracks the configured hotkey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Search Screenshots…" item hardcoded ⇧⌘S, so rebinding the invoke hotkey to anything else (Hyper+S in my case) left the menu displaying the old default — a quiet lie to anyone who's touched Preferences. Read the live HotKeyChord instead and map it to a KeyboardShortcut?. Chords SwiftUI can't represent (empty, non-mappable keycodes) return nil, so the menu drops the glyph instead of showing the wrong one. Co-authored-by: Claude Co-authored-by: GitButler --- Sources/Vista/MenuBarContentView.swift | 79 +++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/Sources/Vista/MenuBarContentView.swift b/Sources/Vista/MenuBarContentView.swift index 172fa39..12a872b 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,79 @@ 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? { + guard keyCode != 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 = layoutCharacter(for: keyCode), let ch = name.first else { + return nil + } + return KeyEquivalent(Character(ch.lowercased())) + } + } + + private 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 + } + } +} From 1bc22b331ab908c17d28497e01d3dafbc74a0f29 Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Wed, 22 Apr 2026 23:37:51 +1000 Subject: [PATCH 2/3] dev-run.sh: clean up app + bundle on tail exit Ctrl-C out of the log tail now means "done dev-running": pkill -x Vista, then rm -rf Distribution/Vista.app. The dev bundle shares com.gordonbeeming.vista with the brew-installed prod copy, so a stale dev build left on disk silently wins the hotkey and UserDefaults on the next launch. The teardown closes that hazard. Scoped to the tailing branch; --no-tail is still fire-and-forget. Co-authored-by: Claude Co-authored-by: GitButler --- Scripts/dev-run.sh | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) 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 From bb02c22da27fdf49eeb223456f9d62fa0d8bb30b Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Wed, 22 Apr 2026 23:47:07 +1000 Subject: [PATCH 3/3] Address Copilot review on #4: shared layout helper + A-key sentinel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bot-review follow-ups on the menu shortcut change: - `HotKeyChord.layoutCharacter(for:)` now owns the UCKeyTranslate → String conversion. `KeyRecorderView.layoutKeyName` and the menu shortcut extension both call into it, each applying its own casing rule at the call site (uppercase for the recorder badge, lowercase for SwiftUI's `KeyEquivalent`). Kills the two near-identical copies of the layout-translation plumbing. - The "cleared chord" sentinel was `keyCode != 0`, but Carbon virtual keycode 0 is `kVK_ANSI_A`, so any chord on the A key (e.g. Hyper+A) was silently returning nil and dropping the menu glyph. Guard on `(keyCode == 0 && modifiers == 0)` instead — only the fully-empty chord means "no shortcut". Co-authored-by: Claude Co-authored-by: GitButler --- Sources/Vista/HotKeyManager.swift | 36 ++++++++++++++++++++++++ Sources/Vista/KeyRecorderView.swift | 27 +----------------- Sources/Vista/MenuBarContentView.swift | 39 ++++++-------------------- 3 files changed, 45 insertions(+), 57 deletions(-) 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 12a872b..9ba49fb 100644 --- a/Sources/Vista/MenuBarContentView.swift +++ b/Sources/Vista/MenuBarContentView.swift @@ -132,7 +132,12 @@ extension HotKeyChord { /// the menu never shows a stale/wrong glyph — the global Carbon /// hotkey still fires regardless. var asSwiftUIShortcut: KeyboardShortcut? { - guard keyCode != 0 else { return nil } + // 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 @@ -164,39 +169,11 @@ extension HotKeyChord { // 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 = layoutCharacter(for: keyCode), let ch = name.first else { + guard let name = HotKeyChord.layoutCharacter(for: keyCode), + let ch = name.first else { return nil } return KeyEquivalent(Character(ch.lowercased())) } } - - private 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 - } - } }