diff --git a/README.md b/README.md index fc8db2c..15e300e 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ forward** — Herdr selects the pane but leaves the window where it was, so an agent key pressed from a browser used to move a cursor you could not see. Set `WL_TERMINAL_BUNDLE_ID` if your panes do not live in Ghostty. -**Row 3** is actions: +**Row 3** is actions — all four are overridable from `config.json`, stack/tabs/land included: | key | what it does | |---|---| @@ -62,8 +62,10 @@ agent key pressed from a browser used to move a cursor you could not see. Set Superwhisper — and one more macro key. -**The dial** tunes reasoning effort. **The joystick** switches model: it puts -the model list on screen, north and south move, east confirms, west cancels. +**The dial** tunes reasoning effort by default; the `"dial"` config key can +point it at Herdr navigation instead — stepping the focused agent, tab, or +workspace. **The joystick** switches model: it puts the model list on screen, +north and south move, east confirms, west cancels. ### The stack key @@ -148,18 +150,43 @@ the dial and joystick offer, drop a `config.json` into "keys": { "9": "Open PRs for all active GitButler branches", "12": "Run but pull", - "10+11": "Summarise what you are working on" + "10+11": "Summarise what you are working on", + "6": { "shortcut": "cmd+shift+5" }, + "7": { "shortcut": "f13" } }, + "dial": "effort", "claude": { "models": ["fable", "opus"], "efforts": ["low", "high"] }, "codex": { "models": ["gpt-5.6-sol", "gpt-5.6-codex"] } } ``` A bound string is injected into the focused agent's prompt, unsubmitted — you -still read it and press enter. `"10+11"` addresses the wide key as one; `"10"` -and `"11"` address its halves. Keys the file does not mention keep their -defaults, and an empty string unbinds a key outright. Binding the wide key to -text replaces its right-command tap. +still read it and press enter. A key can be bound to a keyboard shortcut +instead — `{ "shortcut": "cmd+shift+5" }` — which is synthesised system-wide +regardless of what Herdr is doing: launch an app's own hotkey, take a +screenshot, trigger Mission Control, anything a physical key combo can do. +Modifiers (`cmd`/`command`, `shift`, `opt`/`option`/`alt`, `ctrl`/`control`) go +in any order, `+`-joined, with exactly one base key — a letter, digit, named +punctuation key (`equal`, `minus`, `comma`, …), or a named key (`space`, +`tab`, `return`, `escape`, `delete`, `up`/`down`/`left`/`right`, `f1`–`f19`). +An unrecognised shortcut string does nothing and reports itself as an error +the next time you press that key. A synthesised shortcut needs the +**Accessibility** permission, same as the wide key's right-command tap. + +`"10+11"` addresses the wide key as one; `"10"` and `"11"` address its +halves. **Every spare key is overridable this way, including the stack (6), +tabs (7), and land (8) keys** — binding one replaces its built-in job +entirely. Keys the file does not mention keep their defaults: 9 and 12 type +their text macros, the wide key taps right-command, and 6/7/8 stay +stack/tabs/land. An empty string (or `{"shortcut": ""}`) unbinds a key +outright, back to nothing. + +`"dial"` sets what the knob does. `"effort"` (the default) climbs the +reasoning-effort ladder for the focused agent; `"agent"` steps focus through +the agents in sidebar order; `"tab"` cycles the tabs of the focused workspace; +`"space"` (or `"workspace"`) steps through workspaces. All four wrap at the +ends and follow the turn direction. An unrecognised value falls back to +`"effort"`. `codex.models` is your copy of what Codex's own `/model` menu offers, in its order — the joystick steers that menu rather than owning it, so there is nothing diff --git a/Sources/WLKit/BridgeController.swift b/Sources/WLKit/BridgeController.swift index bf18e02..2a6c9be 100644 --- a/Sources/WLKit/BridgeController.swift +++ b/Sources/WLKit/BridgeController.swift @@ -56,6 +56,9 @@ public final class BridgeController: ObservableObject { public var onLandKey: (() -> Void)? /// Called when either half of the wide voice key is pressed. public var onVoiceKey: (() -> Void)? + /// Called for a key bound to a shortcut, once it has parsed cleanly. + /// Posting the event is app-layer (needs AppKit and Accessibility). + public var onShortcut: ((ShortcutSpec) -> Void)? /// Called per dial detent: +1 clockwise, -1 counter-clockwise. public var onDial: ((Int) -> Void)? /// Called when the joystick enters a cardinal sector. @@ -193,12 +196,19 @@ public final class BridgeController: ObservableObject { lastError = nil } catch { deviceConnected = false - let message = error.localizedDescription - if message.contains("0xE00002C1") || message.contains("Input Monitoring") { + // Ask the API that knows about the grant rather than reading the + // error code, which says "not permitted" for a wedged pad too. + let granted = IOHIDCheckAccess(kIOHIDRequestTypeListenEvent) == kIOHIDAccessTypeGranted + switch DeviceOpenFailure.classify(accessGranted: granted, message: error.localizedDescription) { + case .permissionMissing: permissionDenied = true - if !warnedPermission { warnedPermission = true; lastError = message } - } else { - lastError = message + if !warnedPermission { + warnedPermission = true + lastError = DeviceOpenFailure.permissionMissing.message + } + case .deviceUnavailable(let underlying): + permissionDenied = false + lastError = DeviceOpenFailure.deviceUnavailable(underlying).message } scheduleReopen() return @@ -332,22 +342,29 @@ public final class BridgeController: ObservableObject { reconcileStatusStreams(fetched) let state = StatusMapper.aggregate(fetched, config) - // Macro and voice keys share ids, so the binding decides each key's - // light: configured text wins, the wide key falls back to voice. - let flexKeys = (Pad.macroKeyIDs + Pad.voiceKeyIDs).map { key -> OAI.Thread in - if keyBindings.text(for: key) != nil { + // Every overridable key shares this light: a binding (text or + // shortcut) wins and paints the generic macro colour; an unbound key + // falls back to whatever it does by default. + let flexKeys = Pad.overridableKeyIDs.map { key -> OAI.Thread in + switch keyBindings.action(for: key) { + case .text, .shortcut: return StatusMapper.macroThread(id: key, config) + case .off: + return OAI.Thread(id: key, brightness: 0, effect: .off) + case nil: + switch key { + case Pad.stackKeyID: return StatusMapper.stackThread(open: stackPanelOpen, config) + case Pad.tabCycleKeyID: return StatusMapper.tabCycleThread(config) + case Pad.landKeyID: return StatusMapper.landThread(open: landPanelOpen, config) + default: + guard Pad.voiceKeyIDs.contains(key) else { + return OAI.Thread(id: key, brightness: 0, effect: .off) + } + return StatusMapper.voiceThread(id: key, active: voiceActive, config) + } } - if Pad.voiceKeyIDs.contains(key) { - return StatusMapper.voiceThread(id: key, active: voiceActive, config) - } - return OAI.Thread(id: key, brightness: 0, effect: .off) } - let threads = StatusMapper.threads(for: fetched, config) - + [StatusMapper.stackThread(open: stackPanelOpen, config), - StatusMapper.tabCycleThread(config), - StatusMapper.landThread(open: landPanelOpen, config)] - + flexKeys + let threads = StatusMapper.threads(for: fetched, config) + flexKeys // Fingerprint the whole rendered picture, not just the aggregate, so // one agent changing still repaints when the worst state has not. @@ -413,20 +430,21 @@ public final class BridgeController: ObservableObject { /// has to agree with `Pad`, so keep the dispatch in a single switch. public func handleKeyPress(_ index: Int) { if onKeyIntercept?(index) == true { return } + // A config binding wins over any of these keys' built-in role — this + // is how the config file may repurpose stack, tabs, land, the macro + // keys, or the wide voice key. + if Pad.overridableKeyIDs.contains(index), let action = keyBindings.action(for: index) { + perform(action) + return + } if index == Pad.stackKeyID { onStackKey?() } else if index == Pad.tabCycleKeyID { Task { await cycleTabs() } } else if index == Pad.landKeyID { onLandKey?() - } else if Pad.macroKeyIDs.contains(index) || Pad.voiceKeyIDs.contains(index) { - // A configured text macro wins over the key's built-in role, which - // is how the config file may repurpose the wide voice key. - if let text = keyBindings.text(for: index) { - Task { await injectPrompt(text) } - } else if Pad.voiceKeyIDs.contains(index) { - onVoiceKey?() - } + } else if Pad.voiceKeyIDs.contains(index) { + onVoiceKey?() } else if index == Pad.dialUpID || index == Pad.dialDownID { onDial?(index == Pad.dialUpID ? 1 : -1) } else if let direction = Pad.JoystickDirection(keyID: index) { @@ -436,6 +454,24 @@ public final class BridgeController: ObservableObject { } } + /// Runs a key's bound action. A shortcut is validated here rather than at + /// config-load time, so a typo surfaces as a panel error on the press that + /// hits it, not a silently dropped binding. + private func perform(_ action: KeyBindings.KeyAction) { + switch action { + case .off: + break // explicitly inert — beats the built-in job on purpose + case .text(let text): + Task { await injectPrompt(text) } + case .shortcut(let spec): + guard let parsed = ShortcutSpec.parse(spec) else { + lastError = "Unrecognised shortcut \"\(spec)\"." + return + } + onShortcut?(parsed) + } + } + /// Types a macro string into the focused agent's prompt, unsubmitted — /// the human still reads it and presses enter. public func injectPrompt(_ text: String) async { @@ -474,6 +510,52 @@ public final class BridgeController: ObservableObject { } } + /// The dial as a Herdr navigator. `step` is +1 / -1 from the encoder + /// detents; `.effort` is handled in the app layer and never reaches here. + public func cycleDial(_ step: Int, mode: KeyBindings.DialMode) async { + switch mode { + case .effort: break + case .agent: await cycleAgent(step) + case .tab: await cycleTabsBy(step) + case .space: await cycleWorkspace(step) + } + } + + private func cycleTabsBy(_ step: Int) async { + do { + try await HerdrClient.cycleTabs(step) + } catch { + lastError = error.localizedDescription + } + } + + /// Steps focus to the next/previous agent, in the same reading order the + /// keys use, and brings the terminal forward as an agent key would. + private func cycleAgent(_ step: Int) async { + do { + guard let next = HerdrClient.adjacentAgent( + in: try await HerdrClient.listAgents(), step: step + ), let target = next.focusTarget else { return } + try await HerdrClient.focusAgent(target) + raiseTerminal() + } catch { + lastError = error.localizedDescription + } + } + + /// Steps focus to the next/previous workspace in sidebar order. + private func cycleWorkspace(_ step: Int) async { + do { + guard let next = HerdrClient.adjacentWorkspace( + in: try await HerdrClient.listWorkspaces(), step: step + ) else { return } + try await HerdrClient.focusWorkspace(next.workspaceID) + raiseTerminal() + } catch { + lastError = error.localizedDescription + } + } + /// Repaints the stack key. Called by the app when the window opens or /// closes, so the key reflects what is actually on screen. public func setStackPanelOpen(_ open: Bool) async { diff --git a/Sources/WLKit/DeviceOpenFailure.swift b/Sources/WLKit/DeviceOpenFailure.swift new file mode 100644 index 0000000..a670d65 --- /dev/null +++ b/Sources/WLKit/DeviceOpenFailure.swift @@ -0,0 +1,33 @@ +import Foundation + +/// Why the pad would not open, in terms someone can act on. +/// +/// `IOHIDDeviceOpen` returns `kIOReturnNotPermitted` (0xE00002E2) for a missing +/// Input Monitoring grant *and* for a device that is already held or has got +/// itself wedged, so reading the code alone gets it wrong half the time. The +/// original check only recognised the older 0xE00002C1, so on current systems a +/// permissions failure fell through and the menu simply said it was looking for +/// the pad, which sends you hunting for a cable when the answer is a checkbox. +public enum DeviceOpenFailure: Equatable { + case permissionMissing + case deviceUnavailable(String) + + /// `accessGranted` comes from `IOHIDCheckAccess`, the only caller that + /// actually knows the state of the grant. An error that names a permission + /// outright is still believed, in case the check itself is stale. + public static func classify(accessGranted: Bool, message: String) -> DeviceOpenFailure { + if message.contains("0xE00002C1") || message.contains("Input Monitoring") { + return .permissionMissing + } + return accessGranted ? .deviceUnavailable(message) : .permissionMissing + } + + public var message: String { + switch self { + case .permissionMissing: + return "Input Monitoring is not granted. System Settings › Privacy & Security › Input Monitoring, then switch the manager off and on." + case .deviceUnavailable(let underlying): + return "The pad did not open. Something else may be holding it, or it needs a reconnect - unplug it, wait a moment, plug it back in. (\(underlying))" + } + } +} diff --git a/Sources/WLKit/HerdrClient.swift b/Sources/WLKit/HerdrClient.swift index 29387e9..c59cfd1 100644 --- a/Sources/WLKit/HerdrClient.swift +++ b/Sources/WLKit/HerdrClient.swift @@ -100,6 +100,26 @@ public struct HerdrTab: Equatable, Sendable { } } +public struct HerdrWorkspace: Equatable, Sendable { + public var workspaceID: String + /// Display position in the sidebar, which is the order workspaces cycle in. + public var number: Int + public var focused: Bool + + init(json: [String: Any]) { + workspaceID = json["workspace_id"] as? String ?? "" + number = json["number"] as? Int ?? 0 + focused = json["focused"] as? Bool ?? false + } + + /// For tests. + public init(workspaceID: String, number: Int, focused: Bool = false) { + self.workspaceID = workspaceID + self.number = number + self.focused = focused + } +} + public enum HerdrError: LocalizedError { case cannotConnect(String, String) case timeout(String) @@ -205,6 +225,18 @@ public enum HerdrClient { _ = try await request("agent.focus", params: ["target": target]) } + /// Workspaces (Herdr's "spaces") in `number` order — the sidebar order. + public static func listWorkspaces() async throws -> [HerdrWorkspace] { + let result = try await request("workspace.list") + let raw = result["workspaces"] as? [[String: Any]] ?? [] + return raw.map(HerdrWorkspace.init(json:)).sorted { $0.number < $1.number } + } + + /// Focuses a workspace; Herdr restores that space's own active tab and pane. + public static func focusWorkspace(_ workspaceID: String) async throws { + _ = try await request("workspace.focus", params: ["workspace_id": workspaceID]) + } + public static func listTabs(workspaceID: String? = nil) async throws -> [HerdrTab] { var params: [String: Any] = [:] if let workspaceID { params["workspace_id"] = workspaceID } @@ -229,17 +261,17 @@ public enum HerdrClient { _ = try await request("pane.send_text", params: ["pane_id": paneID, "text": text]) } - /// Focuses the tab after the focused one in its workspace, wrapping at the - /// end. Tabs in other workspaces are left alone: cycling is a - /// within-window gesture, not a window switcher. - public static func cycleTabs() async throws { - guard let next = nextTab(in: try await listTabs()) else { return } + /// Focuses the tab `step` places from the focused one in its workspace, + /// wrapping at either end. Tabs in other workspaces are left alone: cycling + /// is a within-window gesture, not a window switcher. + public static func cycleTabs(_ step: Int = 1) async throws { + guard let next = adjacentTab(in: try await listTabs(), step: step) else { return } try await focusTab(next.tabID) } - /// The tab `cycleTabs` would focus, or nil when there is nothing to do — - /// no focused tab, or a workspace with a single tab. - public static func nextTab(in tabs: [HerdrTab]) -> HerdrTab? { + /// The tab `step` places from the focused one, wrapping, or nil when there + /// is nothing to do — no focused tab, or a workspace with a single tab. + public static func adjacentTab(in tabs: [HerdrTab], step: Int) -> HerdrTab? { guard let focused = tabs.first(where: \.focused) else { return nil } let siblings = tabs .filter { $0.workspaceID == focused.workspaceID } @@ -247,7 +279,37 @@ public enum HerdrClient { guard siblings.count > 1, let index = siblings.firstIndex(of: focused) else { return nil } - return siblings[(index + 1) % siblings.count] + return siblings[wrap(index + step, into: siblings.count)] + } + + /// The tab the tabs key focuses: one step forward. + public static func nextTab(in tabs: [HerdrTab]) -> HerdrTab? { + adjacentTab(in: tabs, step: 1) + } + + /// The agent `step` places from the focused one in `agent.list` order — + /// which is sidebar order and is never re-sorted — wrapping at either end. + /// Nil when nothing is focused or there is only one agent. + public static func adjacentAgent(in agents: [HerdrAgent], step: Int) -> HerdrAgent? { + guard agents.count > 1, + let index = agents.firstIndex(where: \.focused) + else { return nil } + return agents[wrap(index + step, into: agents.count)] + } + + /// The workspace `step` places from the focused one in `number` order, + /// wrapping. Nil when nothing is focused or there is only one workspace. + public static func adjacentWorkspace(in spaces: [HerdrWorkspace], step: Int) -> HerdrWorkspace? { + let ordered = spaces.sorted { $0.number < $1.number } + guard ordered.count > 1, + let index = ordered.firstIndex(where: \.focused) + else { return nil } + return ordered[wrap(index + step, into: ordered.count)] + } + + /// Positive modulo, so a step past either end wraps rather than trapping. + private static func wrap(_ index: Int, into count: Int) -> Int { + ((index % count) + count) % count } private static let counter = Counter() diff --git a/Sources/WLKit/IconSymbols.swift b/Sources/WLKit/IconSymbols.swift new file mode 100644 index 0000000..ba965af --- /dev/null +++ b/Sources/WLKit/IconSymbols.swift @@ -0,0 +1,26 @@ +import Foundation + +/// The SF Symbols the menu-bar icon draws. +/// +/// Gathered here rather than written inline at the draw site so a test can +/// check they all still exist. A symbol name that is not on the running system +/// resolves to nil, and the drawing code has nowhere to go but a blank image, +/// so the icon quietly loses its glyph on exactly the machines where the symbol +/// was withdrawn or never shipped. +public enum IconSymbols { + + /// Running, nothing to report. + public static let idle = "keyboard" + + /// Agents are doing something worth a colour. + public static let active = "keyboard.fill" + + /// No pad, or no permission to reach it. + /// + /// Not `keyboard.badge.exclamationmark`, which reads better but is not on + /// every system - where it is absent the icon drew as a bare dot with no + /// keyboard at all, in the two states where you most need to see it. + public static let problem = "keyboard.badge.ellipsis" + + public static let all = [idle, active, problem] +} diff --git a/Sources/WLKit/KeyBindings.swift b/Sources/WLKit/KeyBindings.swift index fa49425..26f9db1 100644 --- a/Sources/WLKit/KeyBindings.swift +++ b/Sources/WLKit/KeyBindings.swift @@ -11,18 +11,58 @@ import Foundation /// "12": "Run but pull", /// "10+11": "Summarise what you are working on" /// }, +/// "dial": "effort", /// "claude": { "models": ["fable", "opus"], "efforts": ["low", "high"] }, /// "codex": { "models": ["gpt-5.6-sol", "gpt-5.6-codex"] } /// } /// /// A bound string is injected into the focused agent's prompt, unsubmitted. -/// `"10+11"` addresses the wide key as one; `"10"` and `"11"` address its -/// halves separately. Keys the file does not mention keep their defaults — -/// 9 and 12 have the defaults below, the wide key defaults to the voice key. -/// An empty string unbinds a key outright. +/// A key can also be bound to a system-wide keyboard shortcut instead — +/// `{"shortcut": "cmd+shift+5"}` — synthesised regardless of what Herdr is +/// doing. `"10+11"` addresses the wide key as one; `"10"` and `"11"` address +/// its halves separately. Keys the file does not mention keep their +/// defaults — 9 and 12 default to the text macros below, the wide key +/// defaults to the voice key, and 6/7/8 default to stack/tabs/land. Binding +/// any of those overrides its default; an empty string (or `{"shortcut":""}`) +/// unbinds a key outright. +/// +/// A top-level `"dial"` string repurposes the knob: `"effort"` (the default) +/// climbs the reasoning-effort ladder, `"agent"` steps the focused agent, +/// `"tab"` cycles tabs in the focused workspace, `"space"` (or `"workspace"`) +/// steps the focused workspace. Anything else falls back to `"effort"`. public struct KeyBindings: Sendable, Equatable { - public private(set) var texts: [Int: String] + /// What a dial detent does. `effort` is handled in the app layer; the rest + /// are Herdr navigation and run through `BridgeController.cycleDial`. + public enum DialMode: String, Sendable { + case effort, agent, tab, space + + init(config: String?) { + switch config?.lowercased() { + case "agent": self = .agent + case "tab": self = .tab + case "space", "workspace": self = .space + default: self = .effort + } + } + } + + /// What a bound key does. `.shortcut` carries the raw config string — + /// `ShortcutSpec.parse` validates it at dispatch time, so a typo shows up + /// as an error in the panel rather than silently dropping the binding. + /// + /// `.off` is deliberately not the same thing as a key going unmentioned: + /// an unmentioned stack/tabs/land/voice key keeps its built-in job, but + /// `.off` is a real, present binding that beats it — how you silence a + /// built-in you don't want. (Named `off`, not `none`, so it can never be + /// confused with `Optional.none` when matched against `KeyAction?`.) + public enum KeyAction: Equatable, Sendable { + case text(String) + case shortcut(String) + case off + } + + public private(set) var actions: [Int: KeyAction] /// The models the joystick cycles through in Claude Code, in order. /// Overridable with a top-level `"claude": {"models": [...]}` object. public private(set) var claudeModels: [String] @@ -35,28 +75,40 @@ public struct KeyBindings: Sendable, Equatable { /// the panel shows the steering guide alone. public private(set) var codexModels: [String] - public static let defaults: [Int: String] = [ - 9: "Open PRs for all active GitButler branches", - 12: "Run but pull", + /// What the knob does. Set with a top-level `"dial"` string. + public private(set) var dialMode: DialMode + + public static let defaults: [Int: KeyAction] = [ + 9: .text("Open PRs for all active GitButler branches"), + 12: .text("Run but pull"), ] public static let defaultClaudeModels = ["fable", "opus", "sonnet", "haiku"] public static let defaultClaudeEfforts = ["low", "medium", "high", "xhigh", "max"] public init( - texts: [Int: String] = KeyBindings.defaults, + actions: [Int: KeyAction] = KeyBindings.defaults, claudeModels: [String] = KeyBindings.defaultClaudeModels, claudeEfforts: [String] = KeyBindings.defaultClaudeEfforts, - codexModels: [String] = [] + codexModels: [String] = [], + dialMode: DialMode = .effort ) { - self.texts = texts + self.actions = actions self.claudeModels = claudeModels self.claudeEfforts = claudeEfforts self.codexModels = codexModels + self.dialMode = dialMode + } + + /// The action bound to a key, or nil when the key does whatever it does + /// by default (an agent key, or — unless overridden — stack/tabs/land/voice). + public func action(for key: Int) -> KeyAction? { + actions[key] } - /// The string bound to a key, or nil when the key does something else. + /// The text bound to a key, or nil when it is unbound or bound to a + /// shortcut instead. public func text(for key: Int) -> String? { - guard let text = texts[key], !text.isEmpty else { return nil } + guard case .text(let text) = actions[key] else { return nil } return text } @@ -79,16 +131,12 @@ public struct KeyBindings: Sendable, Equatable { guard let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { return KeyBindings() } - var texts = defaults + var actions = defaults if let keys = json["keys"] as? [String: Any] { for (name, value) in keys { - guard let text = value as? String else { continue } + guard let action = keyAction(from: value) else { continue } for key in keyIDs(for: name) { - if text.isEmpty { - texts.removeValue(forKey: key) - } else { - texts[key] = text - } + actions[key] = action } } } @@ -99,13 +147,29 @@ public struct KeyBindings: Sendable, Equatable { let codex = json["codex"] as? [String: Any] let codexModels = (codex?["models"] as? [String])?.filter { !$0.isEmpty } return KeyBindings( - texts: texts, + actions: actions, claudeModels: models?.isEmpty == false ? models! : defaultClaudeModels, claudeEfforts: efforts?.isEmpty == false ? efforts! : defaultClaudeEfforts, - codexModels: codexModels ?? [] + codexModels: codexModels ?? [], + dialMode: DialMode(config: json["dial"] as? String) ) } + /// A key's value is a bare string (a text macro, or `""` to turn it off), + /// an object with a `"shortcut"` string (same empty-string rule), or + /// `false` (turns it off outright — the unambiguous choice for a + /// stack/tabs/land key, where an empty string could read as "leave it + /// alone"). Anything else — `true`, a shortcut object missing its field, + /// a number — binds nothing, same as leaving the key unmentioned. + private static func keyAction(from value: Any) -> KeyAction? { + if let flag = value as? Bool { return flag ? nil : .off } + if let text = value as? String { return text.isEmpty ? .off : .text(text) } + if let object = value as? [String: Any], let shortcut = object["shortcut"] as? String { + return shortcut.isEmpty ? .off : .shortcut(shortcut) + } + return nil + } + private static func keyIDs(for name: String) -> [Int] { if name == "10+11" { return Pad.voiceKeyIDs } guard let id = Int(name) else { return [] } diff --git a/Sources/WLKit/OAIProtocol.swift b/Sources/WLKit/OAIProtocol.swift index a6329a1..a709404 100644 --- a/Sources/WLKit/OAIProtocol.swift +++ b/Sources/WLKit/OAIProtocol.swift @@ -192,6 +192,12 @@ public enum Pad { public static let boundKeyIDs = agentKeyIDs + [stackKeyID, tabCycleKeyID, landKeyID] + voiceKeyIDs + macroKeyIDs + /// Every key whose job `config.json` can replace: stack, tabs, land, the + /// wide key, and the two plain macro keys. Agent keys are never in this + /// list — Herdr's own agent list decides those. + public static let overridableKeyIDs = + [stackKeyID, tabCycleKeyID, landKeyID] + voiceKeyIDs + macroKeyIDs + // MARK: Dial and joystick /// The dial and joystick emit keycodes like the keys do, so binding them diff --git a/Sources/WLKit/ShortcutSpec.swift b/Sources/WLKit/ShortcutSpec.swift new file mode 100644 index 0000000..65ee25f --- /dev/null +++ b/Sources/WLKit/ShortcutSpec.swift @@ -0,0 +1,68 @@ +import Foundation + +/// A parsed keyboard shortcut, e.g. `"cmd+shift+5"` — the modifier flags and +/// virtual keycode `CGEvent` needs to synthesise it. Parsing lives here, with +/// no AppKit dependency, so it can be tested on its own; posting the event is +/// app-layer, the same split `VoiceController` uses for the wide key's tap. +public struct ShortcutSpec: Equatable, Sendable { + public var keyCode: UInt16 + public var modifiers: Modifiers + + public struct Modifiers: OptionSet, Sendable, Equatable { + public let rawValue: UInt8 + public init(rawValue: UInt8) { self.rawValue = rawValue } + public static let command = Modifiers(rawValue: 1 << 0) + public static let shift = Modifiers(rawValue: 1 << 1) + public static let option = Modifiers(rawValue: 1 << 2) + public static let control = Modifiers(rawValue: 1 << 3) + } + + /// Parses a `"+"`-separated spec like `"cmd+shift+5"` or `"ctrl+alt+u"`: + /// modifiers in any order, case-insensitive, exactly one base key. Nil for + /// anything that isn't exactly that — no base key, more than one, or a + /// name this table doesn't know. + public static func parse(_ spec: String) -> ShortcutSpec? { + let parts = spec + .lowercased() + .split(separator: "+") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + guard !parts.isEmpty else { return nil } + + var modifiers: Modifiers = [] + var keyCode: UInt16? + for part in parts { + switch part { + case "cmd", "command": modifiers.insert(.command) + case "shift": modifiers.insert(.shift) + case "opt", "option", "alt": modifiers.insert(.option) + case "ctrl", "control": modifiers.insert(.control) + default: + // A second base key, or an unrecognised name, both fail — + // reporting silence rather than guessing which one you meant. + guard keyCode == nil, let code = keyCodes[part] else { return nil } + keyCode = code + } + } + guard let keyCode else { return nil } + return ShortcutSpec(keyCode: keyCode, modifiers: modifiers) + } + + /// Standard US-ANSI virtual keycodes (the Carbon `kVK_*` table). Named + /// keys only need to cover what a macro key would plausibly bind — + /// letters, digits, punctuation, function keys, arrows. + private static let keyCodes: [String: UInt16] = [ + "a": 0, "s": 1, "d": 2, "f": 3, "h": 4, "g": 5, "z": 6, "x": 7, "c": 8, "v": 9, + "b": 11, "q": 12, "w": 13, "e": 14, "r": 15, "y": 16, "t": 17, + "1": 18, "2": 19, "3": 20, "4": 21, "6": 22, "5": 23, "equal": 24, "9": 25, "7": 26, + "minus": 27, "8": 28, "0": 29, "rightbracket": 30, "o": 31, "u": 32, + "leftbracket": 33, "i": 34, "p": 35, "return": 36, "l": 37, "j": 38, + "quote": 39, "k": 40, "semicolon": 41, "backslash": 42, "comma": 43, + "slash": 44, "n": 45, "m": 46, "period": 47, "tab": 48, "space": 49, + "grave": 50, "delete": 51, "escape": 53, + "f1": 122, "f2": 120, "f3": 99, "f4": 118, "f5": 96, "f6": 97, "f7": 98, + "f8": 100, "f9": 101, "f10": 109, "f11": 103, "f12": 111, + "f13": 105, "f14": 107, "f15": 113, "f16": 106, "f17": 64, "f18": 79, "f19": 80, + "left": 123, "right": 124, "down": 125, "up": 126, + ] +} diff --git a/Sources/WLMicroManager/MenuBarIcon.swift b/Sources/WLMicroManager/MenuBarIcon.swift index 45e2a6e..2765539 100644 --- a/Sources/WLMicroManager/MenuBarIcon.swift +++ b/Sources/WLMicroManager/MenuBarIcon.swift @@ -36,10 +36,10 @@ enum MenuBarIcon { var symbolName: String { switch self { - case .off: return "keyboard" - case .permissionDenied, .deviceMissing: return "keyboard.badge.exclamationmark" - case .idle: return "keyboard" - case .allIdle, .working, .blocked: return "keyboard.fill" + case .off: return IconSymbols.idle + case .permissionDenied, .deviceMissing: return IconSymbols.problem + case .idle: return IconSymbols.idle + case .allIdle, .working, .blocked: return IconSymbols.active } } @@ -71,7 +71,12 @@ enum MenuBarIcon { static func image(for state: State) -> NSImage { let symbolSize = NSSize(width: 18, height: 18) let config = NSImage.SymbolConfiguration(pointSize: 14, weight: .regular) - let symbol = NSImage(systemSymbolName: state.symbolName, accessibilityDescription: state.help)? + // Falling back to the plain keyboard rather than to a blank image: a + // symbol the running system does not have would otherwise leave the + // menu bar with a status dot floating on its own, which reads as a + // broken app rather than as whatever the state actually is. + let symbol = (NSImage(systemSymbolName: state.symbolName, accessibilityDescription: state.help) + ?? NSImage(systemSymbolName: IconSymbols.idle, accessibilityDescription: state.help))? .withSymbolConfiguration(config) ?? NSImage(size: symbolSize) diff --git a/Sources/WLMicroManager/MenuPanelView.swift b/Sources/WLMicroManager/MenuPanelView.swift index 78ed990..7bd6d7b 100644 --- a/Sources/WLMicroManager/MenuPanelView.swift +++ b/Sources/WLMicroManager/MenuPanelView.swift @@ -101,30 +101,22 @@ struct MenuPanelView: View { private func keyView(_ index: Int) -> some View { let color = bridge.keyColors[index] let isBound = Pad.boundKeyIDs.contains(index) - let isStackKey = index == Pad.stackKeyID - let isTabCycleKey = index == Pad.tabCycleKeyID - let isLandKey = index == Pad.landKeyID - let macroText = bridge.keyBindings.text(for: index) - let isVoiceKey = macroText == nil && Pad.voiceKeyIDs.contains(index) + // A binding overrides the key's built-in role — same rule + // `BridgeController.handleKeyPress` uses, so the two stay in sync. + let action = Pad.overridableKeyIDs.contains(index) ? bridge.keyBindings.action(for: index) : nil + let isStackKey = index == Pad.stackKeyID && action == nil + let isTabCycleKey = index == Pad.tabCycleKeyID && action == nil + let isLandKey = index == Pad.landKeyID && action == nil + let isVoiceKey = action == nil && Pad.voiceKeyIDs.contains(index) // Key index and agent slot are different orderings — the top row is // wired right to left — so the slot lookup goes through the pad map. let slot = Pad.agentSlot(for: index) let agent = slot.flatMap { $0 < bridge.agents.count ? bridge.agents[$0] : nil } return Button { - if isStackKey { - StackPanelController.shared.toggle() - } else if isTabCycleKey { - Task { await bridge.cycleTabs() } - } else if isLandKey { - LandPanelController.shared.handleLandKey() - } else if let macroText { - Task { await bridge.injectPrompt(macroText) } - } else if isVoiceKey { - VoiceController.shared.handleVoiceKey() - } else if let slot, agent != nil { - Task { await bridge.focusSlot(slot) } - } + // The same dispatch a physical press goes through, so a click + // here can never disagree with what the key actually does. + bridge.handleKeyPress(index) } label: { RoundedRectangle(cornerRadius: 5) .fill(color ?? Color.secondary.opacity(isBound ? 0.16 : 0.07)) @@ -136,10 +128,10 @@ struct MenuPanelView: View { } .buttonStyle(.plain) .disabled(agent == nil && !isStackKey && !isTabCycleKey && !isLandKey - && macroText == nil && !isVoiceKey) + && (action == nil || action == .off) && !isVoiceKey) .help(helpText(index, agent: agent, isStackKey: isStackKey, isTabCycleKey: isTabCycleKey, isLandKey: isLandKey, - macroText: macroText, isVoiceKey: isVoiceKey)) + action: action, isVoiceKey: isVoiceKey)) } private func helpText( @@ -148,13 +140,18 @@ struct MenuPanelView: View { isStackKey: Bool, isTabCycleKey: Bool, isLandKey: Bool, - macroText: String?, + action: KeyBindings.KeyAction?, isVoiceKey: Bool ) -> String { if isStackKey { return "GitButler stack for the focused agent" } if isTabCycleKey { return "Cycle tabs in the focused Herdr window" } if isLandKey { return "Land the focused agent's branches onto the target" } - if let macroText { return "Type: \(macroText)" } + switch action { + case .text(let text): return "Type: \(text)" + case .shortcut(let spec): return "Shortcut: \(spec)" + case .off: return "Unbound" + case nil: break + } if isVoiceKey { return "Right command — start or stop Superwhisper" } if let agent { return "\(agent.shortName) — \(agent.status)" } return "Key \(index)" diff --git a/Sources/WLMicroManager/MicroManagerApp.swift b/Sources/WLMicroManager/MicroManagerApp.swift index 45afbde..2b47129 100644 --- a/Sources/WLMicroManager/MicroManagerApp.swift +++ b/Sources/WLMicroManager/MicroManagerApp.swift @@ -43,6 +43,12 @@ struct MicroManagerApp: App { } bridge.onVoiceKey = { voice.handleVoiceKey() } + let shortcuts = ShortcutController.shared + shortcuts.onError = { [weak bridge] message in + bridge?.noteError(message) + } + bridge.onShortcut = { spec in shortcuts.post(spec) } + let tune = TuneController.shared tune.onError = { [weak bridge] message in bridge?.noteError(message) @@ -50,7 +56,18 @@ struct MicroManagerApp: App { tune.bindings = { [weak bridge] in bridge?.keyBindings ?? KeyBindings() } - bridge.onDial = { step in tune.handleDial(step) } + // `config.json`'s "dial" key decides the knob's job. It is + // re-read on every bridge start, and this closure reads it + // live, so an edit takes effect on the next off/on toggle. + bridge.onDial = { [weak bridge] step in + guard let bridge else { return } + let mode = bridge.keyBindings.dialMode + if mode == .effort { + tune.handleDial(step) + } else { + Task { await bridge.cycleDial(step, mode: mode) } + } + } bridge.onJoystick = { direction in tune.handleJoystick(direction) } // While a land confirmation is up, every key that is not // the land key means "cancel", nothing else. diff --git a/Sources/WLMicroManager/ShortcutController.swift b/Sources/WLMicroManager/ShortcutController.swift new file mode 100644 index 0000000..ab9069f --- /dev/null +++ b/Sources/WLMicroManager/ShortcutController.swift @@ -0,0 +1,82 @@ +import AppKit +import WLKit + +/// Posts a config-bound `ShortcutSpec` as a real system keystroke, so a spare +/// key can drive anything a hotkey can — a screenshot tool, Mission Control, +/// an app's own shortcut — regardless of what Herdr is doing. +/// +/// Same technique as `VoiceController`'s right-command tap: a synthesised +/// `CGEvent`, which needs the Accessibility grant on top of the pad's own +/// Input Monitoring grant. +@MainActor +final class ShortcutController { + + static let shared = ShortcutController() + + var onError: ((String) -> Void)? + + func post(_ spec: ShortcutSpec) { + let prompt = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String + guard AXIsProcessTrustedWithOptions([prompt: true] as CFDictionary) else { + onError?("Keyboard shortcuts need the Accessibility permission: " + + "System Settings → Privacy & Security → Accessibility → Micro Manager.") + return + } + + let source = CGEventSource(stateID: .hidSystemState) + guard + let baseDown = CGEvent(keyboardEventSource: source, + virtualKey: CGKeyCode(spec.keyCode), keyDown: true), + let baseUp = CGEvent(keyboardEventSource: source, + virtualKey: CGKeyCode(spec.keyCode), keyDown: false) + else { + onError?("Could not synthesise that shortcut.") + return + } + + // Real hardware reports a modifier key's own press as a distinct HID + // event — a keyboard never sends "T with the control flag set" + // without also sending control's own keydown. Stamping the flag on + // the base key alone (an earlier version of this did only that) + // satisfies a listener that reads event.flags on the base key, but + // not one that also tracks each modifier's own press independently — + // which is exactly what lets an app offer "tap a bare modifier" as + // its own trigger, as OpenSuperWhisper's hotkey settings do. So each + // modifier gets a real keydown/keyup here too, building the flag set + // up one key at a time the way an actual chord's reports would. + var held: CGEventFlags = [] + let chords = ShortcutSpec.Modifiers.chord.filter { spec.modifiers.contains($0.modifier) } + + for (_, code, flag) in chords { + held.insert(flag) + let down = CGEvent(keyboardEventSource: source, virtualKey: code, keyDown: true) + down?.flags = held + down?.post(tap: .cghidEventTap) + } + + baseDown.flags = held + baseUp.flags = held // still held on release — the modifier ups come after + baseDown.post(tap: .cghidEventTap) + baseUp.post(tap: .cghidEventTap) + + for (_, code, flag) in chords.reversed() { + held.remove(flag) + let up = CGEvent(keyboardEventSource: source, virtualKey: code, keyDown: false) + up?.flags = held + up?.post(tap: .cghidEventTap) + } + } +} + +private extension ShortcutSpec.Modifiers { + /// Left-hand virtual keycode and `CGEventFlags` bit for each modifier, + /// in a fixed press order. The order is arbitrary — a listener never + /// cares which modifier's HID report arrived first — but fixed, so a + /// chord presses and releases the same way every time. + static let chord: [(modifier: Self, keyCode: CGKeyCode, flag: CGEventFlags)] = [ + (.control, 0x3B, .maskControl), + (.option, 0x3A, .maskAlternate), + (.shift, 0x38, .maskShift), + (.command, 0x37, .maskCommand), + ] +} diff --git a/Tests/WLKitTests/DeviceOpenFailureTests.swift b/Tests/WLKitTests/DeviceOpenFailureTests.swift new file mode 100644 index 0000000..dd5a557 --- /dev/null +++ b/Tests/WLKitTests/DeviceOpenFailureTests.swift @@ -0,0 +1,48 @@ +import XCTest +@testable import WLKit + +/// Telling "you have not granted permission" apart from "the pad is not +/// reachable", which look identical from the error code alone. +/// +/// `IOHIDDeviceOpen` answers `kIOReturnNotPermitted` (0xE00002E2) both when the +/// Input Monitoring grant is missing and when the device is already held or +/// wedged, so the code cannot be the thing that decides. `IOHIDCheckAccess` is +/// the only source that actually knows about the grant, so it casts the vote. +final class DeviceOpenFailureTests: XCTestCase { + + private let notPermitted = "IOHIDDeviceOpen failed (0xE00002E2)" + + func testMissingGrantIsReportedAsAPermissionProblem() { + let failure = DeviceOpenFailure.classify(accessGranted: false, message: notPermitted) + XCTAssertEqual(failure, .permissionMissing) + } + + /// The case that sent us in circles: the same error code with the grant + /// already in place is a device problem, and calling it a permission + /// problem sends someone back to System Settings for nothing. + func testSameErrorWithTheGrantInPlaceIsADeviceProblem() { + let failure = DeviceOpenFailure.classify(accessGranted: true, message: notPermitted) + XCTAssertEqual(failure, .deviceUnavailable(notPermitted)) + } + + /// Whatever we say next has to name the thing to try, because the error + /// code on its own has already proved useless to a reader. + func testTheDeviceMessageSuggestsSomethingToDo() { + let advice = DeviceOpenFailure.deviceUnavailable(notPermitted).message + XCTAssertTrue(advice.contains("reconnect") || advice.contains("unplug"), + "a wedged pad is fixed by reseating it, so say so: \(advice)") + XCTAssertTrue(advice.contains(notPermitted), "keep the raw error for anyone diagnosing") + } + + func testThePermissionMessageNamesTheSettingToLookFor() { + let advice = DeviceOpenFailure.permissionMissing.message + XCTAssertTrue(advice.contains("Input Monitoring"), advice) + } + + /// Older systems phrase the denial differently, and that reading should + /// still win over a stale access check. + func testAnExplicitPermissionErrorIsBelievedRegardless() { + let legacy = "IOHIDDeviceOpen failed (0xE00002C1)" + XCTAssertEqual(DeviceOpenFailure.classify(accessGranted: true, message: legacy), .permissionMissing) + } +} diff --git a/Tests/WLKitTests/HerdrDialCycleTests.swift b/Tests/WLKitTests/HerdrDialCycleTests.swift new file mode 100644 index 0000000..347b666 --- /dev/null +++ b/Tests/WLKitTests/HerdrDialCycleTests.swift @@ -0,0 +1,101 @@ +import XCTest +@testable import WLKit + +/// The dial's `agent` and `space` modes: step the focused agent or workspace +/// one place along, wrapping, in whichever direction the encoder turned. +final class HerdrAgentCycleTests: XCTestCase { + + private func agent(_ pane: String, focused: Bool = false) -> HerdrAgent { + HerdrAgent(status: "idle", paneID: pane, focused: focused) + } + + func testAdvancesToTheNextAgentInListOrder() { + let agents = [agent("p1", focused: true), agent("p2"), agent("p3")] + XCTAssertEqual(HerdrClient.adjacentAgent(in: agents, step: 1)?.paneID, "p2") + } + + func testStepsBackwardWithANegativeStep() { + let agents = [agent("p1"), agent("p2", focused: true), agent("p3")] + XCTAssertEqual(HerdrClient.adjacentAgent(in: agents, step: -1)?.paneID, "p1") + } + + func testWrapsForwardPastTheEnd() { + let agents = [agent("p1"), agent("p2"), agent("p3", focused: true)] + XCTAssertEqual(HerdrClient.adjacentAgent(in: agents, step: 1)?.paneID, "p1") + } + + func testWrapsBackwardPastTheStart() { + let agents = [agent("p1", focused: true), agent("p2")] + XCTAssertEqual(HerdrClient.adjacentAgent(in: agents, step: -1)?.paneID, "p2") + } + + /// `agent.list` order is sidebar order and is never re-sorted; the ids here + /// are deliberately out of alphabetical order to prove it. + func testKeepsListOrderAndNeverResorts() { + let agents = [agent("z"), agent("a", focused: true), agent("m")] + XCTAssertEqual(HerdrClient.adjacentAgent(in: agents, step: 1)?.paneID, "m") + } + + func testNoFocusedAgentDoesNothing() { + XCTAssertNil(HerdrClient.adjacentAgent(in: [agent("p1"), agent("p2")], step: 1)) + } + + func testASingleAgentHasNowhereToGo() { + XCTAssertNil(HerdrClient.adjacentAgent(in: [agent("p1", focused: true)], step: 1)) + } + + func testEmptyListIsSafe() { + XCTAssertNil(HerdrClient.adjacentAgent(in: [], step: 1)) + } +} + +final class HerdrWorkspaceCycleTests: XCTestCase { + + func testAdvancesToTheNextWorkspaceByNumber() { + let spaces = [ + HerdrWorkspace(workspaceID: "w1", number: 1, focused: true), + HerdrWorkspace(workspaceID: "w2", number: 2), + HerdrWorkspace(workspaceID: "w3", number: 3), + ] + XCTAssertEqual(HerdrClient.adjacentWorkspace(in: spaces, step: 1)?.workspaceID, "w2") + } + + func testStepsBackwardWithANegativeStep() { + let spaces = [ + HerdrWorkspace(workspaceID: "w1", number: 1), + HerdrWorkspace(workspaceID: "w2", number: 2, focused: true), + ] + XCTAssertEqual(HerdrClient.adjacentWorkspace(in: spaces, step: -1)?.workspaceID, "w1") + } + + func testWrapsFromTheLastWorkspaceToTheFirst() { + let spaces = [ + HerdrWorkspace(workspaceID: "w1", number: 1), + HerdrWorkspace(workspaceID: "w2", number: 2, focused: true), + ] + XCTAssertEqual(HerdrClient.adjacentWorkspace(in: spaces, step: 1)?.workspaceID, "w1") + } + + /// `workspace.list` order is not guaranteed to be display order; `number` is. + func testCyclesInNumberOrderNotListOrder() { + let spaces = [ + HerdrWorkspace(workspaceID: "w3", number: 3), + HerdrWorkspace(workspaceID: "w1", number: 1, focused: true), + HerdrWorkspace(workspaceID: "w2", number: 2), + ] + XCTAssertEqual(HerdrClient.adjacentWorkspace(in: spaces, step: 1)?.workspaceID, "w2") + } + + func testNoFocusedWorkspaceDoesNothing() { + let spaces = [ + HerdrWorkspace(workspaceID: "w1", number: 1), + HerdrWorkspace(workspaceID: "w2", number: 2), + ] + XCTAssertNil(HerdrClient.adjacentWorkspace(in: spaces, step: 1)) + } + + func testASingleWorkspaceHasNowhereToGo() { + let spaces = [HerdrWorkspace(workspaceID: "w1", number: 1, focused: true)] + XCTAssertNil(HerdrClient.adjacentWorkspace(in: spaces, step: 1)) + } +} diff --git a/Tests/WLKitTests/HerdrTabCycleTests.swift b/Tests/WLKitTests/HerdrTabCycleTests.swift index faeb887..43fc379 100644 --- a/Tests/WLKitTests/HerdrTabCycleTests.swift +++ b/Tests/WLKitTests/HerdrTabCycleTests.swift @@ -51,4 +51,35 @@ final class HerdrTabCycleTests: XCTestCase { ] XCTAssertNil(HerdrClient.nextTab(in: tabs)) } + + // MARK: - Direction + + /// The dial turns both ways, so tab cycling has to as well. + func testStepsBackwardToThePreviousTab() { + let tabs = [ + HerdrTab(tabID: "t1", number: 1), + HerdrTab(tabID: "t2", number: 2, focused: true), + HerdrTab(tabID: "t3", number: 3), + ] + XCTAssertEqual(HerdrClient.adjacentTab(in: tabs, step: -1)?.tabID, "t1") + } + + func testWrapsBackwardFromTheFirstTabToTheLast() { + let tabs = [ + HerdrTab(tabID: "t1", number: 1, focused: true), + HerdrTab(tabID: "t2", number: 2), + HerdrTab(tabID: "t3", number: 3), + ] + XCTAssertEqual(HerdrClient.adjacentTab(in: tabs, step: -1)?.tabID, "t3") + } + + /// The tabs key still works: it is `adjacentTab` with a step of one. + func testNextTabIsAdjacentTabForwardOne() { + let tabs = [ + HerdrTab(tabID: "t1", number: 1, focused: true), + HerdrTab(tabID: "t2", number: 2), + ] + XCTAssertEqual(HerdrClient.nextTab(in: tabs)?.tabID, + HerdrClient.adjacentTab(in: tabs, step: 1)?.tabID) + } } diff --git a/Tests/WLKitTests/IconSymbolsTests.swift b/Tests/WLKitTests/IconSymbolsTests.swift new file mode 100644 index 0000000..57f8e31 --- /dev/null +++ b/Tests/WLKitTests/IconSymbolsTests.swift @@ -0,0 +1,30 @@ +import XCTest +import AppKit +@testable import WLKit + +/// Guards the menu-bar glyph against a failure that is invisible until it is +/// embarrassing: `NSImage(systemSymbolName:)` returns nil for a name the running +/// system does not have, the drawing code falls back to an empty image, and the +/// icon becomes a bare status dot with no keyboard at all. +/// +/// Worse, the states that need the glyph most are the error ones, so the icon +/// vanishes precisely when someone is trying to work out what is wrong. +final class IconSymbolsTests: XCTestCase { + + func testEverySymbolTheIconDrawsExistsOnThisSystem() { + for name in IconSymbols.all { + XCTAssertNotNil( + NSImage(systemSymbolName: name, accessibilityDescription: nil), + "\(name) is not an SF Symbol on this system, so the icon would draw blank" + ) + } + } + + /// The glyph is how the icon is found in a crowded menu bar, so every state + /// keeps the keyboard rather than swapping to a generic warning mark. + func testEveryStateKeepsTheKeyboardShape() { + for name in IconSymbols.all { + XCTAssertTrue(name.hasPrefix("keyboard"), "\(name) would not read as this app") + } + } +} diff --git a/Tests/WLKitTests/KeyBindingsTests.swift b/Tests/WLKitTests/KeyBindingsTests.swift index 68954fc..5e18880 100644 --- a/Tests/WLKitTests/KeyBindingsTests.swift +++ b/Tests/WLKitTests/KeyBindingsTests.swift @@ -86,4 +86,111 @@ final class KeyBindingsTests: XCTestCase { XCTAssertEqual(bindings.claudeModels, KeyBindings.defaultClaudeModels, "a codex-only config leaves the claude side alone") } + + // MARK: - Dial mode + + /// With nothing in the file the dial keeps its original job: effort. + func testDialModeDefaultsToEffort() { + XCTAssertEqual(KeyBindings().dialMode, .effort) + XCTAssertEqual(parse("{}").dialMode, .effort) + } + + func testDialModeComesFromTheFile() { + XCTAssertEqual(parse(#"{"dial": "agent"}"#).dialMode, .agent) + XCTAssertEqual(parse(#"{"dial": "tab"}"#).dialMode, .tab) + XCTAssertEqual(parse(#"{"dial": "space"}"#).dialMode, .space) + XCTAssertEqual(parse(#"{"dial": "effort"}"#).dialMode, .effort) + } + + /// `space` is the pad-facing word; `workspace` is the socket API's. Take both. + func testDialModeAcceptsWorkspaceAsAnAliasForSpace() { + XCTAssertEqual(parse(#"{"dial": "workspace"}"#).dialMode, .space) + } + + func testDialModeIsCaseInsensitive() { + XCTAssertEqual(parse(#"{"dial": "AGENT"}"#).dialMode, .agent) + } + + /// A typo or the wrong type should not brick the dial — fall back to effort. + func testUnknownDialModeFallsBackToEffort() { + XCTAssertEqual(parse(#"{"dial": "banana"}"#).dialMode, .effort) + XCTAssertEqual(parse(#"{"dial": 5}"#).dialMode, .effort) + } + + func testDialModeSurvivesAKeysOnlyConfig() { + XCTAssertEqual(parse(#"{"keys": {"9": "Ship it"}}"#).dialMode, .effort) + } + + // MARK: - Key actions (text and shortcut) + + func testAKeyCanBeBoundToAShortcut() { + let bindings = parse(#"{"keys": {"6": {"shortcut": "cmd+shift+5"}}}"#) + XCTAssertEqual(bindings.action(for: 6), .shortcut("cmd+shift+5")) + XCTAssertNil(bindings.text(for: 6), "a shortcut binding is not a text binding") + } + + /// Stack, tabs, and land used to be fixed; a bound key now overrides them. + func testStackTabAndLandKeysAreOverridable() { + let bindings = parse(#""" + {"keys": {"6": {"shortcut": "cmd+shift+5"}, "7": "note", "8": ""}} + """#) + XCTAssertEqual(bindings.action(for: 6), .shortcut("cmd+shift+5")) + XCTAssertEqual(bindings.action(for: 7), .text("note")) + XCTAssertEqual(bindings.action(for: 8), .off, "empty string turns the key off outright") + } + + func testUnmentionedStackTabAndLandKeysHaveNoActionByDefault() { + // nil here means "unmentioned, keep the built-in job" — not the same + // as `.off`, which is an explicit override that beats the built-in. + XCTAssertNil(KeyBindings().action(for: Pad.stackKeyID)) + XCTAssertNil(KeyBindings().action(for: Pad.tabCycleKeyID)) + XCTAssertNil(KeyBindings().action(for: Pad.landKeyID)) + } + + func testMacroKeyDefaultsComeBackAsTextActions() { + XCTAssertEqual(KeyBindings().action(for: 9), + .text("Open PRs for all active GitButler branches")) + } + + /// A shortcut object with no recognised field binds nothing, same as a + /// malformed value anywhere else in the file — the key stays unmentioned. + func testAShortcutObjectMissingItsFieldIsIgnored() { + let bindings = parse(#"{"keys": {"6": {}}}"#) + XCTAssertNil(bindings.action(for: 6)) + } + + func testWideKeyShortcutBindsBothHalves() { + let bindings = parse(#"{"keys": {"10+11": {"shortcut": "cmd+shift+5"}}}"#) + XCTAssertEqual(bindings.action(for: 10), .shortcut("cmd+shift+5")) + XCTAssertEqual(bindings.action(for: 11), .shortcut("cmd+shift+5")) + } + + /// An empty shortcut string turns the key off too, same rule as text. + func testEmptyShortcutTurnsAKeyOff() { + let bindings = parse(#"{"keys": {"9": {"shortcut": ""}}}"#) + XCTAssertEqual(bindings.action(for: 9), .off) + } + + /// `false` is the explicit, no-ambiguity way to turn a key off — the + /// value to reach for on 6/7/8, whose built-in job would otherwise run. + func testFalseExplicitlyTurnsAKeyOff() { + let bindings = parse(#"{"keys": {"6": false, "8": false}}"#) + XCTAssertEqual(bindings.action(for: 6), .off) + XCTAssertEqual(bindings.action(for: 8), .off) + } + + /// `.off` beats the built-in even though it is a real, present binding — + /// unlike an unmentioned key, it is not nil. + func testOffIsARealBindingNotAnAbsentOne() { + let bindings = parse(#"{"keys": {"6": false}}"#) + XCTAssertNotNil(bindings.action(for: 6)) + XCTAssertEqual(bindings.action(for: 6), .off) + } + + /// `true` has no meaning for a key binding — ignored like any other + /// malformed value, leaving the key at its default. + func testTrueIsIgnored() { + let bindings = parse(#"{"keys": {"6": true}}"#) + XCTAssertNil(bindings.action(for: 6)) + } } diff --git a/Tests/WLKitTests/ShortcutSpecTests.swift b/Tests/WLKitTests/ShortcutSpecTests.swift new file mode 100644 index 0000000..a15b4c2 --- /dev/null +++ b/Tests/WLKitTests/ShortcutSpecTests.swift @@ -0,0 +1,58 @@ +import XCTest +@testable import WLKit + +/// Parsing for config-bound keyboard shortcuts ("cmd+shift+5"). Posting the +/// synthesised event is app-layer (needs AppKit); this is the pure half. +final class ShortcutSpecTests: XCTestCase { + + func testParsesABareKey() { + let spec = ShortcutSpec.parse("5") + XCTAssertEqual(spec?.keyCode, 23) + XCTAssertEqual(spec?.modifiers, []) + } + + func testParsesModifiersInAnyOrder() { + XCTAssertEqual(ShortcutSpec.parse("cmd+shift+5"), ShortcutSpec.parse("shift+cmd+5")) + } + + func testParsesAllFourModifiers() { + let spec = ShortcutSpec.parse("cmd+shift+ctrl+alt+5") + XCTAssertEqual(spec?.modifiers, [.command, .shift, .control, .option]) + } + + func testAcceptsAliasesForModifiers() { + XCTAssertEqual(ShortcutSpec.parse("command+option+5"), ShortcutSpec.parse("cmd+alt+5")) + XCTAssertEqual(ShortcutSpec.parse("control+5"), ShortcutSpec.parse("ctrl+5")) + } + + func testIsCaseInsensitive() { + XCTAssertEqual(ShortcutSpec.parse("CMD+SHIFT+5"), ShortcutSpec.parse("cmd+shift+5")) + } + + func testParsesNamedKeys() { + XCTAssertEqual(ShortcutSpec.parse("f13")?.keyCode, 105) + XCTAssertEqual(ShortcutSpec.parse("up")?.keyCode, 126) + XCTAssertEqual(ShortcutSpec.parse("space")?.keyCode, 49) + XCTAssertEqual(ShortcutSpec.parse("escape")?.keyCode, 53) + } + + func testRejectsAnUnknownKeyName() { + XCTAssertNil(ShortcutSpec.parse("cmd+banana")) + } + + func testRejectsMoreThanOneBaseKey() { + XCTAssertNil(ShortcutSpec.parse("5+6")) + } + + func testRejectsAModifierOnlySpec() { + XCTAssertNil(ShortcutSpec.parse("cmd+shift")) + } + + func testRejectsAnEmptySpec() { + XCTAssertNil(ShortcutSpec.parse("")) + } + + func testToleratesStraySpaces() { + XCTAssertEqual(ShortcutSpec.parse(" cmd + shift + 5 "), ShortcutSpec.parse("cmd+shift+5")) + } +}