From 8082099c490c20b7d46093c82a4d32ada15541e6 Mon Sep 17 00:00:00 2001 From: Peter Pistorius Date: Fri, 1 May 2026 16:22:02 +0200 Subject: [PATCH] Refresh tile aspect ratio after window resize reconcileTiles only handled added/removed windows; if a window was resized between prewarm and show, the kept tile retained its stale SCWindow with the old frame, so layoutTiles produced the wrong aspect ratio and the live stream's capture config stayed at the old dimensions. Update kept tiles' scWindow from the fresh fetch, treat resize as a reconcile reason so the animated re-layout runs, and rebuild the capture pipeline for tiles whose size actually changed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/ca7a0a92.md | 5 ++++ Sources/cmdcmd/Overlay.swift | 44 +++++++++++++++++++++++++++++------- Sources/cmdcmd/Tile.swift | 21 ++++++++++++++++- 3 files changed, 61 insertions(+), 9 deletions(-) create mode 100644 .changeset/ca7a0a92.md diff --git a/.changeset/ca7a0a92.md b/.changeset/ca7a0a92.md new file mode 100644 index 0000000..9cd051f --- /dev/null +++ b/.changeset/ca7a0a92.md @@ -0,0 +1,5 @@ +--- +bump: patch +--- + +Refresh tile aspect ratio when a window is resized between overlay opens. Previously, resizing a window while the overlay was hidden would leave the next cmd-cmd showing the tile at its old aspect ratio (and capturing the live preview at the old dimensions) until something else added or removed a window. diff --git a/Sources/cmdcmd/Overlay.swift b/Sources/cmdcmd/Overlay.swift index 7dd2ef8..eb473a2 100644 --- a/Sources/cmdcmd/Overlay.swift +++ b/Sources/cmdcmd/Overlay.swift @@ -318,8 +318,25 @@ final class Overlay { let currentIDs = Set(allTiles.map { CGWindowID($0.scWindow.windowID) }) let addedIDs = newIDs.subtracting(currentIDs) let removedIDs = currentIDs.subtracting(newIDs) - guard !addedIDs.isEmpty || !removedIDs.isEmpty else { return } - Log.debug("reconcile: +\(addedIDs.count) -\(removedIDs.count) (was \(currentIDs.count), now \(newIDs.count))") + + // 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) @@ -382,12 +399,23 @@ final class Overlay { } let live = config.livePreviewsEnabled - Task { - await withTaskGroup(of: Void.self) { group in - for t in added { - group.addTask { - await t.snapshot() - if live { await t.start() } + 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) } } } } diff --git a/Sources/cmdcmd/Tile.swift b/Sources/cmdcmd/Tile.swift index febc7cd..4c3ea77 100644 --- a/Sources/cmdcmd/Tile.swift +++ b/Sources/cmdcmd/Tile.swift @@ -54,7 +54,7 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { return NSColor(srgbRed: r, green: g, blue: b, alpha: 1) } - let scWindow: SCWindow + var scWindow: SCWindow let ownerPID: pid_t let ignoreKey: String let layer: CALayer @@ -476,6 +476,25 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { try? await s.stopCapture() } + /// Underlying window resized after capture started. Tear down the existing + /// stream (its config is fixed at the old dimensions) and rebuild from the + /// fresh `scWindow.frame`. + func refreshAfterResize(live: Bool) async { + if cancelled { return } + if let s = stream { + self.stream = nil + stopWatchdog() + try? await s.stopCapture() + } + if cancelled { return } + hasRenderedLiveFrame = false + loggedFirstLiveFrame = false + await snapshot() + if live && !cancelled { + await start() + } + } + func stopSync(group: DispatchGroup) { cancelled = true suppressFrames = true