From 8b2c48af41b9aae0a50af32be24eb77abd4d85b9 Mon Sep 17 00:00:00 2001 From: Peter Pistorius Date: Tue, 28 Apr 2026 22:46:04 +0200 Subject: [PATCH] Add tile ordering by recent app usage New "Order tiles by recent app usage" setting (default off). When on, tiles are sorted by most-recent activation of their owning app, with saved drag-order as a tiebreak. Activation history is recorded via NSWorkspace.didActivateApplicationNotification and capped at 128 entries in UserDefaults under "appUsageOrder". Keyed by bundle identifier (with pid:N fallback) so it's stable across windows. Off by default to preserve user-controlled drag-to-reorder behavior; turning it on takes over and ignores the saved per-display tile order. Co-Authored-By: plyght Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/d4b5a9e4.md | 5 +++ Sources/cmdcmd/Config.swift | 4 ++- Sources/cmdcmd/Overlay.swift | 53 +++++++++++++++++++++++++++-- Sources/cmdcmd/SettingsWindow.swift | 18 ++++++++-- 4 files changed, 75 insertions(+), 5 deletions(-) create mode 100644 .changeset/d4b5a9e4.md diff --git a/.changeset/d4b5a9e4.md b/.changeset/d4b5a9e4.md new file mode 100644 index 0000000..111c5e9 --- /dev/null +++ b/.changeset/d4b5a9e4.md @@ -0,0 +1,5 @@ +--- +bump: patch +--- + +Optionally order tiles by recent app usage. diff --git a/Sources/cmdcmd/Config.swift b/Sources/cmdcmd/Config.swift index 59305f4..46977ac 100644 --- a/Sources/cmdcmd/Config.swift +++ b/Sources/cmdcmd/Config.swift @@ -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()) diff --git a/Sources/cmdcmd/Overlay.swift b/Sources/cmdcmd/Overlay.swift index 5aaee4c..7fbe918 100644 --- a/Sources/cmdcmd/Overlay.swift +++ b/Sources/cmdcmd/Overlay.swift @@ -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 @@ -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() } @@ -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() { @@ -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 @@ -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) }) diff --git a/Sources/cmdcmd/SettingsWindow.swift b/Sources/cmdcmd/SettingsWindow.swift index b34befc..f1e53af 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: 460), + contentRect: NSRect(x: 0, y: 0, width: 460, height: 520), styleMask: [.titled, .closable, .miniaturizable], backing: .buffered, defer: false @@ -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)? @@ -40,6 +41,7 @@ private final class SettingsModel: ObservableObject { livePreviews = config.livePreviewsEnabled displayMode = config.displayModeOrDefault letterJump = config.letterJumpEnabled + usageOrdering = config.usageOrderingEnabled base = config } @@ -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) @@ -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) { @@ -140,6 +154,6 @@ private struct SettingsRootView: View { } } .padding(24) - .frame(minWidth: 420, minHeight: 420) + .frame(minWidth: 420, minHeight: 480) } }