diff --git a/Sources/Stag/Models/HotKeyCombination+Display.swift b/Sources/Stag/Models/HotKeyCombination+Display.swift new file mode 100644 index 0000000..5d0bfc9 --- /dev/null +++ b/Sources/Stag/Models/HotKeyCombination+Display.swift @@ -0,0 +1,39 @@ +import Cocoa + +extension HotKeyCombination { + /// Human-readable shortcut string, e.g. "⇧⌘1". Modifiers are emitted in the + /// macOS-standard order (⌃⌥⇧⌘) followed by the key label. Returns "" when no + /// key is set (keyCode 0); callers present their own placeholder there. + var displayString: String { + guard keyCode != 0 else { return "" } + var parts: [String] = [] + let flags = modifierFlags + if flags.contains(.control) { parts.append("\u{2303}") } // ⌃ + if flags.contains(.option) { parts.append("\u{2325}") } // ⌥ + if flags.contains(.shift) { parts.append("\u{21E7}") } // ⇧ + if flags.contains(.command) { parts.append("\u{2318}") } // ⌘ + parts.append(Self.keyName(keyCode)) + return parts.joined() + } + + /// Maps a macOS ANSI virtual keycode to its display label. Unknown codes fall + /// back to "Key". + static func keyName(_ code: UInt16) -> String { + keyNames[code] ?? "Key\(code)" + } + + /// True macOS ANSI keycodes (NOT sequential — e.g. kVK_ANSI_5 = 23, _6 = 22). + private static let keyNames: [UInt16: String] = [ + 18: "1", 19: "2", 20: "3", 21: "4", 23: "5", 22: "6", + 26: "7", 28: "8", 25: "9", 29: "0", + 24: "=", 27: "-", + 12: "Q", 13: "W", 14: "E", 15: "R", 16: "T", 17: "Y", + 32: "U", 34: "I", 31: "O", 35: "P", + 0: "A", 1: "S", 2: "D", 3: "F", 4: "H", 5: "G", + 38: "J", 40: "K", 37: "L", + 45: "N", 46: "M", + 6: "Z", 7: "X", 8: "C", 9: "V", 11: "B", + 49: "Space", + 36: "Return", 53: "Esc", 48: "Tab", 51: "Delete", + ] +} diff --git a/Sources/Stag/Views/PreferencesWindow.swift b/Sources/Stag/Views/PreferencesWindow.swift index c13cf55..0b87f40 100644 --- a/Sources/Stag/Views/PreferencesWindow.swift +++ b/Sources/Stag/Views/PreferencesWindow.swift @@ -830,33 +830,7 @@ private struct ShortcutRecorder: View { private var displayText: String { if isRecording { return "Press shortcut\u{2026}" } if current.keyCode == 0 { return "None" } - var parts: [String] = [] - let flags = current.modifierFlags - if flags.contains(.control) { parts.append("\u{2303}") } - if flags.contains(.option) { parts.append("\u{2325}") } - if flags.contains(.shift) { parts.append("\u{21E7}") } - if flags.contains(.command) { parts.append("\u{2318}") } - let key = keyName(current.keyCode) - parts.append(key) - return parts.joined() - } - - private func keyName(_ code: UInt16) -> String { - let map: [UInt16: String] = [ - // True macOS ANSI digit keycodes (NOT sequential). - 18: "1", 19: "2", 20: "3", 21: "4", 23: "5", 22: "6", - 26: "7", 28: "8", 25: "9", 29: "0", - 24: "=", 27: "-", - 12: "Q", 13: "W", 14: "E", 15: "R", 16: "T", 17: "Y", - 32: "U", 34: "I", 31: "O", 35: "P", - 0: "A", 1: "S", 2: "D", 3: "F", 4: "H", 5: "G", - 38: "J", 40: "K", 37: "L", - 45: "N", 46: "M", - 6: "Z", 7: "X", 8: "C", 9: "V", 11: "B", - 49: "Space", - 36: "Return", 53: "Esc", 48: "Tab", 51: "Delete", - ] - return map[code] ?? "Key\(code)" + return current.displayString } } diff --git a/Tests/StagTests/HotKeyCombinationDisplayTests.swift b/Tests/StagTests/HotKeyCombinationDisplayTests.swift new file mode 100644 index 0000000..4b556af --- /dev/null +++ b/Tests/StagTests/HotKeyCombinationDisplayTests.swift @@ -0,0 +1,42 @@ +import XCTest +import Cocoa +@testable import Stag + +/// Display-string formatting extracted from PreferencesWindow's shortcut row. +final class HotKeyCombinationDisplayTests: XCTestCase { + + private func combo(_ keyCode: UInt16, _ flags: NSEvent.ModifierFlags) -> HotKeyCombination { + HotKeyCombination(keyCode: keyCode, modifiers: flags.rawValue) + } + + func testCommandShiftDigit() { + // keyCode 18 == "1"; modifiers render in ⌃⌥⇧⌘ order. + XCTAssertEqual(combo(18, [.command, .shift]).displayString, "\u{21E7}\u{2318}1") + } + + func testModifierOrderingIsControlOptionShiftCommand() { + let s = combo(8, [.command, .control, .shift, .option]).displayString // 8 == "C" + XCTAssertEqual(s, "\u{2303}\u{2325}\u{21E7}\u{2318}C") + } + + func testNonSequentialDigitKeycodes() { + // Regression guard: kVK_ANSI_5 = 23, kVK_ANSI_6 = 22. + XCTAssertEqual(HotKeyCombination.keyName(23), "5") + XCTAssertEqual(HotKeyCombination.keyName(22), "6") + } + + func testUnknownKeycodeFallsBack() { + XCTAssertEqual(HotKeyCombination.keyName(99), "Key99") + XCTAssertEqual(combo(99, [.command]).displayString, "\u{2318}Key99") + } + + func testZeroKeyCodeIsEmpty() { + XCTAssertEqual(combo(0, [.command]).displayString, "") + } + + func testSpecialKeyLabels() { + XCTAssertEqual(HotKeyCombination.keyName(49), "Space") + XCTAssertEqual(HotKeyCombination.keyName(36), "Return") + XCTAssertEqual(HotKeyCombination.keyName(53), "Esc") + } +}