From 99409cda8ff2cc6aad1f1f0c04aa0c1cba1c4fa9 Mon Sep 17 00:00:00 2001 From: Peter Pistorius Date: Wed, 6 May 2026 06:59:53 -0700 Subject: [PATCH 1/2] Render overlay from fresh state and drop the ignore feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes that all sharpen what the overlay shows on cmd-cmd: - Remove the ignore / show-hidden feature (cmd+delete, cmd+y). The bundle+title key was unreliable across tabs and doc renames, the feature was hidden behind chords with no UI affordance, and the filtered tile list it produced was the main reason savedOrder kept drifting. - Persist drag and cmd+arrow reorders through allTiles instead of the search-filtered tiles list. savedOrder now reflects every known window, so newly-opened windows reliably append at the back. - Always render from a fresh SCShareableContent fetch on show. The prewarm-then-reconcile path is gone — no more stale tiles sliding into their final positions after every cmd-cmd. Prewarm still runs in the background to keep the framework call warm. --- README.md | 6 +- Sources/cmdcmd/Config.swift | 2 - Sources/cmdcmd/HintPill.swift | 59 -------- Sources/cmdcmd/Keymap.swift | 6 - Sources/cmdcmd/Overlay.swift | 258 ++++------------------------------ Sources/cmdcmd/Tile.swift | 4 - 6 files changed, 28 insertions(+), 307 deletions(-) delete mode 100644 Sources/cmdcmd/HintPill.swift diff --git a/README.md b/README.md index 8c439ad..479102c 100644 --- a/README.md +++ b/README.md @@ -23,14 +23,12 @@ Requires macOS 14+. | click / drag | Pick or drag-to-reorder | | ⌘ + arrow | Swap selected tile with neighbour in that direction | | ⌘W | Close selected window | -| ⌘`delete` | Ignore / un-ignore selected window | -| ⌘Y | Toggle "show hidden" view | | ⌘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 | -Tile order and ignored windows persist per display via `UserDefaults`. Idle windows (no draw activity for ~2.5s) get a subtle indicator dot. The "show hidden" view displays every window — ignored ones at reduced opacity — so you can un-ignore them. +Tile order persists per display via `UserDefaults`. Idle windows (no draw activity for ~2.5s) get a subtle indicator dot. ### Config file @@ -56,7 +54,7 @@ Right-click the `⌘ ⌘` Dock icon and pick **Open Config…** — that opens ` Binding spec — modifier tokens: `cmd`, `shift`, `opt` (or `option`/`alt`), `ctrl`. Special keys: `esc`, `space`, `return`, `delete`, `left`, `right`, `up`, `down`. Anything else is a single character. -Actions: `pick`, `dismiss`, `move-left|right|up|down`, `swap-left|right|up|down`, `pick-1` … `pick-9`, `ignore`, `toggle-hidden`, `close`, `search`, `tag-green|blue|red|yellow|orange|purple|clear`. +Actions: `pick`, `dismiss`, `move-left|right|up|down`, `swap-left|right|up|down`, `pick-1` … `pick-9`, `close`, `search`, `tag-green|blue|red|yellow|orange|purple|clear`. ## Build diff --git a/Sources/cmdcmd/Config.swift b/Sources/cmdcmd/Config.swift index 3471244..e6a1024 100644 --- a/Sources/cmdcmd/Config.swift +++ b/Sources/cmdcmd/Config.swift @@ -330,8 +330,6 @@ struct Config: Codable { ("8", .pick8), ("9", .pick9), ("cmd+w", .close), - ("cmd+delete", .ignore), - ("cmd+y", .toggleHidden), ("cmd+f", .search), ("opt+g", .tagGreen), ("opt+b", .tagBlue), diff --git a/Sources/cmdcmd/HintPill.swift b/Sources/cmdcmd/HintPill.swift deleted file mode 100644 index ea25002..0000000 --- a/Sources/cmdcmd/HintPill.swift +++ /dev/null @@ -1,59 +0,0 @@ -import AppKit - -/// A small text pill anchored to the bottom-center of the overlay, -/// used to surface mode hints like "Focus" or "Hidden". -final class HintPill { - private var layer: CATextLayer? - - func show(text: String, in parent: CALayer, bounds: CGRect) { - let l = layer ?? makeLayer() - if layer == nil { - parent.addSublayer(l) - layer = l - } - l.string = text - l.isHidden = false - layout(in: bounds) - } - - func hide() { - layer?.isHidden = true - } - - /// Drop the cached layer reference. Call after the parent layer has - /// been wiped (e.g. on overlay teardown) so the next show() builds fresh. - func reset() { - layer = nil - } - - private func layout(in bounds: CGRect) { - guard let l = layer else { return } - let text = (l.string as? String) ?? "" - let attrs: [NSAttributedString.Key: Any] = [ - .font: l.font as? NSFont ?? NSFont.systemFont(ofSize: 12, weight: .medium) - ] - let textWidth = (text as NSString).size(withAttributes: attrs).width - let pad: CGFloat = 18 - let height: CGFloat = 26 - let width = ceil(textWidth) + pad * 2 - l.frame = CGRect( - x: (bounds.width - width) / 2, - y: 24, - width: width, - height: height - ) - } - - private func makeLayer() -> CATextLayer { - let h = CATextLayer() - h.alignmentMode = .center - h.foregroundColor = NSColor.white.withAlphaComponent(0.85).cgColor - h.backgroundColor = NSColor.black.withAlphaComponent(0.55).cgColor - h.cornerRadius = 10 - h.masksToBounds = true - h.font = NSFont.systemFont(ofSize: 12, weight: .medium) - h.fontSize = 12 - h.contentsScale = NSScreen.main?.backingScaleFactor ?? 2 - return h - } -} diff --git a/Sources/cmdcmd/Keymap.swift b/Sources/cmdcmd/Keymap.swift index 34d7554..db91ccf 100644 --- a/Sources/cmdcmd/Keymap.swift +++ b/Sources/cmdcmd/Keymap.swift @@ -11,8 +11,6 @@ enum Action: String, Codable, Hashable { case swapRight = "swap-right" case swapUp = "swap-up" case swapDown = "swap-down" - case ignore - case toggleHidden = "toggle-hidden" case close case tagGreen = "tag-green" case tagBlue = "tag-blue" @@ -44,8 +42,6 @@ enum Action: String, Codable, Hashable { case .swapRight: return "Swap with the tile on the right" case .swapUp: return "Swap with the tile above" case .swapDown: return "Swap with the tile below" - case .ignore: return "Ignore / un-ignore the selected window" - case .toggleHidden: return "Toggle the 'show hidden' view" case .close: return "Close the selected window" case .tagGreen: return "Tag green" case .tagBlue: return "Tag blue" @@ -134,8 +130,6 @@ final class Keymap { "left": .moveLeft, "right": .moveRight, "up": .moveUp, "down": .moveDown, "a": .moveLeft, "d": .moveRight, "w": .moveUp, "s": .moveDown, "cmd+left": .swapLeft, "cmd+right": .swapRight, "cmd+up": .swapUp, "cmd+down": .swapDown, - "cmd+delete": .ignore, - "cmd+y": .toggleHidden, "cmd+w": .close, "cmd+f": .search, "opt+g": .tagGreen, "opt+b": .tagBlue, "opt+r": .tagRed, "opt+y": .tagYellow, diff --git a/Sources/cmdcmd/Overlay.swift b/Sources/cmdcmd/Overlay.swift index 2bd02f1..673b522 100644 --- a/Sources/cmdcmd/Overlay.swift +++ b/Sources/cmdcmd/Overlay.swift @@ -17,7 +17,6 @@ final class Overlay { private var prevFrontPID: pid_t = 0 private var prevFrontTitle: String = "" private var prevPickedWindowID: CGWindowID? - private var showIgnored: Bool = false private var dragState: DragState? private var lastLetterJump: String? private let tracker: SpaceTracker @@ -30,11 +29,6 @@ final class Overlay { private var displayKey: String = "main" private var activeScreen: NSScreen? - private var ignoredKeys: Set { - get { Set((UserDefaults.standard.array(forKey: "ignoredWindows.\(displayKey)") as? [String]) ?? []) } - set { UserDefaults.standard.set(Array(newValue), forKey: "ignoredWindows.\(displayKey)") } - } - private var paneColors: [CGWindowID: String] = [:] private struct DragState { @@ -57,13 +51,10 @@ final class Overlay { private var workspaceObserver: NSObjectProtocol? private var appActivationObserver: NSObjectProtocol? private var activityTimer: Timer? - private let hint = HintPill() private let search = SearchField() private var searchQuery: String = "" private var searching: Bool = false - private var cachedShareable: SCShareableContent? - private var cachedShareableAt: CFAbsoluteTime = 0 private var refreshGeneration: Int = 0 private static var usageOrder: [String] { @@ -111,14 +102,9 @@ final class Overlay { } private func prewarmShareable() { - Task { [weak self] in + Task { do { - let c = try await SCShareableContent.excludingDesktopWindows(true, onScreenWindowsOnly: true) - await MainActor.run { - guard let self else { return } - self.cachedShareable = c - self.cachedShareableAt = CFAbsoluteTimeGetCurrent() - } + _ = try await SCShareableContent.excludingDesktopWindows(true, onScreenWindowsOnly: true) } catch { Log.write("SCShareableContent prewarm failed: \(error)") } @@ -169,18 +155,14 @@ final class Overlay { activeScreen = screen displayKey = Self.displayKeyString(for: screen) visible = true + refreshGeneration &+= 1 + let gen = refreshGeneration startActivityTimer() - Log.debug(String(format: "show: setup=%.1fms prevFrontPID=%d title=\"%@\" cached=%@", + Log.debug(String(format: "show: setup=%.1fms prevFrontPID=%d title=\"%@\"", (CFAbsoluteTimeGetCurrent() - t0) * 1000, - prevFrontPID, prevFrontTitle as NSString, - cachedShareable == nil ? "no" : "yes")) + prevFrontPID, prevFrontTitle as NSString)) - if let cached = cachedShareable { - renderOverlay(content: cached, screen: screen) - Task { await refreshAndReconcile(screen: screen) } - } else { - Task { await prepareAndShow() } - } + Task { await prepareAndShow(gen: gen, screen: screen) } } private func renderOverlay(content: SCShareableContent, screen: NSScreen) { @@ -272,7 +254,7 @@ final class Overlay { return title as? String } - private func prepareAndShow() async { + private func prepareAndShow(gen: Int, screen: NSScreen) async { let scContent: SCShareableContent? do { scContent = try await SCShareableContent.excludingDesktopWindows(true, onScreenWindowsOnly: true) @@ -282,167 +264,8 @@ final class Overlay { } guard let content = scContent else { return } await MainActor.run { - self.cachedShareable = content - self.cachedShareableAt = CFAbsoluteTimeGetCurrent() - let s = self.activeScreen ?? Self.cursorScreen() - self.renderOverlay(content: content, screen: s) - } - } - - private func refreshAndReconcile(screen: NSScreen) async { - refreshGeneration &+= 1 - let gen = refreshGeneration - let content: SCShareableContent - do { - content = try await SCShareableContent.excludingDesktopWindows(true, onScreenWindowsOnly: true) - } catch { - Log.write("SCShareableContent refresh failed: \(error)") - return - } - await MainActor.run { - self.cachedShareable = content - self.cachedShareableAt = CFAbsoluteTimeGetCurrent() guard self.visible, gen == self.refreshGeneration else { return } - let displayBounds = CGDisplayBounds(Self.displayID(for: screen)) - let candidates = content.windows - .filter(Self.isCapturable) - .filter { Self.windowMostlyOn(displayBounds: displayBounds, window: $0) } - // The first reconcile after a show fixes up phantom tiles from the - // prewarm cache: windows closed externally before the user reopened - // the overlay never really "appeared," so don't fade them out. - self.reconcileTiles(candidates: candidates, silentRemovals: true) - } - } - - private static let reconcileDuration: TimeInterval = 0.2 - - private func reconcileTiles(candidates: [SCWindow], silentRemovals: Bool = false) { - let newIDs = Set(candidates.map { CGWindowID($0.windowID) }) - let currentIDs = Set(allTiles.map { CGWindowID($0.scWindow.windowID) }) - let addedIDs = newIDs.subtracting(currentIDs) - let removedIDs = currentIDs.subtracting(newIDs) - - // Refresh kept tiles' SCWindow so .frame reflects the current size. - // Without this, a window resized between prewarm and show keeps a - // stale frame and the tile renders at the old aspect ratio. - let candidateMap = Dictionary(uniqueKeysWithValues: candidates.map { (CGWindowID($0.windowID), $0) }) - var resized: [Tile] = [] - for t in allTiles { - let id = CGWindowID(t.scWindow.windowID) - guard !removedIDs.contains(id), let fresh = candidateMap[id] else { continue } - let oldSize = t.scWindow.frame.size - let newSize = fresh.frame.size - t.scWindow = fresh - if abs(oldSize.width - newSize.width) > 1 || abs(oldSize.height - newSize.height) > 1 { - resized.append(t) - } - } - - guard !addedIDs.isEmpty || !removedIDs.isEmpty || !resized.isEmpty else { return } - Log.debug("reconcile: +\(addedIDs.count) -\(removedIDs.count) ~\(resized.count) (was \(currentIDs.count), now \(newIDs.count))") - - let added: [Tile] = candidates.compactMap { w -> Tile? in - let id = CGWindowID(w.windowID) - guard addedIDs.contains(id), let pid = w.owningApplication?.processID else { return nil } - return Tile(scWindow: w, ownerPID: pid) - } - let removed: [Tile] = allTiles.filter { removedIDs.contains(CGWindowID($0.scWindow.windowID)) } - let kept: [Tile] = allTiles.filter { !removedIDs.contains(CGWindowID($0.scWindow.windowID)) } - - let ordered = orderTiles(kept + added) - savedOrder = ordered.map { CGWindowID($0.scWindow.windowID) } - allTiles = ordered - - let prevSelectedID = tiles.indices.contains(selectedIndex) - ? CGWindowID(tiles[selectedIndex].scWindow.windowID) - : nil - - // For silent removals, drop the layers immediately — these tiles only - // showed up because of the stale prewarm cache and shouldn't be in the - // animated layout pass at all. - if silentRemovals && !removed.isEmpty { - CATransaction.begin() - CATransaction.setDisableActions(true) - for t in removed { t.layer.removeFromSuperlayer() } - CATransaction.commit() - Task(priority: .utility) { - await withTaskGroup(of: Void.self) { group in - for t in removed { - group.addTask(priority: .utility) { await t.stop() } - } - } - } - } - - // Insert new layers invisibly so rebuildDisplayed's layout can place them - // before we animate them in. - CATransaction.begin() - CATransaction.setDisableActions(true) - for t in added { - t.layer.opacity = 0 - window?.contentView?.layer?.addSublayer(t.layer) - } - CATransaction.commit() - - let duration = config.animations ? Self.reconcileDuration : 0 - suspendFrames() - CATransaction.begin() - CATransaction.setAnimationDuration(duration) - CATransaction.setAnimationTimingFunction(Self.smoothEasing) - rebuildDisplayed() - // rebuildDisplayed unconditionally sets opacity = 1; restore the entrance state - // for added tiles and trigger the fade-out for removed tiles. - for t in added { t.layer.opacity = 1 } - if !silentRemovals { - for t in removed { - t.layer.opacity = 0 - t.layer.zPosition = -1 - } - } - CATransaction.commit() - resumeFrames(after: duration) - - if let sid = prevSelectedID, - let idx = tiles.firstIndex(where: { CGWindowID($0.scWindow.windowID) == sid }) { - selectedIndex = idx - updateSelection() - } - - if !silentRemovals && !removed.isEmpty { - DispatchQueue.main.asyncAfter(deadline: .now() + duration) { [weak self] in - for t in removed { t.layer.removeFromSuperlayer() } - guard self != nil else { return } - Task(priority: .utility) { - await withTaskGroup(of: Void.self) { group in - for t in removed { - group.addTask(priority: .utility) { await t.stop() } - } - } - } - } - } - - let live = config.livePreviewsEnabled - if !added.isEmpty { - Task { - await withTaskGroup(of: Void.self) { group in - for t in added { - group.addTask { - await t.snapshot() - if live { await t.start() } - } - } - } - } - } - if !resized.isEmpty { - Task { - await withTaskGroup(of: Void.self) { group in - for t in resized { - group.addTask { await t.refreshAfterResize(live: live) } - } - } - } + self.renderOverlay(content: content, screen: screen) } } @@ -560,16 +383,11 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B } private func rebuildDisplayed() { - let ignored = ignoredKeys - let baseDisplayed = allTiles.filter { showIgnored ? true : !ignored.contains($0.ignoreKey) } - let displayed = baseDisplayed.filter { Self.matches(tile: $0, query: searchQuery) } + let displayed = allTiles.filter { Self.matches(tile: $0, query: searchQuery) } let visibleSet = Set(displayed.map { ObjectIdentifier($0) }) for t in allTiles { - let isIgnored = ignored.contains(t.ignoreKey) - let inSearch = visibleSet.contains(ObjectIdentifier(t)) - let hiddenByIgnore = showIgnored ? false : isIgnored - t.layer.isHidden = hiddenByIgnore || !inSearch - t.layer.opacity = (showIgnored && isIgnored) ? 0.3 : 1.0 + t.layer.isHidden = !visibleSet.contains(ObjectIdentifier(t)) + t.layer.opacity = 1.0 t.setNumber(nil) t.tintColorName = paneColors[CGWindowID(t.scWindow.windowID)] } @@ -650,26 +468,6 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B tiles[selectedIndex].tintColorName = name } - private func toggleIgnoreSelected() { - guard tiles.indices.contains(selectedIndex) else { return } - let key = tiles[selectedIndex].ignoreKey - var set = ignoredKeys - if set.contains(key) { set.remove(key) } else { set.insert(key) } - ignoredKeys = set - let prev = selectedIndex - rebuildDisplayed() - selectedIndex = min(prev, max(0, tiles.count - 1)) - updateSelection() - layoutTilesAnimated() - } - - private func toggleShowIgnored() { - showIgnored.toggle() - rebuildDisplayed() - layoutTilesAnimated() - updateHint() - } - private func selectApp(startingWith letter: String) { guard config.letterJumpEnabled, !tiles.isEmpty else { return } let needle = letter.lowercased() @@ -689,8 +487,7 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B switch action { case .pick: pick() case .dismiss: - if showIgnored { toggleShowIgnored() } - else if !searchQuery.isEmpty { cancelSearch() } + if !searchQuery.isEmpty { cancelSearch() } else { dismiss() } case .search: enterSearch() case .moveLeft: move(dx: -1, dy: 0) @@ -701,8 +498,6 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B case .swapRight: swapSelected(dx: 1, dy: 0) case .swapUp: swapSelected(dx: 0, dy: -1) case .swapDown: swapSelected(dx: 0, dy: 1) - case .ignore: toggleIgnoreSelected() - case .toggleHidden: toggleShowIgnored() case .close: closeSelected() case .tagGreen: tagSelectedColor("green") case .tagBlue: tagSelectedColor("blue") @@ -763,15 +558,6 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B } } - private func updateHint() { - guard let win = window, let root = win.contentView?.layer else { return } - if showIgnored { - hint.show(text: "Hidden ⌘⌫ toggle esc exit", in: root, bounds: win.contentView?.bounds ?? .zero) - } else { - hint.hide() - } - } - func shutdown() { let toStop = allTiles allTiles = [] @@ -798,13 +584,11 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B tiles = [] allTiles = [] selectedIndex = 0 - showIgnored = false lastLetterJump = nil searching = false searchQuery = "" search.hide() view?.resetMomentaryPeek() - hint.hide() Task(priority: .utility) { await withTaskGroup(of: Void.self) { group in for t in toStop { @@ -831,7 +615,6 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B w?.orderOut(nil) clearLayers() } - hint.reset() DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in self?.prewarmShareable() } @@ -1013,8 +796,13 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B tile.layer.zPosition = 0 if state.moved { if let target = tiles.firstIndex(where: { $0 !== tile && $0.layer.frame.contains(point) }) { + let other = tiles[target] tiles.swapAt(state.index, target) - savedOrder = tiles.map { CGWindowID($0.scWindow.windowID) } + if let ai = allTiles.firstIndex(where: { $0 === tile }), + let bi = allTiles.firstIndex(where: { $0 === other }) { + allTiles.swapAt(ai, bi) + } + savedOrder = allTiles.map { CGWindowID($0.scWindow.windowID) } selectedIndex = target renumberTiles() } @@ -1036,8 +824,14 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B guard newCol >= 0, newCol < cols, newRow >= 0 else { return } let target = newRow * cols + newCol guard target >= 0, target < tiles.count, target != selectedIndex else { return } + let a = tiles[selectedIndex] + let b = tiles[target] tiles.swapAt(selectedIndex, target) - savedOrder = tiles.map { CGWindowID($0.scWindow.windowID) } + if let ai = allTiles.firstIndex(where: { $0 === a }), + let bi = allTiles.firstIndex(where: { $0 === b }) { + allTiles.swapAt(ai, bi) + } + savedOrder = allTiles.map { CGWindowID($0.scWindow.windowID) } selectedIndex = target renumberTiles() layoutTilesAnimated() diff --git a/Sources/cmdcmd/Tile.swift b/Sources/cmdcmd/Tile.swift index 4c3ea77..daa0192 100644 --- a/Sources/cmdcmd/Tile.swift +++ b/Sources/cmdcmd/Tile.swift @@ -56,7 +56,6 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { var scWindow: SCWindow let ownerPID: pid_t - let ignoreKey: String let layer: CALayer private let content: CALayer private let numberChip: CALayer @@ -91,9 +90,6 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { init(scWindow: SCWindow, ownerPID: pid_t) { self.scWindow = scWindow self.ownerPID = ownerPID - let bid = scWindow.owningApplication?.bundleIdentifier ?? "" - let title = scWindow.title ?? "" - self.ignoreKey = "\(bid)|||\(title)" let outer = CALayer() outer.masksToBounds = false From 4847aad5cf39b0060bf0a54235b70d98fc9c24e3 Mon Sep 17 00:00:00 2001 From: Peter Pistorius Date: Wed, 6 May 2026 07:29:11 -0700 Subject: [PATCH 2/2] Add changeset for ignore removal and fresh-render show --- .changeset/d1082b7a.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/d1082b7a.md diff --git a/.changeset/d1082b7a.md b/.changeset/d1082b7a.md new file mode 100644 index 0000000..068bfbe --- /dev/null +++ b/.changeset/d1082b7a.md @@ -0,0 +1,5 @@ +--- +bump: minor +--- + +Drop the ignore / show-hidden feature (cmd+delete, cmd+y were too hidden and the bundle+title key was unreliable). Render the overlay from a fresh window snapshot on every show — no more stale tiles sliding into place after cmd-cmd. Drag and cmd+arrow now persist order through every known window, so newly-opened windows reliably append at the back.