Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/04a5f95c.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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.

Expand Down
9 changes: 8 additions & 1 deletion Sources/cmdcmd/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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 ?? .letters }

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())
Expand Down
154 changes: 154 additions & 0 deletions Sources/cmdcmd/LabelAssigner.swift
Original file line number Diff line number Diff line change
@@ -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<S: Sequence>(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..<chars.count {
let prev = chars[i - 1]
let cur = chars[i]
if prev.isLowercase && cur.isUppercase {
splits.append(i)
continue
}
if prev.isUppercase && cur.isUppercase,
i + 1 < chars.count, chars[i + 1].isLowercase {
splits.append(i)
continue
}
}
splits.append(chars.count)
var tokens: [String] = []
for k in 0..<splits.count - 1 {
let part = String(chars[splits[k]..<splits[k + 1]])
if !part.isEmpty { tokens.append(part) }
}
return tokens
}
}
87 changes: 78 additions & 9 deletions Sources/cmdcmd/Overlay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ final class Overlay {

func updateConfig(_ config: Config) {
self.config = config
view?.letterPickActive = config.tilePicksMode == .letters
if config.tilePicksMode != .letters {
pickBuffer = ""
}
}

private var displayKey: String = "main"
Expand Down Expand Up @@ -57,6 +61,10 @@ final class Overlay {

private var refreshGeneration: Int = 0

private let labelAssigner = LabelAssigner()
private var tileLabels: [CGWindowID: String] = [:]
private var pickBuffer: String = ""

private static var usageOrder: [String] {
get { (UserDefaults.standard.array(forKey: "appUsageOrder") as? [String]) ?? [] }
set { UserDefaults.standard.set(Array(newValue.prefix(128)), forKey: "appUsageOrder") }
Expand Down Expand Up @@ -388,13 +396,11 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> 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) {
Expand All @@ -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 }
Expand Down Expand Up @@ -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..<tiles.count) + Array(0..<min(start, tiles.count))
Expand All @@ -487,7 +521,11 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> 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)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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()
}
}
Loading
Loading