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
33 changes: 33 additions & 0 deletions Sources/Stag/Capture/KeyCaptureRules.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
16 changes: 5 additions & 11 deletions Sources/Stag/Views/PreferencesWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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
}
Expand Down
61 changes: 61 additions & 0 deletions Tests/StagTests/KeyCaptureRulesTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading