From ee802e6286a1b5fcfb41a429fee2518bfc7f7b66 Mon Sep 17 00:00:00 2001 From: Peter Pistorius Date: Tue, 28 Apr 2026 22:37:03 +0200 Subject: [PATCH 1/2] Add first-letter app-jump in the overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Press an app's first letter while the overlay is up to select the next matching tile; repeat to cycle through matches. Match is by the owning application's name (prefix, case-insensitive). Toggle in Settings → "First-letter app jump" (default on). Letter keys that don't match anything are no-ops, matching the existing unmapped- key behavior. Letters bound in config.json bindings still win — keymap runs before letter-jump. Co-Authored-By: plyght Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/526aa585.md | 5 +++++ Sources/cmdcmd/Config.swift | 4 +++- Sources/cmdcmd/Overlay.swift | 18 ++++++++++++++++++ Sources/cmdcmd/OverlayView.swift | 10 ++++++++++ Sources/cmdcmd/SettingsWindow.swift | 18 ++++++++++++++++-- 5 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 .changeset/526aa585.md diff --git a/.changeset/526aa585.md b/.changeset/526aa585.md new file mode 100644 index 0000000..3679ba6 --- /dev/null +++ b/.changeset/526aa585.md @@ -0,0 +1,5 @@ +--- +bump: patch +--- + +Type an app's first letter to select it in the overlay; repeat to cycle matches. diff --git a/Sources/cmdcmd/Config.swift b/Sources/cmdcmd/Config.swift index f43d1cd..59305f4 100644 --- a/Sources/cmdcmd/Config.swift +++ b/Sources/cmdcmd/Config.swift @@ -12,12 +12,14 @@ struct Config: Codable { var bindings: [String: Action] var livePreviews: Bool? var displayMode: DisplayMode? + var letterJump: Bool? var triggerSpec: String { trigger ?? "cmd-cmd" } var livePreviewsEnabled: Bool { livePreviews ?? true } var displayModeOrDefault: DisplayMode { displayMode ?? .dock } + var letterJumpEnabled: Bool { letterJump ?? true } - static let `default` = Config(animations: true, trigger: nil, bindings: [:], livePreviews: nil, displayMode: nil) + static let `default` = Config(animations: true, trigger: nil, bindings: [:], livePreviews: nil, displayMode: nil, letterJump: nil) static var fileURL: URL { URL(fileURLWithPath: NSHomeDirectory()) diff --git a/Sources/cmdcmd/Overlay.swift b/Sources/cmdcmd/Overlay.swift index e381414..5aaee4c 100644 --- a/Sources/cmdcmd/Overlay.swift +++ b/Sources/cmdcmd/Overlay.swift @@ -19,6 +19,7 @@ final class Overlay { private var prevPickedWindowID: CGWindowID? private var showIgnored: Bool = false private var dragState: DragState? + private var lastLetterJump: String? private let tracker: SpaceTracker private var config: Config @@ -397,6 +398,21 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B updateHint() } + private func selectApp(startingWith letter: String) { + guard config.letterJumpEnabled, !tiles.isEmpty else { return } + let needle = letter.lowercased() + let start = lastLetterJump == needle ? selectedIndex + 1 : 0 + let order = Array(start.. B allTiles = [] selectedIndex = 0 showIgnored = false + lastLetterJump = nil view?.resetMomentaryPeek() hint.hide() Task(priority: .utility) { @@ -852,6 +869,7 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B v.onMouseDown = { [weak self] p in self?.mouseDownAt(p) } v.onMouseDragged = { [weak self] p in self?.mouseDraggedAt(p) } v.onMouseUp = { [weak self] p in self?.mouseUpAt(p) } + v.onLetter = { [weak self] letter in self?.selectApp(startingWith: letter) } w.contentView = v view = v return w diff --git a/Sources/cmdcmd/OverlayView.swift b/Sources/cmdcmd/OverlayView.swift index 5d9668d..13051eb 100644 --- a/Sources/cmdcmd/OverlayView.swift +++ b/Sources/cmdcmd/OverlayView.swift @@ -13,6 +13,7 @@ final class OverlayView: NSView { var onMouseDown: ((NSPoint) -> Void)? var onMouseDragged: ((NSPoint) -> Void)? var onMouseUp: ((NSPoint) -> Void)? + var onLetter: ((String) -> Void)? private var momentaryPeek = false override var acceptsFirstResponder: Bool { true } @@ -29,6 +30,15 @@ final class OverlayView: NSView { onAction?(action) return } + if bareMods.isEmpty, + let onLetter, + let chars = event.charactersIgnoringModifiers?.lowercased(), + chars.count == 1, + let scalar = chars.unicodeScalars.first, + CharacterSet.lowercaseLetters.contains(scalar) { + onLetter(chars) + return + } } override func keyUp(with event: NSEvent) { diff --git a/Sources/cmdcmd/SettingsWindow.swift b/Sources/cmdcmd/SettingsWindow.swift index a750b08..f5a3c79 100644 --- a/Sources/cmdcmd/SettingsWindow.swift +++ b/Sources/cmdcmd/SettingsWindow.swift @@ -11,7 +11,7 @@ final class SettingsWindowController: NSWindowController { init(config: Config) { model = SettingsModel(config: config) let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 460, height: 400), + contentRect: NSRect(x: 0, y: 0, width: 460, height: 460), styleMask: [.titled, .closable, .miniaturizable], backing: .buffered, defer: false @@ -30,6 +30,7 @@ private final class SettingsModel: ObservableObject { @Published var animations: Bool { didSet { save() } } @Published var livePreviews: Bool { didSet { save() } } @Published var displayMode: DisplayMode { didSet { save() } } + @Published var letterJump: Bool { didSet { save() } } private var base: Config @Published var status: String = "" var onSave: ((Config) -> Void)? @@ -38,6 +39,7 @@ private final class SettingsModel: ObservableObject { animations = config.animations livePreviews = config.livePreviewsEnabled displayMode = config.displayModeOrDefault + letterJump = config.letterJumpEnabled base = config } @@ -46,11 +48,13 @@ private final class SettingsModel: ObservableObject { config.animations = animations config.livePreviews = livePreviews config.displayMode = displayMode + config.letterJump = letterJump do { try Config.patchOnDisk([ ("animations", animations ? "true" : "false"), ("livePreviews", livePreviews ? "true" : "false"), ("displayMode", "\"\(displayMode.rawValue)\""), + ("letterJump", letterJump ? "true" : "false"), ]) base = config onSave?(config) @@ -100,6 +104,16 @@ private struct SettingsRootView: View { } .toggleStyle(.switch) + Toggle(isOn: $model.letterJump) { + VStack(alignment: .leading, spacing: 2) { + Text("First-letter app jump").font(.system(size: 13, weight: .medium)) + Text("Press an app's first letter to select it; repeat to cycle matches.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .toggleStyle(.switch) + VStack(alignment: .leading, spacing: 6) { Text("Show app in").font(.system(size: 13, weight: .medium)) Picker("", selection: $model.displayMode) { @@ -126,6 +140,6 @@ private struct SettingsRootView: View { } } .padding(24) - .frame(minWidth: 420, minHeight: 360) + .frame(minWidth: 420, minHeight: 420) } } From 5c1352a22e57e585a3de76c6c9aee8933443884e Mon Sep 17 00:00:00 2001 From: Peter Pistorius Date: Tue, 28 Apr 2026 22:41:30 +0200 Subject: [PATCH 2/2] Gate letter-jump behind Control modifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bare letters were colliding with the wasd movement bindings, making letter-jump unreachable for apps starting with W/A/S/D. Require ⌃ (Control) + letter to fire letter-jump; bare wasd remains as movement. Control was chosen because no existing default binding uses it (Cmd, Opt, Shift are all in use). Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/cmdcmd/OverlayView.swift | 10 +++++----- Sources/cmdcmd/SettingsWindow.swift | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/cmdcmd/OverlayView.swift b/Sources/cmdcmd/OverlayView.swift index 13051eb..7043d31 100644 --- a/Sources/cmdcmd/OverlayView.swift +++ b/Sources/cmdcmd/OverlayView.swift @@ -26,11 +26,7 @@ final class OverlayView: NSView { onSpaceDown?() return } - if let action = keymap.action(for: event) { - onAction?(action) - return - } - if bareMods.isEmpty, + if bareMods == [.control], let onLetter, let chars = event.charactersIgnoringModifiers?.lowercased(), chars.count == 1, @@ -39,6 +35,10 @@ final class OverlayView: NSView { onLetter(chars) return } + if let action = keymap.action(for: event) { + onAction?(action) + return + } } override func keyUp(with event: NSEvent) { diff --git a/Sources/cmdcmd/SettingsWindow.swift b/Sources/cmdcmd/SettingsWindow.swift index f5a3c79..b34befc 100644 --- a/Sources/cmdcmd/SettingsWindow.swift +++ b/Sources/cmdcmd/SettingsWindow.swift @@ -107,7 +107,7 @@ private struct SettingsRootView: View { Toggle(isOn: $model.letterJump) { VStack(alignment: .leading, spacing: 2) { Text("First-letter app jump").font(.system(size: 13, weight: .medium)) - Text("Press an app's first letter to select it; repeat to cycle matches.") + Text("Hold ⌃ (Control) + an app's first letter to select it; repeat to cycle matches.") .font(.caption) .foregroundStyle(.secondary) }