Skip to content
Closed
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
Binary file added .pi/semantic-grep.sqlite
Binary file not shown.
3 changes: 3 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ let package = Package(
targets: [
.executableTarget(
name: "cmdcmd",
swiftSettings: [
.unsafeFlags(["-parse-as-library"]),
],
linkerSettings: [
.linkedFramework("AppKit"),
.linkedFramework("ScreenCaptureKit"),
Expand Down
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,13 @@ Tile order and ignored windows persist per display via `UserDefaults`. Idle wind

### Config file

Right-click the `⌘ ⌘` Dock icon and pick **Open Config…** — that creates `~/Library/Application Support/cmdcmd/config.json` (if missing) and opens it in your default editor. Loaded at app launch; restart after edits.
Right-click the `⌘ ⌘` Dock icon and pick **Settings…** for the built-in settings window, or **Open Config…** to edit `~/Library/Application Support/cmdcmd/config.json` directly. Trigger edits require a restart; visual settings apply immediately from the settings window.

```json
{
"animations": true,
"minimalMode": true,
"displayMode": "dock",
"trigger": "cmd-cmd",
"bindings": {
"h": "move-left",
Expand All @@ -51,6 +53,10 @@ Right-click the `⌘ ⌘` Dock icon and pick **Open Config…** — that creates

`animations: false` skips the show / pick zoom transitions.

`minimalMode: true` is the default. It replaces live window previews with large app icons for a quieter, lighter overlay and avoids asking for Screen Recording permission.

`displayMode` controls where cmdcmd appears: `"dock"`, `"menu-bar"`, or `"hidden"`. Hidden mode removes both Dock and menu bar UI; open the app again from Launchpad/Spotlight/Finder to bring settings back.

`trigger` chooses what summons the overlay. Default `"cmd-cmd"` is the both-Command-keys chord. Anything else is treated as a regular hotkey spec — e.g. `"cmd+shift+space"` or `"f13"` (uses the same shortcut grammar as `bindings`). Hotkeys other than the chord require Accessibility permission to be globally observable.

Binding spec — modifier tokens: `cmd`, `shift`, `opt` (or `option`/`alt`), `ctrl`. Special keys: `esc`, `space`, `return`, `delete`, `left`, `right`, `up`, `down`. Anything else is a single character.
Expand Down Expand Up @@ -79,9 +85,9 @@ On first launch you'll see an onboarding window explaining what the app needs an
- **Screen Recording** — for live tile previews (ScreenCaptureKit).
- **Accessibility** — for the ⌘⌘ chord listener and to raise / forward keys to the chosen window.

Each row has a Grant button that opens the matching pane in System Settings. Click Continue once both are toggled on. Both are required; the app does nothing without them.
Minimal mode is the default and does not show the permission onboarding at launch. If you turn off minimal mode, cmdcmd asks for the permissions needed for live previews. Each row has a Grant button that opens the matching pane in System Settings.

The app shows in the Dock as `⌘ ⌘`. Right-click it for **Open Config…** (or quit it the normal way).
By default the app shows in the Dock as `⌘ ⌘`. Right-click it for **Settings…**, **Open Config…**, or quit it the normal way. You can switch it to menu-bar-only or fully hidden in Settings.

## Layout

Expand All @@ -93,7 +99,8 @@ Sources/cmdcmd/
Overlay.swift # overlay window, tile grid, selection, animations
OverlayView.swift # NSWindow + NSView event router for the overlay
HintPill.swift # bottom-center mode-hint label
Config.swift # JSON config loader (animations, trigger, bindings)
Config.swift # JSON config loader (animations, minimal mode, display mode, trigger, bindings)
SettingsWindow.swift # built-in settings window
Keymap.swift # default shortcuts + override resolver
HotkeyMonitor.swift # global hotkey trigger (alternative to CmdChord)
Tile.swift # per-window SCStream preview layer
Expand Down
43 changes: 39 additions & 4 deletions Sources/cmdcmd/CmdChord.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ final class CmdChord {
private var rightDown = false
private var contaminated = false
private var fired = false
private var eventTap: CFMachPort?
private var eventTapRunLoopSource: CFRunLoopSource?
private let handler: () -> Void

init(handler: @escaping () -> Void) {
Expand All @@ -29,23 +31,56 @@ final class CmdChord {
return e
}
monitors = [global, local, globalKey, localKey].compactMap { $0 }
installEventTap()
}

deinit {
for m in monitors { NSEvent.removeMonitor(m) }
if let eventTap { CGEvent.tapEnable(tap: eventTap, enable: false) }
if let eventTapRunLoopSource { CFRunLoopRemoveSource(CFRunLoopGetMain(), eventTapRunLoopSource, .commonModes) }
}

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

private func installEventTap() {
let mask = (1 << CGEventType.flagsChanged.rawValue) | (1 << CGEventType.keyDown.rawValue)
let callback: CGEventTapCallBack = { _, type, event, userInfo in
guard let userInfo else { return Unmanaged.passUnretained(event) }
let chord = Unmanaged<CmdChord>.fromOpaque(userInfo).takeUnretainedValue()
DispatchQueue.main.async {
if type == .keyDown {
chord.markContaminated()
} else if type == .flagsChanged {
chord.handleFlags(keyCode: Int(event.getIntegerValueField(.keyboardEventKeycode)), flags: event.flags)
}
}
return Unmanaged.passUnretained(event)
}
let ref = Unmanaged.passUnretained(self).toOpaque()
guard let tap = CGEvent.tapCreate(tap: .cgSessionEventTap, place: .headInsertEventTap, options: .listenOnly, eventsOfInterest: CGEventMask(mask), callback: callback, userInfo: ref) else {
Log.write("cmd-cmd event tap unavailable")
return
}
eventTap = tap
let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
eventTapRunLoopSource = source
CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes)
CGEvent.tapEnable(tap: tap, enable: true)
}

private func handleFlags(_ event: NSEvent) {
let cmd = event.modifierFlags.contains(.command)
switch Int(event.keyCode) {
handleFlags(keyCode: Int(event.keyCode), flags: event.cgEvent?.flags ?? CGEventFlags(rawValue: UInt64(event.modifierFlags.rawValue)))
}

private func handleFlags(keyCode: Int, flags: CGEventFlags) {
let raw = flags.rawValue
switch keyCode {
case kVK_Command:
leftDown = cmd && event.modifierFlags.rawValue & 0x8 != 0
leftDown = raw & CGEventFlags.maskCommand.rawValue != 0 && raw & 0x8 != 0
case kVK_RightCommand:
rightDown = cmd && event.modifierFlags.rawValue & 0x10 != 0
rightDown = raw & CGEventFlags.maskCommand.rawValue != 0 && raw & 0x10 != 0
default:
return
}
Expand Down
52 changes: 50 additions & 2 deletions Sources/cmdcmd/Config.swift
Original file line number Diff line number Diff line change
@@ -1,13 +1,48 @@
import Foundation

enum DisplayMode: String, Codable, CaseIterable {
case dock
case menuBar = "menu-bar"
case hidden
}

struct Config: Codable {
var animations: Bool
var minimalMode: Bool
var displayMode: DisplayMode
var trigger: String?
var bindings: [String: Action]
var vimBindings: Bool
var letterJump: Bool

var triggerSpec: String { trigger ?? "cmd-cmd" }

static let `default` = Config(animations: true, trigger: nil, bindings: [:])
enum CodingKeys: String, CodingKey {
case animations, minimalMode, displayMode, trigger, bindings, vimBindings, letterJump
}

init(animations: Bool, minimalMode: Bool, displayMode: DisplayMode, trigger: String?, bindings: [String: Action], vimBindings: Bool, letterJump: Bool) {
self.animations = animations
self.minimalMode = minimalMode
self.displayMode = displayMode
self.trigger = trigger
self.bindings = bindings
self.vimBindings = vimBindings
self.letterJump = letterJump
}

init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
animations = try c.decodeIfPresent(Bool.self, forKey: .animations) ?? true
minimalMode = try c.decodeIfPresent(Bool.self, forKey: .minimalMode) ?? true
displayMode = try c.decodeIfPresent(DisplayMode.self, forKey: .displayMode) ?? .dock
trigger = try c.decodeIfPresent(String.self, forKey: .trigger)
bindings = try c.decodeIfPresent([String: Action].self, forKey: .bindings) ?? [:]
vimBindings = try c.decodeIfPresent(Bool.self, forKey: .vimBindings) ?? true
letterJump = try c.decodeIfPresent(Bool.self, forKey: .letterJump) ?? true
}

static let `default` = Config(animations: true, minimalMode: true, displayMode: .dock, trigger: nil, bindings: [:], vimBindings: true, letterJump: true)

static var fileURL: URL {
URL(fileURLWithPath: NSHomeDirectory())
Expand All @@ -17,9 +52,13 @@ struct Config: Codable {
static let template = """
{
"animations": true,
"minimalMode": true,
"displayMode": "dock",
"trigger": "cmd-cmd",
"bindings": {
}
},
"vimBindings": true,
"letterJump": true
}
"""

Expand All @@ -33,6 +72,15 @@ struct Config: Codable {
}
}

static func save(_ config: Config) throws {
let dir = fileURL.deletingLastPathComponent()
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(config)
try data.write(to: fileURL, options: .atomic)
}

static func ensureExists() throws -> URL {
let dir = fileURL.deletingLastPathComponent()
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
Expand Down
11 changes: 10 additions & 1 deletion Sources/cmdcmd/Keymap.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,15 @@ struct Shortcut: Hashable {
final class Keymap {
private var bindings: [Shortcut: Action] = [:]

init(overrides: [String: Action] = [:]) {
init(overrides: [String: Action] = [:], vimBindings: Bool = true) {
for (raw, action) in Self.defaults {
if let s = Shortcut.parse(raw) { bindings[s] = action }
}
if vimBindings {
for (raw, action) in Self.vimDefaults {
if let s = Shortcut.parse(raw) { bindings[s] = action }
}
}
for (raw, action) in overrides {
if let s = Shortcut.parse(raw) { bindings[s] = action }
}
Expand All @@ -106,4 +111,8 @@ final class Keymap {
"1": .pick1, "2": .pick2, "3": .pick3, "4": .pick4, "5": .pick5,
"6": .pick6, "7": .pick7, "8": .pick8, "9": .pick9,
]

static let vimDefaults: [String: Action] = [
"h": .moveLeft, "l": .moveRight, "k": .moveUp, "j": .moveDown,
]
}
33 changes: 33 additions & 0 deletions Sources/cmdcmd/NSWindowAnimations.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import AppKit

extension NSWindow {
func fadeInAndUp(distance: CGFloat = 50, duration: TimeInterval = 0.125, callback: (() -> Void)? = nil) {
let toFrame = frame
let fromFrame = NSRect(x: toFrame.minX, y: toFrame.minY - distance, width: toFrame.width, height: toFrame.height)
setFrame(fromFrame, display: true)
alphaValue = 0
NSAnimationContext.runAnimationGroup { context in
context.duration = duration
context.timingFunction = CAMediaTimingFunction(controlPoints: 0.4, 0, 0.2, 1)
animator().alphaValue = 1
animator().setFrame(toFrame, display: true)
} completionHandler: {
callback?()
}
}

func fadeOutAndDown(distance: CGFloat = 50, duration: TimeInterval = 0.125, callback: (() -> Void)? = nil) {
let fromFrame = frame
let toFrame = NSRect(x: fromFrame.minX, y: fromFrame.minY - distance, width: fromFrame.width, height: fromFrame.height)
setFrame(fromFrame, display: true)
alphaValue = 1
NSAnimationContext.runAnimationGroup { context in
context.duration = duration
context.timingFunction = CAMediaTimingFunction(controlPoints: 0.4, 0, 0.2, 1)
animator().alphaValue = 0
animator().setFrame(toFrame, display: true)
} completionHandler: {
callback?()
}
}
}
Loading