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
5 changes: 5 additions & 0 deletions .changeset/f3ac57bd.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
bump: minor
---

Add an All Spaces window scope, and make repeated Command-key chords trigger reliably.
108 changes: 88 additions & 20 deletions Sources/cmdcmd/CmdChord.swift
Original file line number Diff line number Diff line change
@@ -1,16 +1,58 @@
import AppKit
import Carbon.HIToolbox

private struct CmdChordStateMachine {
private var leftDown = false
private var rightDown = false
private var contaminated = false
private var fired = false

mutating func markContaminated() {
if leftDown || rightDown { contaminated = true }
}

mutating func handleFlags(keyCode: Int, flags: CGEventFlags) -> Bool {
let wasBothDown = leftDown && rightDown
let raw = flags.rawValue
switch keyCode {
case kVK_Command:
leftDown = raw & CGEventFlags.maskCommand.rawValue != 0 && raw & 0x8 != 0
case kVK_RightCommand:
rightDown = raw & CGEventFlags.maskCommand.rawValue != 0 && raw & 0x10 != 0
default:
return false
}

if !leftDown && !rightDown {
contaminated = false
fired = false
return false
}

// A clean chord may be repeated while one Command key stays held:
// both down -> exactly one down resets `fired`, so pressing the other
// Command again can toggle. If any normal key contaminated the chord,
// keep blocking until both Command keys are released.
let isExactlyOneDown = leftDown != rightDown
if wasBothDown && isExactlyOneDown && !contaminated {
fired = false
}

if leftDown && rightDown && !contaminated && !fired {
fired = true
return true
}
return false
}
}

