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..7043d31 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 } @@ -25,6 +26,15 @@ final class OverlayView: NSView { onSpaceDown?() return } + if bareMods == [.control], + let onLetter, + let chars = event.charactersIgnoringModifiers?.lowercased(), + chars.count == 1, + let scalar = chars.unicodeScalars.first, + CharacterSet.lowercaseLetters.contains(scalar) { + onLetter(chars) + return + } if let action = keymap.action(for: event) { onAction?(action) return diff --git a/Sources/cmdcmd/SettingsWindow.swift b/Sources/cmdcmd/SettingsWindow.swift index a750b08..b34befc 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("Hold ⌃ (Control) + 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) } }