From bf164e9ef7ef238ea356241013a600d040e1614c Mon Sep 17 00:00:00 2001 From: Peter Pistorius Date: Wed, 6 May 2026 07:23:06 -0700 Subject: [PATCH 1/3] Add letter-prefix tile labels mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New "Tile labels" setting picks between numbers (1-9) and letters (app initials). In letters mode, every tile gets a sticky 2-char prefix derived from its app name — "gc" for Google Chrome, "wa" for WhatsApp, "cu" for Cursor, "cc" for Claude Code — and the user types the prefix to pick + activate. Algorithm: - Tokenize the app name on whitespace and camelCase boundaries. "WhatsApp" -> ["Whats", "App"], "VSCodium" -> ["VS", "Codium"]. - 2+ tokens: first letter of first two tokens. 1 token: first two letters. - Same-app dups keep the first letter and pick the second from a home-row pool (j k l f d s a g h) — second Chrome window becomes "gj". - Cross-app collisions extend to 3 chars from the app name (Calendar vs Camera -> "ca" vs "cam"). - Assignments are sticky for the lifetime of the process: closing or opening other windows never reshuffles existing prefixes. Input: - A typeahead buffer matches the prefix as you type. The matched portion of each tile's chip renders in yellow; tiles whose prefix does not match the buffer dim to 30%. - delete (backspace) pops the last char, esc clears the buffer (and dismisses on a second press). - letters mode suppresses 1-9 picks, ctrl+letter app jump, and the wasd movement keys; arrows still move and cmd+arrow still swaps. --- Sources/cmdcmd/Config.swift | 9 +- Sources/cmdcmd/LabelAssigner.swift | 154 ++++++++++++++++++++++++++++ Sources/cmdcmd/Overlay.swift | 87 ++++++++++++++-- Sources/cmdcmd/OverlayView.swift | 17 +++ Sources/cmdcmd/SettingsWindow.swift | 21 +++- Sources/cmdcmd/Tile.swift | 41 ++++++-- 6 files changed, 311 insertions(+), 18 deletions(-) create mode 100644 Sources/cmdcmd/LabelAssigner.swift diff --git a/Sources/cmdcmd/Config.swift b/Sources/cmdcmd/Config.swift index e6a1024..c0c39a9 100644 --- a/Sources/cmdcmd/Config.swift +++ b/Sources/cmdcmd/Config.swift @@ -6,6 +6,11 @@ enum DisplayMode: String, Codable, CaseIterable { case hidden } +enum TilePicks: String, Codable, CaseIterable { + case numbers + case letters +} + struct Config: Codable { var animations: Bool var trigger: String? @@ -14,14 +19,16 @@ struct Config: Codable { var displayMode: DisplayMode? var letterJump: Bool? var usageOrdering: Bool? + var tilePicks: TilePicks? var triggerSpec: String { trigger ?? "cmd-cmd" } var livePreviewsEnabled: Bool { livePreviews ?? true } var displayModeOrDefault: DisplayMode { displayMode ?? .dock } var letterJumpEnabled: Bool { letterJump ?? true } var usageOrderingEnabled: Bool { usageOrdering ?? false } + var tilePicksMode: TilePicks { tilePicks ?? .numbers } - static let `default` = Config(animations: true, trigger: nil, bindings: [:], livePreviews: nil, displayMode: nil, letterJump: nil, usageOrdering: nil) + static let `default` = Config(animations: true, trigger: nil, bindings: [:], livePreviews: nil, displayMode: nil, letterJump: nil, usageOrdering: nil, tilePicks: nil) static var fileURL: URL { URL(fileURLWithPath: NSHomeDirectory()) diff --git a/Sources/cmdcmd/LabelAssigner.swift b/Sources/cmdcmd/LabelAssigner.swift new file mode 100644 index 0000000..b6d9819 --- /dev/null +++ b/Sources/cmdcmd/LabelAssigner.swift @@ -0,0 +1,154 @@ +import CoreGraphics +import Foundation + +/// Assigns short typeable prefixes (e.g. "wa", "cc", "cal") to tiles for +/// letter-pick mode. Existing assignments are sticky for the lifetime of the +/// process: once a window has a prefix, closing or opening other windows +/// never reshuffles it. +final class LabelAssigner { + private struct Entry { + let prefix: String + let appKey: String + } + + private var entries: [CGWindowID: Entry] = [:] + + /// Compute prefixes for the given tiles in their grid order. Tiles already + /// in the assignment map keep their existing prefix; new tiles claim the + /// next non-colliding prefix. + func assign(_ tiles: [Tile]) -> [CGWindowID: String] { + let presentIDs = Set(tiles.map { CGWindowID($0.scWindow.windowID) }) + entries = entries.filter { presentIDs.contains($0.key) } + + var used: [String: String] = [:] // prefix -> appKey + for (_, e) in entries { used[e.prefix] = e.appKey } + + for tile in tiles { + let id = CGWindowID(tile.scWindow.windowID) + if entries[id] != nil { continue } + let appName = tile.scWindow.owningApplication?.applicationName ?? "?" + let appKey = tile.scWindow.owningApplication?.bundleIdentifier ?? appName + let natural = Self.naturalPrefix(appName: appName) + + guard !natural.isEmpty else { continue } + + if used[natural] == nil { + entries[id] = Entry(prefix: natural, appKey: appKey) + used[natural] = appKey + continue + } + + let conflictingApp = used[natural] ?? "" + let firstChar = String(natural.prefix(1)) + + if conflictingApp == appKey { + if let pick = Self.firstAvailable( + candidates: Self.homeRow.map { firstChar + String($0) }, + used: used.keys + ) { + entries[id] = Entry(prefix: pick, appKey: appKey) + used[pick] = appKey + } + } else { + let extended = Self.naturalThirdChar(appName: appName).map { natural + String($0) } + if let extended, used[extended] == nil { + entries[id] = Entry(prefix: extended, appKey: appKey) + used[extended] = appKey + } else if let pick = Self.firstAvailable( + candidates: Self.homeRow.map { natural + String($0) }, + used: used.keys + ) { + entries[id] = Entry(prefix: pick, appKey: appKey) + used[pick] = appKey + } + } + } + + return Dictionary(uniqueKeysWithValues: entries.map { ($0.key, $0.value.prefix) }) + } + + private static let homeRow: [Character] = ["j", "k", "l", "f", "d", "s", "a", "g", "h"] + + private static func firstAvailable(candidates: [String], used: S) -> String? where S.Element == String { + let usedSet = Set(used) + return candidates.first { !usedSet.contains($0) } + } + + static func naturalPrefix(appName: String) -> String { + let tokens = tokenize(appName) + if tokens.count >= 2 { + let a = tokens[0].first.map { String($0) } ?? "" + let b = tokens[1].first.map { String($0) } ?? "" + return (a + b).lowercased() + } + if let only = tokens.first { + if only.count >= 2 { + return String(only.prefix(2)).lowercased() + } + if let c = only.first { + return String([c, c]).lowercased() + } + } + return "" + } + + private static func naturalThirdChar(appName: String) -> Character? { + let tokens = tokenize(appName) + guard let first = tokens.first else { return nil } + if tokens.count >= 2 { + // Two-token prefix used letters from each — extend with the second + // letter of the first token (e.g. "cc" → "cl" for Claude Code is + // ambiguous; we instead extend within token 1: "Calendar Code" + // would land at "cal" when colliding with "cap …"). Falls back to + // token-2 second letter if token-1 has only one char. + if first.count >= 2 { + let idx = first.index(first.startIndex, offsetBy: 1) + return Character(first[idx].lowercased()) + } + if tokens[1].count >= 2 { + let idx = tokens[1].index(tokens[1].startIndex, offsetBy: 1) + return Character(tokens[1][idx].lowercased()) + } + return nil + } + guard first.count >= 3 else { return nil } + let idx = first.index(first.startIndex, offsetBy: 2) + return Character(first[idx].lowercased()) + } + + /// Split `s` into tokens on whitespace and camelCase boundaries. + /// "Claude Code" → ["Claude", "Code"] + /// "WhatsApp" → ["Whats", "App"] + /// "VSCodium" → ["VS", "Codium"] + static func tokenize(_ s: String) -> [String] { + s.split(whereSeparator: { $0.isWhitespace }) + .flatMap { camelSplit(String($0)) } + .filter { !$0.isEmpty } + } + + private static func camelSplit(_ word: String) -> [String] { + guard !word.isEmpty else { return [] } + let chars = Array(word) + var splits: [Int] = [0] + for i in 1.. B for t in allTiles { t.layer.isHidden = !visibleSet.contains(ObjectIdentifier(t)) t.layer.opacity = 1.0 - t.setNumber(nil) + t.setLabel(nil) t.tintColorName = paneColors[CGWindowID(t.scWindow.windowID)] } tiles = displayed - for (i, t) in tiles.enumerated() { - t.setNumber(i < 9 ? i + 1 : nil) - } + applyTileLabels() let bounds = window?.contentView?.bounds ?? .zero layoutTiles(in: bounds) if !tiles.indices.contains(selectedIndex) { @@ -403,6 +409,33 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B updateSelection() } + private func applyTileLabels() { + switch config.tilePicksMode { + case .numbers: + for (i, t) in tiles.enumerated() { + t.setLabel(i < 9 ? "\(i + 1)" : nil) + } + case .letters: + tileLabels = labelAssigner.assign(allTiles) + let buffer = pickBuffer + for t in allTiles { + let id = CGWindowID(t.scWindow.windowID) + let label = tileLabels[id] + let matched: Int + if !buffer.isEmpty, let label, label.hasPrefix(buffer) { + matched = buffer.count + } else { + matched = 0 + } + t.setLabel(label, matchPrefix: matched) + if !buffer.isEmpty { + let dims = !(label?.hasPrefix(buffer) ?? false) + t.layer.opacity = dims ? 0.3 : 1.0 + } + } + } + } + private static func matches(tile: Tile, query: String) -> Bool { let q = query.trimmingCharacters(in: .whitespaces) if q.isEmpty { return true } @@ -469,7 +502,8 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B } private func selectApp(startingWith letter: String) { - guard config.letterJumpEnabled, !tiles.isEmpty else { return } + guard config.tilePicksMode != .letters, + config.letterJumpEnabled, !tiles.isEmpty else { return } let needle = letter.lowercased() let start = lastLetterJump == needle ? selectedIndex + 1 : 0 let order = Array(start.. B switch action { case .pick: pick() case .dismiss: - if !searchQuery.isEmpty { cancelSearch() } + if !pickBuffer.isEmpty { + pickBuffer = "" + applyTileLabels() + } + else if !searchQuery.isEmpty { cancelSearch() } else { dismiss() } case .search: enterSearch() case .moveLeft: move(dx: -1, dy: 0) @@ -587,6 +625,7 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B lastLetterJump = nil searching = false searchQuery = "" + pickBuffer = "" search.hide() view?.resetMomentaryPeek() Task(priority: .utility) { @@ -839,9 +878,7 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B } private func renumberTiles() { - for (i, t) in tiles.enumerated() { - t.setNumber(i < 9 ? i + 1 : nil) - } + applyTileLabels() } private func layoutTilesAnimated() { @@ -951,8 +988,40 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B 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) } + v.onTypeahead = { [weak self] ch in self?.appendPickBuffer(ch) } + v.onTypeaheadBackspace = { [weak self] in self?.popPickBuffer() } + v.letterPickActive = config.tilePicksMode == .letters w.contentView = v view = v return w } + + private func appendPickBuffer(_ ch: String) { + guard config.tilePicksMode == .letters else { return } + let candidate = pickBuffer + ch + let matches = tiles.filter { tile in + guard let label = tileLabels[CGWindowID(tile.scWindow.windowID)] else { return false } + return label.hasPrefix(candidate) + } + guard !matches.isEmpty else { return } + pickBuffer = candidate + if matches.count == 1, matches[0].layer.isHidden == false, + let label = tileLabels[CGWindowID(matches[0].scWindow.windowID)], + label == candidate { + if let idx = tiles.firstIndex(where: { $0 === matches[0] }) { + selectedIndex = idx + updateSelection() + pick() + return + } + } + applyTileLabels() + } + + private func popPickBuffer() { + guard config.tilePicksMode == .letters else { return } + guard !pickBuffer.isEmpty else { return } + pickBuffer.removeLast() + applyTileLabels() + } } diff --git a/Sources/cmdcmd/OverlayView.swift b/Sources/cmdcmd/OverlayView.swift index 7043d31..faba35a 100644 --- a/Sources/cmdcmd/OverlayView.swift +++ b/Sources/cmdcmd/OverlayView.swift @@ -14,6 +14,9 @@ final class OverlayView: NSView { var onMouseDragged: ((NSPoint) -> Void)? var onMouseUp: ((NSPoint) -> Void)? var onLetter: ((String) -> Void)? + var onTypeahead: ((String) -> Void)? + var onTypeaheadBackspace: (() -> Void)? + var letterPickActive: Bool = false private var momentaryPeek = false override var acceptsFirstResponder: Bool { true } @@ -26,6 +29,20 @@ final class OverlayView: NSView { onSpaceDown?() return } + if letterPickActive && bareMods.isEmpty { + if event.keyCode == 51, let onTypeaheadBackspace { + onTypeaheadBackspace() + return + } + if let chars = event.charactersIgnoringModifiers?.lowercased(), + chars.count == 1, + let scalar = chars.unicodeScalars.first, + CharacterSet.lowercaseLetters.contains(scalar) || CharacterSet.decimalDigits.contains(scalar), + let onTypeahead { + onTypeahead(chars) + return + } + } if bareMods == [.control], let onLetter, let chars = event.charactersIgnoringModifiers?.lowercased(), diff --git a/Sources/cmdcmd/SettingsWindow.swift b/Sources/cmdcmd/SettingsWindow.swift index f1e53af..98779f8 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: 520), + contentRect: NSRect(x: 0, y: 0, width: 460, height: 620), styleMask: [.titled, .closable, .miniaturizable], backing: .buffered, defer: false @@ -32,6 +32,7 @@ private final class SettingsModel: ObservableObject { @Published var displayMode: DisplayMode { didSet { save() } } @Published var letterJump: Bool { didSet { save() } } @Published var usageOrdering: Bool { didSet { save() } } + @Published var tilePicks: TilePicks { didSet { save() } } private var base: Config @Published var status: String = "" var onSave: ((Config) -> Void)? @@ -42,6 +43,7 @@ private final class SettingsModel: ObservableObject { displayMode = config.displayModeOrDefault letterJump = config.letterJumpEnabled usageOrdering = config.usageOrderingEnabled + tilePicks = config.tilePicksMode base = config } @@ -52,6 +54,7 @@ private final class SettingsModel: ObservableObject { config.displayMode = displayMode config.letterJump = letterJump config.usageOrdering = usageOrdering + config.tilePicks = tilePicks do { try Config.patchOnDisk([ ("animations", animations ? "true" : "false"), @@ -59,6 +62,7 @@ private final class SettingsModel: ObservableObject { ("displayMode", "\"\(displayMode.rawValue)\""), ("letterJump", letterJump ? "true" : "false"), ("usageOrdering", usageOrdering ? "true" : "false"), + ("tilePicks", "\"\(tilePicks.rawValue)\""), ]) base = config onSave?(config) @@ -128,6 +132,19 @@ private struct SettingsRootView: View { } .toggleStyle(.switch) + VStack(alignment: .leading, spacing: 6) { + Text("Tile labels").font(.system(size: 13, weight: .medium)) + Picker("", selection: $model.tilePicks) { + Text("Numbers (1–9)").tag(TilePicks.numbers) + Text("Letters (app initials)").tag(TilePicks.letters) + } + .labelsHidden() + .pickerStyle(.segmented) + Text("Letters mode types each tile's app initials (e.g. \"gc\" for Google Chrome) — type the prefix to pick. Disables 1–9 picks, ⌃+letter app jump, and the wasd movement keys.") + .font(.caption) + .foregroundStyle(.secondary) + } + VStack(alignment: .leading, spacing: 6) { Text("Show app in").font(.system(size: 13, weight: .medium)) Picker("", selection: $model.displayMode) { @@ -154,6 +171,6 @@ private struct SettingsRootView: View { } } .padding(24) - .frame(minWidth: 420, minHeight: 480) + .frame(minWidth: 420, minHeight: 580) } } diff --git a/Sources/cmdcmd/Tile.swift b/Sources/cmdcmd/Tile.swift index daa0192..d1b9fa6 100644 --- a/Sources/cmdcmd/Tile.swift +++ b/Sources/cmdcmd/Tile.swift @@ -183,17 +183,19 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { } private let windowTitle: String - private var currentNumber: Int? + private var currentLabel: String? + private var currentMatchCount: Int = 0 - func setNumber(_ n: Int?) { - currentNumber = n + func setLabel(_ s: String?, matchPrefix: Int = 0) { + currentLabel = s + currentMatchCount = max(0, min(matchPrefix, s?.count ?? 0)) updateLabel() } private func updateLabel() { let trimmed = windowTitle.trimmingCharacters(in: .whitespacesAndNewlines) - if let n = currentNumber { - numberText.string = "\(n)" + if let label = currentLabel, !label.isEmpty { + numberText.string = Self.attributedLabel(label, matched: currentMatchCount) numberChip.isHidden = false } else { numberText.string = "" @@ -204,6 +206,23 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { layoutLabel() } + private static let labelFont = NSFont.systemFont(ofSize: 12, weight: .bold) + private static let labelMatchedColor = NSColor.systemYellow + private static let labelUnmatchedColor = NSColor.white + + private static func attributedLabel(_ text: String, matched: Int) -> NSAttributedString { + let attr = NSMutableAttributedString(string: text) + let full = NSRange(location: 0, length: (text as NSString).length) + attr.addAttribute(.font, value: labelFont, range: full) + attr.addAttribute(.foregroundColor, value: labelUnmatchedColor, range: full) + if matched > 0 { + let clamped = min(matched, text.count) + let matchedRange = NSRange(location: 0, length: clamped) + attr.addAttribute(.foregroundColor, value: labelMatchedColor, range: matchedRange) + } + return attr + } + func setFrame(_ rect: CGRect) { let newBounds = CGRect(origin: .zero, size: rect.size) let newShadowPath = CGPath(roundedRect: newBounds, cornerWidth: 10, cornerHeight: 10, transform: nil) @@ -247,10 +266,20 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { let textY = (badgeHeight - lineHeight) / 2 let chipHidden = numberChip.isHidden + let chipText = (currentLabel ?? "") + let chipWidth: CGFloat + if chipHidden || chipText.isEmpty { + chipWidth = 0 + } else if chipText.count <= 1 { + chipWidth = badgeHeight + } else { + let measured = (chipText as NSString).size(withAttributes: [.font: Self.labelFont]).width + chipWidth = max(badgeHeight, ceil(measured) + hPad * 2) + } let chipFrame = CGRect( x: inset, y: rect.size.height - badgeHeight - inset, - width: chipHidden ? 0 : badgeHeight, + width: chipWidth, height: badgeHeight ) if !chipHidden { From 614b4510dd288f6816a9f46905f43908b6bd2ef6 Mon Sep 17 00:00:00 2001 From: Peter Pistorius Date: Wed, 6 May 2026 07:24:56 -0700 Subject: [PATCH 2/3] Default tile labels to letters mode Flips the unset-config default from numbers to letters and updates the README accordingly. Numbers (and wasd / ctrl+letter app jump) remain available via "tilePicks": "numbers" in the config or the Settings picker. --- README.md | 15 ++++++++++++--- Sources/cmdcmd/Config.swift | 2 +- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 479102c..de7ae6c 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,8 @@ Requires macOS 14+. | Key | Action | |---|---| -| arrow keys / `wasd` | Move selection | -| `1`–`9` | Pick that tile | +| arrow keys | Move selection | +| type a tile's prefix | Pick that tile (e.g. `gc` for Google Chrome — see Tile labels below) | | `return` | Pick selected tile | | `space` (hold) | Peek (zoom selected tile while held) | | click / drag | Pick or drag-to-reorder | @@ -26,7 +26,16 @@ Requires macOS 14+. | ⌘F | Search / filter visible windows (substring match on app + title) | | ⌥`g`/`b`/`r`/`y`/`o`/`p` | Tag selected tile (green/blue/red/yellow/orange/purple) | | ⌥`0` | Clear tag on selected tile | -| `esc` | Dismiss overlay | +| `delete` | Pop the last char from the pick buffer | +| `esc` | Clear pick buffer, or dismiss overlay | + +### Tile labels + +Each tile gets a 2-char prefix derived from its app name — `gc` for Google Chrome, `wa` for WhatsApp, `cu` for Cursor, `cc` for Claude Code. Type the prefix to pick + activate the window; the matched portion highlights in yellow as you type, and tiles whose prefix doesn't match dim. + +A second window of the same app keeps the first letter and grabs the next home-row letter (`gj`, `gk`, …). Cross-app collisions extend to 3 chars (Calendar vs Camera → `ca` vs `cam`). Assignments are sticky — closing one window doesn't reshuffle the others. + +Switch to numeric `1`–`9` picks (and `wasd` movement, `⌃+letter` app jump) by setting `"tilePicks": "numbers"` in the config or via Settings. Tile order persists per display via `UserDefaults`. Idle windows (no draw activity for ~2.5s) get a subtle indicator dot. diff --git a/Sources/cmdcmd/Config.swift b/Sources/cmdcmd/Config.swift index c0c39a9..a46787c 100644 --- a/Sources/cmdcmd/Config.swift +++ b/Sources/cmdcmd/Config.swift @@ -26,7 +26,7 @@ struct Config: Codable { var displayModeOrDefault: DisplayMode { displayMode ?? .dock } var letterJumpEnabled: Bool { letterJump ?? true } var usageOrderingEnabled: Bool { usageOrdering ?? false } - var tilePicksMode: TilePicks { tilePicks ?? .numbers } + var tilePicksMode: TilePicks { tilePicks ?? .letters } static let `default` = Config(animations: true, trigger: nil, bindings: [:], livePreviews: nil, displayMode: nil, letterJump: nil, usageOrdering: nil, tilePicks: nil) From a9a49e5e9b03978ceac92018863443d9f9881a69 Mon Sep 17 00:00:00 2001 From: Peter Pistorius Date: Wed, 6 May 2026 07:25:53 -0700 Subject: [PATCH 3/3] Add changeset for letter-prefix tile labels --- .changeset/04a5f95c.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/04a5f95c.md diff --git a/.changeset/04a5f95c.md b/.changeset/04a5f95c.md new file mode 100644 index 0000000..cda3358 --- /dev/null +++ b/.changeset/04a5f95c.md @@ -0,0 +1,5 @@ +--- +bump: minor +--- + +Add letter-prefix tile labels (default). Each tile gets a 2-char prefix from its app name (e.g. "gc" Google Chrome, "wa" WhatsApp); type the prefix to pick. Settings → Tile labels → Numbers to keep the previous 1-9 / wasd behavior.