/// Fires when both the left and right Command keys are held simultaneously,
/// with no other key pressed during the chord.
final class CmdChord {
private var monitors: [Any] = []
private var eventTap: CFMachPort?
private var eventTapRunLoopSource: CFRunLoopSource?
private var leftDown = false
private var rightDown = false
private var contaminated = false
private var fired = false
private var state = CmdChordStateMachine()
private let handler: () -> Void

init(handler: @escaping () -> Void) {
Expand Down Expand Up @@ -78,7 +120,7 @@ final class CmdChord {
}

private func markContaminated() {
if leftDown || rightDown { contaminated = true }
state.markContaminated()
}

private func handleFlags(_ event: NSEvent) {
Expand All @@ -87,25 +129,51 @@ final class CmdChord {
}

private func handleFlags(keyCode: Int, flags: CGEventFlags) {
let raw = flags.rawValue
switch keyCode {
case kVK_Command:
leftDown = raw & CGEventFlags.maskCommand.rawValue != 0 && raw & 0x8 != 0
case kVK_RightCommand:
rightDown = raw & CGEventFlags.maskCommand.rawValue != 0 && raw & 0x10 != 0
default:
return
if state.handleFlags(keyCode: keyCode, flags: flags) {
DispatchQueue.main.async { self.handler() }
}
}

if !leftDown && !rightDown {
contaminated = false
fired = false
return
static func selfTestFailures() -> [String] {
let left = CGEventFlags(rawValue: CGEventFlags.maskCommand.rawValue | 0x8)
let right = CGEventFlags(rawValue: CGEventFlags.maskCommand.rawValue | 0x10)
let both = CGEventFlags(rawValue: CGEventFlags.maskCommand.rawValue | 0x8 | 0x10)
let none = CGEventFlags(rawValue: 0)
var failures: [String] = []

func expect(_ condition: @autoclosure () -> Bool, _ message: String) {
if !condition() { failures.append(message) }
}

if leftDown && rightDown && !contaminated && !fired {
fired = true
DispatchQueue.main.async { self.handler() }
do {
var s = CmdChordStateMachine()
expect(!s.handleFlags(keyCode: kVK_Command, flags: left), "left down alone fired")
expect(s.handleFlags(keyCode: kVK_RightCommand, flags: both), "left-held/right-tap did not fire")
expect(!s.handleFlags(keyCode: kVK_RightCommand, flags: left), "right release fired")
expect(s.handleFlags(keyCode: kVK_RightCommand, flags: both), "left-held second right-tap did not fire")
}

do {
var s = CmdChordStateMachine()
expect(!s.handleFlags(keyCode: kVK_RightCommand, flags: right), "right down alone fired")
expect(s.handleFlags(keyCode: kVK_Command, flags: both), "right-held/left-tap did not fire")
expect(!s.handleFlags(keyCode: kVK_Command, flags: right), "left release fired")
expect(s.handleFlags(keyCode: kVK_Command, flags: both), "right-held second left-tap did not fire")
}

do {
var s = CmdChordStateMachine()
expect(!s.handleFlags(keyCode: kVK_Command, flags: left), "contamination setup fired")
s.markContaminated()
expect(!s.handleFlags(keyCode: kVK_RightCommand, flags: both), "contaminated chord fired")
expect(!s.handleFlags(keyCode: kVK_RightCommand, flags: left), "contaminated right release fired")
expect(!s.handleFlags(keyCode: kVK_RightCommand, flags: both), "contaminated repeat fired before full release")
expect(!s.handleFlags(keyCode: kVK_RightCommand, flags: left), "contaminated repeat release fired")
expect(!s.handleFlags(keyCode: kVK_Command, flags: none), "full release fired")
expect(!s.handleFlags(keyCode: kVK_Command, flags: left), "clean restart left down fired")
expect(s.handleFlags(keyCode: kVK_RightCommand, flags: both), "clean chord after full release did not fire")
}

return failures
}
}
13 changes: 12 additions & 1 deletion Sources/cmdcmd/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ enum TilePicks: String, Codable, CaseIterable {
case letters
}

enum WindowScope: String, Codable, CaseIterable {
case currentSpace = "current-space"
case allSpaces = "all-spaces"
}

struct Config: Codable {
var animations: Bool
var animationSpeed: Double?
Expand All @@ -21,6 +26,7 @@ struct Config: Codable {
var letterJump: Bool?
var usageOrdering: Bool?
var tilePicks: TilePicks?
var windowScope: String?

var animationSpeedOrDefault: Double {
guard let animationSpeed, animationSpeed.isFinite else { return 1.0 }
Expand All @@ -32,8 +38,9 @@ struct Config: Codable {
var letterJumpEnabled: Bool { letterJump ?? true }
var usageOrderingEnabled: Bool { usageOrdering ?? false }
var tilePicksMode: TilePicks { tilePicks ?? .letters }
var windowScopeMode: WindowScope { WindowScope(rawValue: windowScope ?? "") ?? .currentSpace }

static let `default` = Config(animations: true, animationSpeed: nil, trigger: nil, bindings: [:], livePreviews: nil, displayMode: nil, letterJump: nil, usageOrdering: nil, tilePicks: nil)
static let `default` = Config(animations: true, animationSpeed: nil, trigger: nil, bindings: [:], livePreviews: nil, displayMode: nil, letterJump: nil, usageOrdering: nil, tilePicks: nil, windowScope: nil)

static var fileURL: URL {
URL(fileURLWithPath: NSHomeDirectory())
Expand Down Expand Up @@ -370,6 +377,10 @@ struct Config: Codable {
lines.append(" // faster and lighter, especially with many windows open.")
lines.append(" \"livePreviews\": true,")
lines.append("")
lines.append(" // Which windows to show: \"current-space\" keeps existing behavior;")
lines.append(" // \"all-spaces\" also includes windows from other macOS Spaces.")
lines.append(" \"windowScope\": \"current-space\",")
lines.append("")
lines.append(" // What summons the overlay. \"cmd-cmd\" is the both-Command-keys chord.")
lines.append(" // Anything else is a normal hotkey: \"cmd+shift+space\", \"f13\", etc.")
lines.append(" \"trigger\": \"cmd-cmd\",")
Expand Down
Loading
Loading