Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion Scripts/dev-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Comment thread
GordonBeeming marked this conversation as resolved.
}
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
36 changes: 36 additions & 0 deletions Sources/Vista/HotKeyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<CFData>.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
Expand Down
27 changes: 1 addition & 26 deletions Sources/Vista/KeyRecorderView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<CFData>.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.
Expand Down
56 changes: 55 additions & 1 deletion Sources/Vista/MenuBarContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import SwiftUI
import AppKit
import Carbon.HIToolbox
import VistaCore

// Explicit MainActor — touches AppState (MainActor) and drives window
Expand All @@ -22,7 +23,7 @@ struct MenuBarContentView: View {
Button("Search Screenshots…") {
appState.openPanel()
}
.keyboardShortcut("s", modifiers: [.command, .shift])
.keyboardShortcut(appState.preferences.hotKey.asSwiftUIShortcut)

Divider()

Expand Down Expand Up @@ -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()))
}
}
}
Loading