From 150d163fa1f91c2f0b2161e4570e1a9c5ccaa731 Mon Sep 17 00:00:00 2001 From: Peter Pistorius Date: Wed, 29 Apr 2026 12:16:12 +0200 Subject: [PATCH 1/6] Refresh tile previews on every overlay show MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cached CGImage from the previous close was being painted as hasRenderedFrame=true, which short-circuited snapshot() on the next show — so the user saw a stale frame until the live stream's first significant-change frame arrived (or forever, for idle windows). Drop the short-circuit so snapshot() always captures a fresh image, kick off snapshot+stream in parallel with the show animation instead of waiting 210ms, and stop gating the display path on the dirty-rect threshold (keep it only for the idle-dot signal) so the tile keeps tracking the source as frames arrive. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/26563231.md | 5 +++++ Sources/cmdcmd/Overlay.swift | 15 ++++++--------- Sources/cmdcmd/Tile.swift | 6 ++---- 3 files changed, 13 insertions(+), 13 deletions(-) create mode 100644 .changeset/26563231.md diff --git a/.changeset/26563231.md b/.changeset/26563231.md new file mode 100644 index 0000000..d4b9ede --- /dev/null +++ b/.changeset/26563231.md @@ -0,0 +1,5 @@ +--- +bump: patch +--- + +Refresh tile previews on every overlay show so live updates aren't masked by the previous capture diff --git a/Sources/cmdcmd/Overlay.swift b/Sources/cmdcmd/Overlay.swift index 4f83358..8b79a2c 100644 --- a/Sources/cmdcmd/Overlay.swift +++ b/Sources/cmdcmd/Overlay.swift @@ -492,15 +492,12 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B updateSelection() } let live = config.livePreviewsEnabled - let delay: TimeInterval = config.animations ? Self.pickDuration + 0.05 : 0 - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [ordered] in - Task { - await withTaskGroup(of: Void.self) { group in - for t in ordered { - group.addTask { - await t.snapshot() - if live { await t.start() } - } + Task { + await withTaskGroup(of: Void.self) { group in + for t in ordered { + group.addTask { + await t.snapshot() + if live { await t.start() } } } } diff --git a/Sources/cmdcmd/Tile.swift b/Sources/cmdcmd/Tile.swift index 6738496..ddb8cd7 100644 --- a/Sources/cmdcmd/Tile.swift +++ b/Sources/cmdcmd/Tile.swift @@ -154,7 +154,6 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { CATransaction.setDisableActions(true) inner.contents = cached CATransaction.commit() - self.hasRenderedFrame = true } } @@ -320,7 +319,7 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { } func snapshot() async { - if cancelled || hasRenderedFrame || hasRenderedLiveFrame { return } + if cancelled || hasRenderedLiveFrame { return } let filter = SCContentFilter(desktopIndependentWindow: scWindow) let config = captureConfig(maxDim: Tile.thumbMaxDim) do { @@ -334,6 +333,7 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { self.content.contents = image CATransaction.commit() self.hasRenderedFrame = true + self.lastSignificantChangeAt = CFAbsoluteTimeGetCurrent() } } catch { Log.write("tile snapshot failed wid=\(scWindow.windowID): \(error)") @@ -435,8 +435,6 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { } } - guard significantChange || !hasRenderedLiveFrame else { return } - self.lastPixelBuffer = pixelBuffer if suppressFrames { return } From 3fcb4e55939996b344ee621c9e14882ca4f3cda5 Mon Sep 17 00:00:00 2001 From: Peter Pistorius Date: Wed, 29 Apr 2026 13:09:34 +0200 Subject: [PATCH 2/6] =?UTF-8?q?Add=20=E2=8C=98F=20search=20mode=20and=20pr?= =?UTF-8?q?ogressive=20thumbnail=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search mode filters tiles by substring on app name + window title; return commits the filter, esc / Cancel clears it. Snapshot resolution now scales with the tile's on-screen footprint (live capture unchanged) so dense grids stay light while sparse grids look noticeably sharper. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/6e57fbf8.md | 5 + README.md | 3 +- Sources/cmdcmd/Config.swift | 1 + Sources/cmdcmd/Keymap.swift | 3 + Sources/cmdcmd/Overlay.swift | 67 +++++++++++- Sources/cmdcmd/SearchField.swift | 169 +++++++++++++++++++++++++++++++ Sources/cmdcmd/Tile.swift | 21 +++- 7 files changed, 262 insertions(+), 7 deletions(-) create mode 100644 .changeset/6e57fbf8.md create mode 100644 Sources/cmdcmd/SearchField.swift diff --git a/.changeset/6e57fbf8.md b/.changeset/6e57fbf8.md new file mode 100644 index 0000000..aece2a8 --- /dev/null +++ b/.changeset/6e57fbf8.md @@ -0,0 +1,5 @@ +--- +bump: minor +--- + +Search mode (cmd+F): filter tiles by substring match on app name + window title; return commits, esc / Cancel clears. diff --git a/README.md b/README.md index 2414d8f..8c439ad 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Requires macOS 14+. | ⌘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 | @@ -55,7 +56,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`, `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`, `ignore`, `toggle-hidden`, `close`, `search`, `tag-green|blue|red|yellow|orange|purple|clear`. ## Build diff --git a/Sources/cmdcmd/Config.swift b/Sources/cmdcmd/Config.swift index 46977ac..3471244 100644 --- a/Sources/cmdcmd/Config.swift +++ b/Sources/cmdcmd/Config.swift @@ -332,6 +332,7 @@ struct Config: Codable { ("cmd+w", .close), ("cmd+delete", .ignore), ("cmd+y", .toggleHidden), + ("cmd+f", .search), ("opt+g", .tagGreen), ("opt+b", .tagBlue), ("opt+r", .tagRed), diff --git a/Sources/cmdcmd/Keymap.swift b/Sources/cmdcmd/Keymap.swift index 7c4771f..34d7554 100644 --- a/Sources/cmdcmd/Keymap.swift +++ b/Sources/cmdcmd/Keymap.swift @@ -21,6 +21,7 @@ enum Action: String, Codable, Hashable { case tagOrange = "tag-orange" case tagPurple = "tag-purple" case tagClear = "tag-clear" + case search case pick1 = "pick-1" case pick2 = "pick-2" case pick3 = "pick-3" @@ -53,6 +54,7 @@ enum Action: String, Codable, Hashable { case .tagOrange: return "Tag orange" case .tagPurple: return "Tag purple" case .tagClear: return "Clear tag" + case .search: return "Search / filter visible windows" case .pick1: return "Pick tile 1" case .pick2: return "Pick tile 2" case .pick3: return "Pick tile 3" @@ -135,6 +137,7 @@ final class Keymap { "cmd+delete": .ignore, "cmd+y": .toggleHidden, "cmd+w": .close, + "cmd+f": .search, "opt+g": .tagGreen, "opt+b": .tagBlue, "opt+r": .tagRed, "opt+y": .tagYellow, "opt+o": .tagOrange, "opt+p": .tagPurple, "opt+0": .tagClear, "1": .pick1, "2": .pick2, "3": .pick3, "4": .pick4, "5": .pick5, diff --git a/Sources/cmdcmd/Overlay.swift b/Sources/cmdcmd/Overlay.swift index 8b79a2c..1cdef21 100644 --- a/Sources/cmdcmd/Overlay.swift +++ b/Sources/cmdcmd/Overlay.swift @@ -58,6 +58,9 @@ final class Overlay { 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 @@ -506,10 +509,14 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B private func rebuildDisplayed() { let ignored = ignoredKeys - let displayed = allTiles.filter { showIgnored ? true : !ignored.contains($0.ignoreKey) } + let baseDisplayed = allTiles.filter { showIgnored ? true : !ignored.contains($0.ignoreKey) } + let displayed = baseDisplayed.filter { Self.matches(tile: $0, query: searchQuery) } + let visibleSet = Set(displayed.map { ObjectIdentifier($0) }) for t in allTiles { let isIgnored = ignored.contains(t.ignoreKey) - t.layer.isHidden = showIgnored ? false : isIgnored + 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.setNumber(nil) t.tintColorName = paneColors[CGWindowID(t.scWindow.windowID)] @@ -526,6 +533,54 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B updateSelection() } + private static func matches(tile: Tile, query: String) -> Bool { + let q = query.trimmingCharacters(in: .whitespaces) + if q.isEmpty { return true } + let app = tile.scWindow.owningApplication?.applicationName ?? "" + let title = tile.scWindow.title ?? "" + let haystack = app + " " + title + return haystack.localizedCaseInsensitiveContains(q) + } + + private func enterSearch() { + guard let win = window, let host = win.contentView else { return } + searching = true + search.onChange = { [weak self] q in self?.searchQueryChanged(q) } + search.onCommit = { [weak self] in self?.commitSearch() } + search.onCancel = { [weak self] in self?.cancelSearch() } + search.show(in: host, query: searchQuery) + findSearchTextField(in: host)?.onCmdF = { [weak self] in self?.commitSearch() } + } + + private func findSearchTextField(in view: NSView) -> SearchTextField? { + for sub in view.subviews { + if let f = sub as? SearchTextField { return f } + if let nested = findSearchTextField(in: sub) { return nested } + } + return nil + } + + private func commitSearch() { + searching = false + search.hide() + if let v = view { window?.makeFirstResponder(v) } + } + + private func cancelSearch() { + searching = false + searchQuery = "" + search.hide() + rebuildDisplayed() + layoutTilesAnimated() + if let v = view { window?.makeFirstResponder(v) } + } + + private func searchQueryChanged(_ q: String) { + searchQuery = q + rebuildDisplayed() + layoutTilesAnimated() + } + private func tagSelectedColor(_ name: String?) { guard tiles.indices.contains(selectedIndex) else { return } let id = CGWindowID(tiles[selectedIndex].scWindow.windowID) @@ -572,7 +627,10 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B switch action { case .pick: pick() case .dismiss: - if showIgnored { toggleShowIgnored() } else { dismiss() } + if showIgnored { toggleShowIgnored() } + else if !searchQuery.isEmpty { cancelSearch() } + else { dismiss() } + case .search: enterSearch() case .moveLeft: move(dx: -1, dy: 0) case .moveRight: move(dx: 1, dy: 0) case .moveUp: move(dx: 0, dy: -1) @@ -680,6 +738,9 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B selectedIndex = 0 showIgnored = false lastLetterJump = nil + searching = false + searchQuery = "" + search.hide() view?.resetMomentaryPeek() hint.hide() Task(priority: .utility) { diff --git a/Sources/cmdcmd/SearchField.swift b/Sources/cmdcmd/SearchField.swift new file mode 100644 index 0000000..5018519 --- /dev/null +++ b/Sources/cmdcmd/SearchField.swift @@ -0,0 +1,169 @@ +import AppKit + +/// Bottom-center search field shown when the user enters search mode. +/// Contains an editable text field plus a Cancel button. The text field +/// receives all keystrokes while visible; the host wires up callbacks for +/// query changes, return (commit), and esc/cancel. +final class SearchField { + private var container: NSView? + private var field: SearchTextField? + private var cancelButton: NSButton? + private weak var hostWindow: NSWindow? + + var onChange: ((String) -> Void)? + var onCommit: (() -> Void)? + var onCancel: (() -> Void)? + + var isVisible: Bool { container?.superview != nil && !(container?.isHidden ?? true) } + + var query: String { field?.stringValue ?? "" } + + func show(in host: NSView, query: String) { + hostWindow = host.window + let view = container ?? makeContainer() + if container == nil { + container = view + host.addSubview(view) + } else if view.superview !== host { + view.removeFromSuperview() + host.addSubview(view) + } + view.isHidden = false + field?.stringValue = query + layout(in: host.bounds) + if let f = field { + host.window?.makeFirstResponder(f) + // Place cursor at end after string assignment. + f.currentEditor()?.selectedRange = NSRange(location: query.count, length: 0) + } + } + + func hide() { + container?.isHidden = true + if let host = container?.superview, let win = host.window { + // Return first responder to the OverlayView so keymap routing resumes. + win.makeFirstResponder(host) + } + } + + func reset() { + container?.removeFromSuperview() + container = nil + field = nil + cancelButton = nil + } + + func relayout(in bounds: CGRect) { + guard isVisible else { return } + layout(in: bounds) + } + + private func layout(in bounds: CGRect) { + guard let view = container else { return } + let width: CGFloat = 360 + let height: CGFloat = 36 + view.frame = CGRect( + x: (bounds.width - width) / 2, + y: 24, + width: width, + height: height + ) + let buttonWidth: CGFloat = 70 + let pad: CGFloat = 8 + field?.frame = CGRect(x: pad, y: 6, width: width - buttonWidth - pad * 3, height: height - 12) + cancelButton?.frame = CGRect( + x: width - buttonWidth - pad, + y: 4, + width: buttonWidth, + height: height - 8 + ) + } + + private func makeContainer() -> NSView { + let v = NSView(frame: .zero) + v.wantsLayer = true + let layer = v.layer! + layer.backgroundColor = NSColor.black.withAlphaComponent(0.65).cgColor + layer.cornerRadius = 12 + layer.masksToBounds = true + + let f = SearchTextField() + f.isBezeled = false + f.isBordered = false + f.drawsBackground = false + f.focusRingType = .none + f.font = NSFont.systemFont(ofSize: 14, weight: .regular) + f.textColor = .white + f.placeholderAttributedString = NSAttributedString( + string: "Search apps & windows…", + attributes: [ + .foregroundColor: NSColor.white.withAlphaComponent(0.45), + .font: NSFont.systemFont(ofSize: 14, weight: .regular), + ] + ) + f.target = self + f.action = #selector(commit) + f.delegate = TextDelegate.shared + TextDelegate.shared.host = self + v.addSubview(f) + field = f + + let btn = NSButton(title: "Cancel", target: self, action: #selector(cancel)) + btn.bezelStyle = .rounded + btn.controlSize = .small + btn.font = NSFont.systemFont(ofSize: 12, weight: .medium) + v.addSubview(btn) + cancelButton = btn + + return v + } + + @objc private func commit() { + onCommit?() + } + + @objc private func cancel() { + onCancel?() + } + + fileprivate func didChangeText() { + onChange?(query) + } + + fileprivate func didPressEscape() { + onCancel?() + } + + private final class TextDelegate: NSObject, NSTextFieldDelegate { + static let shared = TextDelegate() + weak var host: SearchField? + + func controlTextDidChange(_ obj: Notification) { + host?.didChangeText() + } + + func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool { + if selector == #selector(NSResponder.cancelOperation(_:)) { + host?.didPressEscape() + return true + } + return false + } + } +} + +/// NSTextField subclass that intercepts cmd+f so the host can re-trigger +/// search mode (toggle/refocus) without inserting an "f" character. +final class SearchTextField: NSTextField { + var onCmdF: (() -> Void)? + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + let mods = event.modifierFlags.intersection([.command, .shift, .option, .control]) + if mods == [.command], + (event.charactersIgnoringModifiers ?? "").lowercased() == "f" { + onCmdF?() + return true + } + return super.performKeyEquivalent(with: event) + } +} diff --git a/Sources/cmdcmd/Tile.swift b/Sources/cmdcmd/Tile.swift index ddb8cd7..3671038 100644 --- a/Sources/cmdcmd/Tile.swift +++ b/Sources/cmdcmd/Tile.swift @@ -294,7 +294,21 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { CATransaction.commit() } - private static let thumbMaxDim: CGFloat = 800 + private static let thumbFloor: CGFloat = 700 + private static let thumbCeiling: CGFloat = 2200 + private static let thumbHeadroom: CGFloat = 1.5 + + /// Target snapshot resolution (longest side, in pixels) sized to the tile's + /// current on-screen footprint. Many small tiles → smaller thumbs; a few + /// large tiles → sharper thumbs. Live capture is unaffected and always + /// runs at full window resolution. + private func currentThumbMaxDim() -> CGFloat { + let scale = NSScreen.main?.backingScaleFactor ?? 2 + let longest = max(layer.frame.width, layer.frame.height) + guard longest > 0 else { return 1400 } + let target = longest * scale * Self.thumbHeadroom + return max(Self.thumbFloor, min(Self.thumbCeiling, target)) + } private func captureConfig(maxDim: CGFloat? = nil) -> SCStreamConfiguration { let config = SCStreamConfiguration() @@ -321,7 +335,7 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { func snapshot() async { if cancelled || hasRenderedLiveFrame { return } let filter = SCContentFilter(desktopIndependentWindow: scWindow) - let config = captureConfig(maxDim: Tile.thumbMaxDim) + let config = captureConfig(maxDim: currentThumbMaxDim()) do { let image = try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: config) if cancelled || hasRenderedLiveFrame { return } @@ -364,6 +378,7 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { private func cacheLastFrameDeferred() { let id = CGWindowID(self.scWindow.windowID) let q = self.queue + let cap = currentThumbMaxDim() Tile.cacheQueue.async { var pb: CVPixelBuffer? q.sync { @@ -374,7 +389,7 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { let ci = CIImage(cvPixelBuffer: pb) let extent = ci.extent let largest = max(extent.width, extent.height) - let factor = largest > Tile.thumbMaxDim ? Tile.thumbMaxDim / largest : 1 + let factor = largest > cap ? cap / largest : 1 let scaled = factor < 1 ? ci.transformed(by: CGAffineTransform(scaleX: factor, y: factor)) : ci From 708c8a9dd482002abbc351b8091d7f61a25b237f Mon Sep 17 00:00:00 2001 From: Peter Pistorius Date: Wed, 29 Apr 2026 22:35:04 +0200 Subject: [PATCH 3/6] Forward search-field arrows to tile selection Return picks the highlighted tile, esc / Done commits the filter (rename from Cancel matches the new behaviour), and the cardinal arrow keys move the tile selection instead of the caret. Swallow all caret- movement selectors so the cursor never moves inside the field. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/cmdcmd/Overlay.swift | 14 +++++++-- Sources/cmdcmd/SearchField.swift | 49 +++++++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/Sources/cmdcmd/Overlay.swift b/Sources/cmdcmd/Overlay.swift index 1cdef21..c8e0f95 100644 --- a/Sources/cmdcmd/Overlay.swift +++ b/Sources/cmdcmd/Overlay.swift @@ -546,12 +546,22 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B guard let win = window, let host = win.contentView else { return } searching = true search.onChange = { [weak self] q in self?.searchQueryChanged(q) } - search.onCommit = { [weak self] in self?.commitSearch() } - search.onCancel = { [weak self] in self?.cancelSearch() } + search.onCommit = { [weak self] in self?.dispatch(.pick) } + search.onCancel = { [weak self] in self?.commitSearch() } + search.onArrow = { [weak self] d in self?.dispatchSearchArrow(d) } search.show(in: host, query: searchQuery) findSearchTextField(in: host)?.onCmdF = { [weak self] in self?.commitSearch() } } + private func dispatchSearchArrow(_ d: SearchField.ArrowDirection) { + switch d { + case .left: dispatch(.moveLeft) + case .right: dispatch(.moveRight) + case .up: dispatch(.moveUp) + case .down: dispatch(.moveDown) + } + } + private func findSearchTextField(in view: NSView) -> SearchTextField? { for sub in view.subviews { if let f = sub as? SearchTextField { return f } diff --git a/Sources/cmdcmd/SearchField.swift b/Sources/cmdcmd/SearchField.swift index 5018519..d1eeb4c 100644 --- a/Sources/cmdcmd/SearchField.swift +++ b/Sources/cmdcmd/SearchField.swift @@ -10,9 +10,12 @@ final class SearchField { private var cancelButton: NSButton? private weak var hostWindow: NSWindow? + enum ArrowDirection { case left, right, up, down } + var onChange: ((String) -> Void)? var onCommit: (() -> Void)? var onCancel: (() -> Void)? + var onArrow: ((ArrowDirection) -> Void)? var isVisible: Bool { container?.superview != nil && !(container?.isHidden ?? true) } @@ -108,7 +111,7 @@ final class SearchField { v.addSubview(f) field = f - let btn = NSButton(title: "Cancel", target: self, action: #selector(cancel)) + let btn = NSButton(title: "Done", target: self, action: #selector(cancel)) btn.bezelStyle = .rounded btn.controlSize = .small btn.font = NSFont.systemFont(ofSize: 12, weight: .medium) @@ -134,6 +137,10 @@ final class SearchField { onCancel?() } + fileprivate func didPressArrow(_ d: ArrowDirection) { + onArrow?(d) + } + private final class TextDelegate: NSObject, NSTextFieldDelegate { static let shared = TextDelegate() weak var host: SearchField? @@ -147,8 +154,48 @@ final class SearchField { host?.didPressEscape() return true } + // Swallow all caret-movement selectors so the cursor never moves + // inside the field. Forward the cardinal arrows to tile selection. + if let dir = Self.arrowDirection(for: selector) { + host?.didPressArrow(dir) + return true + } + if Self.movementSelectors.contains(selector) { + return true + } return false } + + private static func arrowDirection(for selector: Selector) -> ArrowDirection? { + switch selector { + case #selector(NSResponder.moveLeft(_:)): return .left + case #selector(NSResponder.moveRight(_:)): return .right + case #selector(NSResponder.moveUp(_:)): return .up + case #selector(NSResponder.moveDown(_:)): return .down + default: return nil + } + } + + private static let movementSelectors: Set = [ + #selector(NSResponder.moveLeftAndModifySelection(_:)), + #selector(NSResponder.moveRightAndModifySelection(_:)), + #selector(NSResponder.moveUpAndModifySelection(_:)), + #selector(NSResponder.moveDownAndModifySelection(_:)), + #selector(NSResponder.moveWordLeft(_:)), + #selector(NSResponder.moveWordRight(_:)), + #selector(NSResponder.moveWordLeftAndModifySelection(_:)), + #selector(NSResponder.moveWordRightAndModifySelection(_:)), + #selector(NSResponder.moveToBeginningOfLine(_:)), + #selector(NSResponder.moveToEndOfLine(_:)), + #selector(NSResponder.moveToBeginningOfLineAndModifySelection(_:)), + #selector(NSResponder.moveToEndOfLineAndModifySelection(_:)), + #selector(NSResponder.moveToBeginningOfDocument(_:)), + #selector(NSResponder.moveToEndOfDocument(_:)), + #selector(NSResponder.moveToBeginningOfDocumentAndModifySelection(_:)), + #selector(NSResponder.moveToEndOfDocumentAndModifySelection(_:)), + #selector(NSResponder.moveToLeftEndOfLine(_:)), + #selector(NSResponder.moveToRightEndOfLine(_:)), + ] } } From 3fefb0220f30fde70599045abb062954e4a4247f Mon Sep 17 00:00:00 2001 From: Peter Pistorius Date: Fri, 1 May 2026 13:09:30 +0200 Subject: [PATCH 4/6] Fix crash when SCScreenshotManager returns nil image The Swift-async overload of captureImage force-unwraps the bridged CGImage?, so a (nil, nil) reply from ScreenCaptureKit traps the process. Wrap the completion-handler form in a continuation and surface a thrown error instead, letting the existing catch log and skip the snapshot. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/cmdcmd/Tile.swift | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/Sources/cmdcmd/Tile.swift b/Sources/cmdcmd/Tile.swift index 3671038..99d4d14 100644 --- a/Sources/cmdcmd/Tile.swift +++ b/Sources/cmdcmd/Tile.swift @@ -332,12 +332,27 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { return config } + private func captureImageSafely(filter: SCContentFilter, config: SCStreamConfiguration) async throws -> CGImage { + try await withCheckedThrowingContinuation { cont in + SCScreenshotManager.captureImage(contentFilter: filter, configuration: config) { image, error in + if let image { + cont.resume(returning: image) + } else { + cont.resume(throwing: error ?? NSError( + domain: "cmdcmd.Tile", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "captureImage returned no image"])) + } + } + } + } + func snapshot() async { if cancelled || hasRenderedLiveFrame { return } let filter = SCContentFilter(desktopIndependentWindow: scWindow) let config = captureConfig(maxDim: currentThumbMaxDim()) do { - let image = try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: config) + let image = try await captureImageSafely(filter: filter, config: config) if cancelled || hasRenderedLiveFrame { return } Tile.setCachedFrame(image, for: CGWindowID(scWindow.windowID)) await MainActor.run { From 678d1f518d5abc3732c0a9b934b88790459fc712 Mon Sep 17 00:00:00 2001 From: Peter Pistorius Date: Fri, 1 May 2026 15:15:32 +0200 Subject: [PATCH 5/6] Auto-restart tile streams and log delivery diagnostics ScreenCaptureKit can stop a stream on its own (window minimised, source app suspended, replayd churn, backpressure) without us recovering. The tile was left holding a recycled IOSurface and the live preview went dead until the overlay was re-shown. Recover by clearing the dead stream, promoting the last pixel buffer to a stable CGImage so the tile keeps a valid image, and rescheduling start() with a linear backoff up to six attempts. The retry counter resets whenever a fresh frame arrives. Add structured logging for every transition and a per-stream watchdog so we can tell the difference between "stream stopped", "stream alive but only emitting idle/blank/suspended", and "stream alive, complete frames flowing but the layer never updates". Errors are formatted with their NSError domain/code/underlying so the cause is visible in /tmp/cmdcmd.log. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/cmdcmd/Tile.swift | 156 +++++++++++++++++++++++++++++++++++++- 1 file changed, 154 insertions(+), 2 deletions(-) diff --git a/Sources/cmdcmd/Tile.swift b/Sources/cmdcmd/Tile.swift index 99d4d14..fad6d8d 100644 --- a/Sources/cmdcmd/Tile.swift +++ b/Sources/cmdcmd/Tile.swift @@ -73,6 +73,20 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { private var lastPixelBuffer: CVPixelBuffer? var suppressFrames = false private let queue = DispatchQueue(label: "cmdcmd.tile", qos: .userInteractive) + private var restartAttempts = 0 + private static let maxRestartAttempts = 6 + // Frame-delivery diagnostics. Updated only on the SCStream sample queue. + private var loggedFirstDelivery = false + private var loggedFirstLiveFrame = false + private var completeCount = 0 + private var skippedIdle = 0 + private var skippedBlank = 0 + private var skippedSuspended = 0 + private var skippedOther = 0 + private var lastSkipLogAt: CFAbsoluteTime = 0 + private var lastHeartbeatLogAt: CFAbsoluteTime = 0 + private var lastFrameAt: CFAbsoluteTime = 0 + private var watchdog: DispatchSourceTimer? init(scWindow: SCWindow, ownerPID: pid_t) { self.scWindow = scWindow @@ -385,11 +399,28 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { return } self.stream = s + let title = scWindow.title ?? "" + let bid = scWindow.owningApplication?.bundleIdentifier ?? "?" + Log.write("tile stream started wid=\(scWindow.windowID) bid=\(bid) title=\"\(title)\" size=\(config.width)x\(config.height) attempt=\(restartAttempts)") + startWatchdog() } catch { - Log.write("tile start failed wid=\(scWindow.windowID): \(error)") + Log.write("tile start failed wid=\(scWindow.windowID): \(Tile.describe(error))") } } + private static func describe(_ error: Error) -> String { + let ns = error as NSError + var info = "domain=\(ns.domain) code=\(ns.code)" + if let underlying = ns.userInfo[NSUnderlyingErrorKey] as? NSError { + info += " underlying=\(underlying.domain)/\(underlying.code)" + } + if let reason = ns.localizedFailureReason { + info += " reason=\"\(reason)\"" + } + info += " desc=\"\(ns.localizedDescription)\"" + return info + } + private func cacheLastFrameDeferred() { let id = CGWindowID(self.scWindow.windowID) let q = self.queue @@ -417,6 +448,7 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { func stop() async { cancelled = true suppressFrames = true + stopWatchdog() cacheLastFrameDeferred() guard let s = stream else { return } self.stream = nil @@ -426,6 +458,7 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { func stopSync(group: DispatchGroup) { cancelled = true suppressFrames = true + stopWatchdog() cacheLastFrameDeferred() guard let s = stream else { return } self.stream = nil @@ -441,9 +474,25 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { let attachments = (CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false) as? [[SCStreamFrameInfo: Any]])?.first let statusRaw = (attachments?[.status] as? Int).flatMap(SCFrameStatus.init(rawValue:)) + if !loggedFirstDelivery { + loggedFirstDelivery = true + Log.write("tile first delivery wid=\(scWindow.windowID) status=\(Tile.describe(statusRaw))") + } if statusRaw == .idle || statusRaw == .blank || statusRaw == .suspended { + switch statusRaw { + case .idle: skippedIdle += 1 + case .blank: skippedBlank += 1 + case .suspended: skippedSuspended += 1 + default: skippedOther += 1 + } + maybeLogSkipSummary() return } + if statusRaw == .complete { + completeCount += 1 + lastFrameAt = CFAbsoluteTimeGetCurrent() + maybeLogHeartbeat() + } var significantChange = false if let attachments { @@ -466,6 +515,14 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { } self.lastPixelBuffer = pixelBuffer + if restartAttempts != 0 { restartAttempts = 0 } + + if !loggedFirstLiveFrame { + loggedFirstLiveFrame = true + let w = CVPixelBufferGetWidth(pixelBuffer) + let h = CVPixelBufferGetHeight(pixelBuffer) + Log.write("tile first live frame wid=\(scWindow.windowID) status=\(Tile.describe(statusRaw)) size=\(w)x\(h) skipped(idle=\(skippedIdle) blank=\(skippedBlank) suspended=\(skippedSuspended) other=\(skippedOther))") + } if suppressFrames { return } @@ -480,6 +537,57 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { } } + private func maybeLogSkipSummary() { + let now = CFAbsoluteTimeGetCurrent() + if lastSkipLogAt == 0 { lastSkipLogAt = now; return } + if now - lastSkipLogAt < 5 { return } + lastSkipLogAt = now + Log.write("tile skip summary wid=\(scWindow.windowID) live=\(loggedFirstLiveFrame) complete=\(completeCount) idle=\(skippedIdle) blank=\(skippedBlank) suspended=\(skippedSuspended) other=\(skippedOther)") + } + + private func maybeLogHeartbeat() { + let now = CFAbsoluteTimeGetCurrent() + if lastHeartbeatLogAt == 0 { lastHeartbeatLogAt = now; return } + if now - lastHeartbeatLogAt < 5 { return } + lastHeartbeatLogAt = now + Log.write("tile heartbeat wid=\(scWindow.windowID) complete=\(completeCount) skipped(idle=\(skippedIdle) blank=\(skippedBlank) suspended=\(skippedSuspended) other=\(skippedOther))") + } + + /// Fires every 5s; if no frame arrived in the last 5s, log a stall. + private func startWatchdog() { + stopWatchdog() + let t = DispatchSource.makeTimerSource(queue: queue) + t.schedule(deadline: .now() + 5, repeating: 5) + t.setEventHandler { [weak self] in + guard let self, !self.cancelled else { return } + let now = CFAbsoluteTimeGetCurrent() + let elapsed = self.lastFrameAt == 0 ? -1 : now - self.lastFrameAt + if self.lastFrameAt == 0 || elapsed > 5 { + Log.write("tile stall wid=\(self.scWindow.windowID) sinceLastFrame=\(String(format: "%.1f", elapsed))s complete=\(self.completeCount) skipped(idle=\(self.skippedIdle) blank=\(self.skippedBlank) suspended=\(self.skippedSuspended) other=\(self.skippedOther)) streamAlive=\(self.stream != nil)") + } + } + t.resume() + watchdog = t + } + + private func stopWatchdog() { + watchdog?.cancel() + watchdog = nil + } + + private static func describe(_ status: SCFrameStatus?) -> String { + guard let status else { return "nil" } + switch status { + case .complete: return "complete" + case .idle: return "idle" + case .blank: return "blank" + case .suspended: return "suspended" + case .started: return "started" + case .stopped: return "stopped" + @unknown default: return "raw(\(status.rawValue))" + } + } + func updateActivity(now: CFAbsoluteTime) { let elapsed = now - lastSignificantChangeAt let activeWithin: CFTimeInterval = 0.5 @@ -502,6 +610,50 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { } func stream(_ stream: SCStream, didStopWithError error: Error) { - Log.write("tile stream stopped: \(error)") + let title = scWindow.title ?? "" + let bid = scWindow.owningApplication?.bundleIdentifier ?? "?" + Log.write("tile stream stopped wid=\(scWindow.windowID) bid=\(bid) title=\"\(title)\" hadFrame=\(hasRenderedLiveFrame) suppressed=\(suppressFrames) cancelled=\(cancelled) complete=\(completeCount) skipped(idle=\(skippedIdle) blank=\(skippedBlank) suspended=\(skippedSuspended) other=\(skippedOther)): \(Tile.describe(error))") + self.stream = nil + stopWatchdog() + promoteLastFrameToLayer() + if cancelled { return } + let attempt = restartAttempts + 1 + restartAttempts = attempt + guard attempt <= Self.maxRestartAttempts else { + Log.write("tile giving up restart wid=\(scWindow.windowID) after \(attempt) attempts") + return + } + let delay = min(5.0, 0.5 * Double(attempt)) + Log.write("tile scheduling restart wid=\(scWindow.windowID) attempt=\(attempt) delay=\(delay)s") + Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + guard let self, !self.cancelled, self.stream == nil else { return } + await self.start() + } + } + + /// Convert the most recent pixel buffer into a CGImage and assign it to the + /// tile's layer. Used when the live stream stops so the tile retains a + /// stable image instead of a recycled IOSurface. + private func promoteLastFrameToLayer() { + let q = self.queue + Tile.cacheQueue.async { [weak self] in + guard let self else { return } + var pb: CVPixelBuffer? + q.sync { + pb = self.lastPixelBuffer + } + guard let pb else { return } + let ci = CIImage(cvPixelBuffer: pb) + guard let cg = Tile.ciContext.createCGImage(ci, from: ci.extent) else { return } + Tile.setCachedFrame(cg, for: CGWindowID(self.scWindow.windowID)) + DispatchQueue.main.async { [weak self] in + guard let self, !self.cancelled else { return } + CATransaction.begin() + CATransaction.setDisableActions(true) + self.content.contents = cg + CATransaction.commit() + } + } } } From 760a199796ea35466e1a0f3324c29266918210b5 Mon Sep 17 00:00:00 2001 From: Peter Pistorius Date: Fri, 1 May 2026 15:36:02 +0200 Subject: [PATCH 6/6] Smooth peek by animating shadow path and accent border MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit beginZoom drove layer.frame from the small grid cell to a near-fullsize target while shadowPath snapped to the destination size at t=0, so the blue accent shadow extended to the final dimensions before the tile had moved — reading as a phantom halo. CALayer does not return a default action for shadowPath, so removing the disable-actions wrapper alone wasn't enough; the path is now driven by an explicit CABasicAnimation that mirrors the active transaction's duration and timing function. While peeked, the selected tile's accent border and blue glow are also faded to zero (and restored on endZoom) so the highlight doesn't overwhelm the near-fullscreen preview. Also silence the unused-result warning on FileHandle.seekToEnd in Log. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/cmdcmd/Log.swift | 2 +- Sources/cmdcmd/Overlay.swift | 8 ++++++++ Sources/cmdcmd/Tile.swift | 25 +++++++++++++++++++++++-- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/Sources/cmdcmd/Log.swift b/Sources/cmdcmd/Log.swift index 60dcc9d..cc1d424 100644 --- a/Sources/cmdcmd/Log.swift +++ b/Sources/cmdcmd/Log.swift @@ -8,7 +8,7 @@ enum Log { FileManager.default.createFile(atPath: path, contents: nil) } let h = try? FileHandle(forWritingTo: url) - try? h?.seekToEnd() + _ = try? h?.seekToEnd() return h }() diff --git a/Sources/cmdcmd/Overlay.swift b/Sources/cmdcmd/Overlay.swift index c8e0f95..7dd2ef8 100644 --- a/Sources/cmdcmd/Overlay.swift +++ b/Sources/cmdcmd/Overlay.swift @@ -1034,6 +1034,10 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B if i == selectedIndex { t.layer.zPosition = 1 t.setFrame(target) + // Fade the accent border + blue glow during the zoom: at full + // size they dominate the screen and read as a flash of color. + t.layer.borderWidth = 0 + t.layer.shadowOpacity = 0 } else { t.layer.opacity = 0 } @@ -1053,6 +1057,10 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B if i < savedFrames.count { t.setFrame(savedFrames[i]) } t.layer.zPosition = 0 t.layer.opacity = 1 + if i == selectedIndex, t.highlight == .subtle { + t.layer.borderWidth = 3 + t.layer.shadowOpacity = 0.6 + } } CATransaction.commit() resumeFrames(after: 0.12) diff --git a/Sources/cmdcmd/Tile.swift b/Sources/cmdcmd/Tile.swift index fad6d8d..febc7cd 100644 --- a/Sources/cmdcmd/Tile.swift +++ b/Sources/cmdcmd/Tile.swift @@ -209,11 +209,32 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { } func setFrame(_ rect: CGRect) { + let newBounds = CGRect(origin: .zero, size: rect.size) + let newShadowPath = CGPath(roundedRect: newBounds, cornerWidth: 10, cornerHeight: 10, transform: nil) + let oldShadowPath = layer.shadowPath + let duration = CATransaction.animationDuration() + let actionsDisabled = CATransaction.disableActions() + layer.frame = rect - content.frame = CGRect(origin: .zero, size: rect.size).insetBy(dx: 1, dy: 1) + content.frame = newBounds.insetBy(dx: 1, dy: 1) + + // CALayer does not return a default action for shadowPath, so an + // implicit animation never starts. Without this, the path snaps to + // the new (often much larger) size at t=0 while layer.frame is still + // animating, which paints a phantom shadow far outside the small + // starting tile. + if !actionsDisabled, duration > 0, let oldShadowPath { + let anim = CABasicAnimation(keyPath: "shadowPath") + anim.fromValue = oldShadowPath + anim.toValue = newShadowPath + anim.duration = duration + anim.timingFunction = CATransaction.animationTimingFunction() + layer.add(anim, forKey: "shadowPath") + } + layer.shadowPath = newShadowPath + CATransaction.begin() CATransaction.setDisableActions(true) - layer.shadowPath = CGPath(roundedRect: CGRect(origin: .zero, size: rect.size), cornerWidth: 10, cornerHeight: 10, transform: nil) layoutLabel() CATransaction.commit() }