From 78d7a06d246ff760e0a46231319623ec6b8e6108 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 01:42:39 +0000 Subject: [PATCH] Refactor: extract key-capture accept/translate rules into KeyCaptureRules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ShortcutCapture (global hotkeys) and EditorKeyCapture (editor tool rebinding) embedded their pure accept/translate decisions inside NSEvent monitor closures, where they couldn't be tested. Lift them out: - HotKeyCaptureRule.combination(keyCode:modifiers:) — requires >=1 device- independent modifier, else nil. - EditorToolKey.binding(char:modifiers:) — lowercases, validates the char set, prefixes the Shift glyph only when Shift alone is held. The single-flight monitor mechanism (which is NSEvent-coupled) stays in place; only the testable rules move. Behavior-preserving. Adds KeyCaptureRulesTests (10 cases). --- Sources/Stag/Capture/KeyCaptureRules.swift | 33 ++++++++++++ Sources/Stag/Views/PreferencesWindow.swift | 16 ++---- Tests/StagTests/KeyCaptureRulesTests.swift | 61 ++++++++++++++++++++++ 3 files changed, 99 insertions(+), 11 deletions(-) create mode 100644 Sources/Stag/Capture/KeyCaptureRules.swift create mode 100644 Tests/StagTests/KeyCaptureRulesTests.swift diff --git a/Sources/Stag/Capture/KeyCaptureRules.swift b/Sources/Stag/Capture/KeyCaptureRules.swift new file mode 100644 index 0000000..ae24842 --- /dev/null +++ b/Sources/Stag/Capture/KeyCaptureRules.swift @@ -0,0 +1,33 @@ +import Cocoa + +/// Pure input rule for the editor's single-key tool rebinding, extracted from +/// EditorKeyCapture so the accept/translate decision can be tested without an +/// NSEvent monitor. +enum EditorToolKey { + /// Valid binding characters: alphanumerics plus a handful of punctuation keys. + private static let valid = CharacterSet.alphanumerics + .union(CharacterSet(charactersIn: "-=[];',./\\`")) + + /// Translates a key press into a tool-binding string, or `nil` to ignore it. + /// Lowercases the character, rejects empty/invalid input, and prefixes "⇧" + /// when Shift — and only Shift — is held. + static func binding(char: String, modifiers: NSEvent.ModifierFlags) -> String? { + let mods = modifiers.intersection(.deviceIndependentFlagsMask) + let lower = char.lowercased() + guard !lower.isEmpty, + lower.unicodeScalars.allSatisfy({ valid.contains($0) }) else { return nil } + return mods == .shift ? "\u{21E7}\(lower)" : lower + } +} + +/// Pure rule for recording a global hotkey, extracted from ShortcutCapture. +enum HotKeyCaptureRule { + /// Builds the combination to record from a keyDown's keyCode + modifiers, or + /// `nil` to ignore it. Requires at least one device-independent modifier, so + /// bare keys can't be bound as global shortcuts. + static func combination(keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> HotKeyCombination? { + let mods = modifiers.intersection(.deviceIndependentFlagsMask) + guard !mods.isEmpty else { return nil } + return HotKeyCombination(keyCode: keyCode, modifiers: mods.rawValue) + } +} diff --git a/Sources/Stag/Views/PreferencesWindow.swift b/Sources/Stag/Views/PreferencesWindow.swift index be28010..c60802e 100644 --- a/Sources/Stag/Views/PreferencesWindow.swift +++ b/Sources/Stag/Views/PreferencesWindow.swift @@ -860,9 +860,9 @@ private final class ShortcutCapture { capture.monitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .flagsChanged]) { event in if event.type == .flagsChanged { return nil } // ignore bare modifier presses if event.keyCode == 53 { capture.finish(nil); return nil } // Esc cancels - let mods = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - guard !mods.isEmpty else { return nil } // require ≥1 modifier; swallow bare keys - capture.finish(HotKeyCombination(keyCode: event.keyCode, modifiers: mods.rawValue)) + guard let combo = HotKeyCaptureRule.combination(keyCode: event.keyCode, + modifiers: event.modifierFlags) else { return nil } + capture.finish(combo) return nil } } @@ -1023,14 +1023,8 @@ private final class EditorKeyCapture { active = capture capture.monitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown]) { event in if event.keyCode == 53 { capture.finish(nil); return nil } // Esc → cancel - let mods = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - guard let char = event.charactersIgnoringModifiers?.lowercased(), !char.isEmpty else { - return nil - } - // Only accept alphanumeric, digits, and a few symbols; reject modifiers-only - let validChars = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-=[];',./\\`")) - guard char.unicodeScalars.allSatisfy({ validChars.contains($0) }) else { return nil } - let key = mods == .shift ? "⇧\(char)" : char + guard let raw = event.charactersIgnoringModifiers, + let key = EditorToolKey.binding(char: raw, modifiers: event.modifierFlags) else { return nil } capture.finish(key) return nil } diff --git a/Tests/StagTests/KeyCaptureRulesTests.swift b/Tests/StagTests/KeyCaptureRulesTests.swift new file mode 100644 index 0000000..78d2820 --- /dev/null +++ b/Tests/StagTests/KeyCaptureRulesTests.swift @@ -0,0 +1,61 @@ +import XCTest +import Cocoa +@testable import Stag + +/// Pure key-capture rules extracted from EditorKeyCapture / ShortcutCapture. +final class KeyCaptureRulesTests: XCTestCase { + + // MARK: EditorToolKey + + func testPlainLetterPassesThrough() { + XCTAssertEqual(EditorToolKey.binding(char: "a", modifiers: []), "a") + } + + func testUppercaseIsLowercased() { + XCTAssertEqual(EditorToolKey.binding(char: "A", modifiers: []), "a") + } + + func testShiftPrefixesArrow() { + XCTAssertEqual(EditorToolKey.binding(char: "a", modifiers: .shift), "\u{21E7}a") + } + + func testNonShiftModifierDoesNotPrefix() { + // Only an exact Shift gets the ⇧ prefix; Command alone does not. + XCTAssertEqual(EditorToolKey.binding(char: "a", modifiers: .command), "a") + } + + func testShiftPlusOtherModifierIsNotTreatedAsShift() { + XCTAssertEqual(EditorToolKey.binding(char: "a", modifiers: [.shift, .command]), "a") + } + + func testValidSymbolAccepted() { + XCTAssertEqual(EditorToolKey.binding(char: "/", modifiers: []), "/") + XCTAssertEqual(EditorToolKey.binding(char: "5", modifiers: []), "5") + } + + func testEmptyAndInvalidRejected() { + XCTAssertNil(EditorToolKey.binding(char: "", modifiers: [])) + XCTAssertNil(EditorToolKey.binding(char: " ", modifiers: [])) + } + + // MARK: HotKeyCaptureRule + + func testCombinationRequiresModifier() { + XCTAssertNil(HotKeyCaptureRule.combination(keyCode: 18, modifiers: [])) + } + + func testCombinationKeepsKeyCodeAndModifiers() { + let combo = HotKeyCaptureRule.combination(keyCode: 18, modifiers: [.command, .shift]) + XCTAssertEqual(combo?.keyCode, 18) + let expected = NSEvent.ModifierFlags([.command, .shift]).rawValue + XCTAssertEqual(combo?.modifiers, expected) + } + + func testCombinationStripsNonDeviceIndependentBits() { + // .capsLock is device-independent and should survive; throw in a high bit + // that isn't part of deviceIndependentFlagsMask to confirm it's stripped. + let stray = NSEvent.ModifierFlags(rawValue: NSEvent.ModifierFlags.command.rawValue | (1 << 1)) + let combo = HotKeyCaptureRule.combination(keyCode: 1, modifiers: stray) + XCTAssertEqual(combo?.modifiers, NSEvent.ModifierFlags.command.rawValue) + } +}