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/d4b5a9e4.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
bump: patch
---

Optionally order tiles by recent app usage.
4 changes: 3 additions & 1 deletion Sources/cmdcmd/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ struct Config: Codable {
var livePreviews: Bool?
var displayMode: DisplayMode?
var letterJump: Bool?
var usageOrdering: Bool?

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 }

static let `default` = Config(animations: true, trigger: nil, bindings: [:], livePreviews: nil, displayMode: nil, letterJump: nil)
static let `default` = Config(animations: true, trigger: nil, bindings: [:], livePreviews: nil, displayMode: nil, letterJump: nil, usageOrdering: nil)

static var fileURL: URL {
URL(fileURLWithPath: NSHomeDirectory())
Expand Down
53 changes: 51 additions & 2 deletions Sources/cmdcmd/Overlay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,35 @@ final class Overlay {
}

private var workspaceObserver: NSObjectProtocol?
private var appActivationObserver: NSObjectProtocol?
private var activityTimer: Timer?
private let hint = HintPill()

private var cachedShareable: SCShareableContent?
private var cachedShareableAt: CFAbsoluteTime = 0

private static var usageOrder: [String] {
get { (UserDefaults.standard.array(forKey: "appUsageOrder") as? [String]) ?? [] }
set { UserDefaults.standard.set(Array(newValue.prefix(128)), forKey: "appUsageOrder") }
}

private static func usageKey(pid: pid_t, bundleIdentifier: String?) -> String {
if let id = bundleIdentifier, !id.isEmpty { return id }
return "pid:\(pid)"
}

private static func usageKey(for tile: Tile) -> String {
usageKey(pid: tile.ownerPID, bundleIdentifier: tile.scWindow.owningApplication?.bundleIdentifier)
}

private static func recordUse(of app: NSRunningApplication) {
guard app.processIdentifier != getpid(), app.activationPolicy == .regular else { return }
let key = usageKey(pid: app.processIdentifier, bundleIdentifier: app.bundleIdentifier)
var order = usageOrder.filter { $0 != key }
order.insert(key, at: 0)
usageOrder = order
}

init(tracker: SpaceTracker, config: Config) {
self.tracker = tracker
self.config = config
Expand All @@ -72,6 +95,14 @@ final class Overlay {
guard let self, self.visible, !self.isPicking else { return }
self.hide(activatePrevious: false)
}
appActivationObserver = NSWorkspace.shared.notificationCenter.addObserver(
forName: NSWorkspace.didActivateApplicationNotification,
object: nil,
queue: .main
) { notification in
guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return }
Self.recordUse(of: app)
}
prewarmShareable()
}

Expand All @@ -96,6 +127,9 @@ final class Overlay {
if let o = workspaceObserver {
NotificationCenter.default.removeObserver(o)
}
if let o = appActivationObserver {
NSWorkspace.shared.notificationCenter.removeObserver(o)
}
}

func toggle() {
Expand Down Expand Up @@ -123,7 +157,9 @@ final class Overlay {

private func show() {
let t0 = CFAbsoluteTimeGetCurrent()
prevFrontPID = NSWorkspace.shared.frontmostApplication?.processIdentifier ?? 0
let prevApp = NSWorkspace.shared.frontmostApplication
prevFrontPID = prevApp?.processIdentifier ?? 0
if let prevApp { Self.recordUse(of: prevApp) }
prevFrontTitle = focusedWindowTitle(pid: prevFrontPID) ?? ""
let screen = Self.cursorScreen()
activeScreen = screen
Expand Down Expand Up @@ -309,7 +345,20 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B

let saved = savedOrder
let ordered: [Tile]
if saved.isEmpty {
if config.usageOrderingEnabled {
let usage = Self.usageOrder
let usageRanks = Dictionary(uniqueKeysWithValues: usage.enumerated().map { ($1, $0) })
let savedRanks = Dictionary(uniqueKeysWithValues: saved.enumerated().map { ($1, $0) })
ordered = mcTiles.sorted { a, b in
let ar = usageRanks[Self.usageKey(for: a)] ?? Int.max
let br = usageRanks[Self.usageKey(for: b)] ?? Int.max
if ar != br { return ar < br }
let asr = savedRanks[CGWindowID(a.scWindow.windowID)] ?? Int.max
let bsr = savedRanks[CGWindowID(b.scWindow.windowID)] ?? Int.max
if asr != bsr { return asr < bsr }
return a.scWindow.windowID < b.scWindow.windowID
}
} else if saved.isEmpty {
ordered = mcTiles
} else {
let presentIDs = Set(mcTiles.map { CGWindowID($0.scWindow.windowID) })
Expand Down
18 changes: 16 additions & 2 deletions Sources/cmdcmd/SettingsWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: 460),
contentRect: NSRect(x: 0, y: 0, width: 460, height: 520),
styleMask: [.titled, .closable, .miniaturizable],
backing: .buffered,
defer: false
Expand All @@ -31,6 +31,7 @@ private final class SettingsModel: ObservableObject {
@Published var livePreviews: Bool { didSet { save() } }
@Published var displayMode: DisplayMode { didSet { save() } }
@Published var letterJump: Bool { didSet { save() } }
@Published var usageOrdering: Bool { didSet { save() } }
private var base: Config
@Published var status: String = ""
var onSave: ((Config) -> Void)?
Expand All @@ -40,6 +41,7 @@ private final class SettingsModel: ObservableObject {
livePreviews = config.livePreviewsEnabled
displayMode = config.displayModeOrDefault
letterJump = config.letterJumpEnabled
usageOrdering = config.usageOrderingEnabled
base = config
}

Expand All @@ -49,12 +51,14 @@ private final class SettingsModel: ObservableObject {
config.livePreviews = livePreviews
config.displayMode = displayMode
config.letterJump = letterJump
config.usageOrdering = usageOrdering
do {
try Config.patchOnDisk([
("animations", animations ? "true" : "false"),
("livePreviews", livePreviews ? "true" : "false"),
("displayMode", "\"\(displayMode.rawValue)\""),
("letterJump", letterJump ? "true" : "false"),
("usageOrdering", usageOrdering ? "true" : "false"),
])
base = config
onSave?(config)
Expand Down Expand Up @@ -114,6 +118,16 @@ private struct SettingsRootView: View {
}
.toggleStyle(.switch)

Toggle(isOn: $model.usageOrdering) {
VStack(alignment: .leading, spacing: 2) {
Text("Order tiles by recent app usage").font(.system(size: 13, weight: .medium))
Text("Most recently used apps come first. Overrides drag-to-reorder across sessions.")
.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) {
Expand All @@ -140,6 +154,6 @@ private struct SettingsRootView: View {
}
}
.padding(24)
.frame(minWidth: 420, minHeight: 420)
.frame(minWidth: 420, minHeight: 480)
}
}
Loading