From 6b8d86ca80c766349942950ff6fcde9fd9e45954 Mon Sep 17 00:00:00 2001 From: Eric Clemmons Date: Sun, 24 May 2026 12:30:39 -0500 Subject: [PATCH 1/5] Animate tiles from each window's actual screen frame The show/pick animations previously started or ended at the overlay's full bounds, so every tile flew in or out from the same big rectangle regardless of where the underlying window actually sat. Convert each tile's CGWindowList frame (top-left, global) into overlay content-view coordinates and use that as the source/target frame, so tiles fly to and from where their windows really live. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/cmdcmd/Overlay.swift | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/Sources/cmdcmd/Overlay.swift b/Sources/cmdcmd/Overlay.swift index c992a80..3268996 100644 --- a/Sources/cmdcmd/Overlay.swift +++ b/Sources/cmdcmd/Overlay.swift @@ -278,13 +278,14 @@ final class Overlay { guard config.animations else { return } let tile = tiles[selectedIndex] let gridFrame = tile.layer.frame + let sourceFrame = Self.contentLocalRect(forSourceCGFrame: tile.window.frame, overlayWindow: w) suspendFrames() CATransaction.begin() CATransaction.setDisableActions(true) tile.highlight = .none tile.layer.zPosition = 1 - tile.setFrame(bounds) + tile.setFrame(sourceFrame) CATransaction.commit() CATransaction.flush() @@ -302,6 +303,23 @@ final class Overlay { } } + /// Convert a CGWindowList-style frame (top-left origin, anchored at the + /// primary display) into the overlay content view's local coordinate + /// space (bottom-left origin, relative to the overlay window). + private static func contentLocalRect(forSourceCGFrame cg: CGRect, overlayWindow w: NSWindow) -> CGRect { + guard let primary = NSScreen.screens.first else { return cg } + let primaryMaxY = primary.frame.maxY + let nsX = cg.origin.x + let nsY = primaryMaxY - cg.origin.y - cg.height + let winFrame = w.frame + return CGRect( + x: nsX - winFrame.origin.x, + y: nsY - winFrame.origin.y, + width: cg.width, + height: cg.height + ) + } + private static func windowMostlyOn(displayBounds: CGRect, window: WindowInfo) -> Bool { let inter = window.frame.intersection(displayBounds) guard !inter.isNull else { return false } @@ -708,6 +726,8 @@ private static func windowMostlyOn(displayBounds: CGRect, window: WindowInfo) -> isPicking = false return } + _ = bounds + let targetFrame = Self.contentLocalRect(forSourceCGFrame: tile.window.frame, overlayWindow: w) suspendFrames() CATransaction.begin() @@ -720,9 +740,8 @@ private static func windowMostlyOn(displayBounds: CGRect, window: WindowInfo) -> CATransaction.begin() CATransaction.setAnimationDuration(Self.pickDuration) CATransaction.setAnimationTimingFunction(Self.smoothEasing) - tile.setFrame(bounds) + tile.setFrame(targetFrame) CATransaction.commit() - _ = w DispatchQueue.main.asyncAfter(deadline: .now() + Self.pickDuration) { [weak self] in guard let self else { return } From 5628c2da3ea16253b44c27687a8a8b7d81c0fd7f Mon Sep 17 00:00:00 2001 From: Eric Clemmons Date: Sun, 24 May 2026 15:23:08 -0500 Subject: [PATCH 2/5] =?UTF-8?q?Animate=20every=20tile=20to=20and=20from=20?= =?UTF-8?q?its=20window's=20frame=20(Expos=C3=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show and dismiss now fly *all* tiles between their grid cells and the real window frames they snapshot, not just the focused one. The backdrop moves to its own CALayer so it can fade independently of the tiles, and both fades run in parallel with the tile movement at the same duration. Each tile's zPosition mirrors the WindowServer stacking order so overlapping tiles maintain the same overlap as the live windows at the start of show and end of dismiss. Easing switched to a symmetric ease-in-out so the slide feels even on both ends. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/cmdcmd/Overlay.swift | 122 +++++++++++++++++++++++------------ 1 file changed, 82 insertions(+), 40 deletions(-) diff --git a/Sources/cmdcmd/Overlay.swift b/Sources/cmdcmd/Overlay.swift index 3268996..64eedd5 100644 --- a/Sources/cmdcmd/Overlay.swift +++ b/Sources/cmdcmd/Overlay.swift @@ -6,6 +6,7 @@ private func _AXUIElementGetWindow(_ axEl: AXUIElement, _ wid: UnsafeMutablePoin final class Overlay { private var window: NSWindow? private var view: OverlayView? + private var backgroundLayer: CALayer? private var visible = false private var allTiles: [Tile] = [] private var tiles: [Tile] = [] @@ -174,25 +175,38 @@ final class Overlay { let w = window ?? makeWindow(frame: visibleFrame) window = w w.setFrame(visibleFrame, display: false) - if config.animations { - w.alphaValue = 0 - } else { - w.alphaValue = 1 - } + w.alphaValue = 1 let tWindow = CFAbsoluteTimeGetCurrent() CATransaction.begin() CATransaction.setDisableActions(true) installTiles(candidates: candidates) + // Match each tile's z-order to its source window's WindowServer + // z-order (candidates[0] is front-most) so tiles overlap correctly + // at the start of show / end of dismiss instead of shuffling past + // each other mid-flight. + for (i, c) in candidates.enumerated() { + let z = CGFloat(candidates.count - i) + if let t = allTiles.first(where: { $0.window.windowID == c.windowID }) { + t.layer.zPosition = z + } + } + // Capture each tile's final grid frame, then teleport to its source + // window frame so animateShow can fly all tiles in Exposé-style. + let gridFrames = tiles.map { $0.layer.frame } + if config.animations { + backgroundLayer?.opacity = 0 + for t in tiles { + let src = Self.contentLocalRect(forSourceCGFrame: t.window.frame, overlayWindow: w) + t.setFrame(src) + } + } CATransaction.commit() let tTiles = CFAbsoluteTimeGetCurrent() w.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) if let v = view { w.makeFirstResponder(v) } let tFront = CFAbsoluteTimeGetCurrent() - if config.animations { - w.fadeInAndUp(distance: 0, duration: 0.10) - } - animateShowFromFocused(in: w) + animateShow(gridFrames: gridFrames) let tEnd = CFAbsoluteTimeGetCurrent() Log.debug(String(format: "render: filter=%.1f window=%.1f(new=%@) installTiles=%.1f orderFront+activate=%.1f animate=%.1f total=%.1f n=%d", (tFilter - t0) * 1000, @@ -258,8 +272,9 @@ final class Overlay { } } - private static let smoothEasing = CAMediaTimingFunction(controlPoints: 0.4, 0, 0.2, 1) - private static let pickDuration: Double = 0.16 + private static let smoothEasing = CAMediaTimingFunction(controlPoints: 0.42, 0, 0.58, 1) + private static let showDuration: Double = 0.2 + private static let dismissDuration: Double = 0.2 private func suspendFrames() { for t in allTiles { t.suppressFrames = true } @@ -272,33 +287,29 @@ final class Overlay { } } - private func animateShowFromFocused(in w: NSWindow) { - guard tiles.indices.contains(selectedIndex), - let bounds = w.contentView?.bounds, bounds.width > 0 else { return } - guard config.animations else { return } - let tile = tiles[selectedIndex] - let gridFrame = tile.layer.frame - let sourceFrame = Self.contentLocalRect(forSourceCGFrame: tile.window.frame, overlayWindow: w) - + private func animateShow(gridFrames: [CGRect]) { + guard config.animations, !tiles.isEmpty, gridFrames.count == tiles.count else { + updateSelection() + return + } suspendFrames() - CATransaction.begin() - CATransaction.setDisableActions(true) - tile.highlight = .none - tile.layer.zPosition = 1 - tile.setFrame(sourceFrame) - CATransaction.commit() + // Make sure the teleport-to-source state from renderOverlay is on + // screen before we kick off the fly-in animation. CATransaction.flush() CATransaction.begin() - CATransaction.setAnimationDuration(Self.pickDuration) + CATransaction.setAnimationDuration(Self.showDuration) CATransaction.setAnimationTimingFunction(Self.smoothEasing) - tile.setFrame(gridFrame) + for (i, t) in tiles.enumerated() { + t.highlight = .none + t.setFrame(gridFrames[i]) + } + backgroundLayer?.opacity = 1 CATransaction.commit() - resumeFrames(after: Self.pickDuration) + resumeFrames(after: Self.showDuration) - DispatchQueue.main.asyncAfter(deadline: .now() + Self.pickDuration) { [weak self, weak tile] in - tile?.layer.zPosition = 0 + DispatchQueue.main.asyncAfter(deadline: .now() + Self.showDuration) { [weak self] in self?.updateSelection() } } @@ -636,9 +647,18 @@ private static func windowMostlyOn(displayBounds: CGRect, window: WindowInfo) -> isZoomed = false savedFrames = [] let clearLayers = { [weak self] in - if let root = self?.window?.contentView?.layer { - root.sublayers?.forEach { $0.removeFromSuperlayer() } + guard let self else { return } + if let root = self.window?.contentView?.layer { + root.sublayers?.forEach { layer in + if layer !== self.backgroundLayer { layer.removeFromSuperlayer() } + } } + // Reset the backdrop so the next show starts opaque again. + // pick() animates this to 0 and we never animate it back up. + CATransaction.begin() + CATransaction.setDisableActions(true) + self.backgroundLayer?.opacity = 1 + CATransaction.commit() } if animate, let w { w.fadeOutAndDown(distance: 0, duration: 0.10) { [weak self] in @@ -721,30 +741,44 @@ private static func windowMostlyOn(displayBounds: CGRect, window: WindowInfo) -> } raiseAXWindow(pid: pid, windowID: windowID, title: title) - guard let w = window, let bounds = w.contentView?.bounds, config.animations else { + guard let w = window, config.animations else { hide(activatePrevious: false) isPicking = false return } - _ = bounds let targetFrame = Self.contentLocalRect(forSourceCGFrame: tile.window.frame, overlayWindow: w) suspendFrames() CATransaction.begin() CATransaction.setDisableActions(true) tile.highlight = .none - tile.layer.zPosition = 1 + // Float above all other tiles during the flight regardless of + // their assigned z-order so the picked tile reads as "the one + // being activated." + tile.layer.zPosition = 1_000_000 CATransaction.commit() CATransaction.flush() + // Every tile flies back to where its window actually lives, so no + // fades are needed — each one settles onto its own window. Only the + // backdrop fades out. + let bg = backgroundLayer CATransaction.begin() - CATransaction.setAnimationDuration(Self.pickDuration) + CATransaction.setAnimationDuration(Self.dismissDuration) CATransaction.setAnimationTimingFunction(Self.smoothEasing) - tile.setFrame(targetFrame) + for (i, t) in tiles.enumerated() { + let dest = (i == selectedIndex) + ? targetFrame + : Self.contentLocalRect(forSourceCGFrame: t.window.frame, overlayWindow: w) + t.setFrame(dest) + } + bg?.opacity = 0 CATransaction.commit() - DispatchQueue.main.asyncAfter(deadline: .now() + Self.pickDuration) { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + Self.dismissDuration) { [weak self] in guard let self else { return } + // Skip hide()'s own fadeOutAndDown — the backdrop is already gone. + self.window?.alphaValue = 0 self.hide(activatePrevious: false) self.isPicking = false } @@ -974,12 +1008,20 @@ private static func windowMostlyOn(displayBounds: CGRect, window: WindowInfo) -> ) w.level = .floating w.isOpaque = false - w.backgroundColor = NSColor.black.withAlphaComponent(0.85) - w.isOpaque = false + // Backdrop lives on a dedicated CALayer (see backgroundLayer below) + // so dismiss can fade it independently of the selected tile. + w.backgroundColor = .clear w.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] let v = OverlayView(frame: frame) v.wantsLayer = true v.layer?.backgroundColor = .clear + + let bg = CALayer() + bg.backgroundColor = NSColor.black.withAlphaComponent(0.85).cgColor + bg.frame = v.bounds + bg.autoresizingMask = [.layerWidthSizable, .layerHeightSizable] + v.layer?.addSublayer(bg) + backgroundLayer = bg v.keymap = Keymap(overrides: config.bindings) v.onAction = { [weak self] action in self?.dispatch(action) } v.onSpaceDown = { [weak self] in self?.beginZoom() } From e8914ce71ab51c39931a7344da6d046cea364df7 Mon Sep 17 00:00:00 2001 From: Eric Clemmons Date: Sun, 24 May 2026 15:26:15 -0500 Subject: [PATCH 3/5] Defer window activation until the dismiss animation lands Calling raiseAXWindow and app.activate up front let WindowServer reorder the real windows behind the dim backdrop before the tile animation even started, which read as a flash. Move both calls into the dismiss transaction's completion block, with a small pause on either side: one so the slide reads as "done" before any real-window movement, and one to let WindowServer apply the reorder before the overlay disappears. setCompletionBlock fires after the actual last rendered frame instead of guessing from a delay. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/cmdcmd/Overlay.swift | 47 +++++++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/Sources/cmdcmd/Overlay.swift b/Sources/cmdcmd/Overlay.swift index 64eedd5..2902c64 100644 --- a/Sources/cmdcmd/Overlay.swift +++ b/Sources/cmdcmd/Overlay.swift @@ -735,13 +735,12 @@ private static func windowMostlyOn(displayBounds: CGRect, window: WindowInfo) -> prevPickedWindowID = windowID isPicking = true - raiseAXWindow(pid: pid, windowID: windowID, title: title) - if let app = NSRunningApplication(processIdentifier: pid) { - app.activate() - } - raiseAXWindow(pid: pid, windowID: windowID, title: title) - guard let w = window, config.animations else { + raiseAXWindow(pid: pid, windowID: windowID, title: title) + if let app = NSRunningApplication(processIdentifier: pid) { + app.activate() + } + raiseAXWindow(pid: pid, windowID: windowID, title: title) hide(activatePrevious: false) isPicking = false return @@ -766,6 +765,34 @@ private static func windowMostlyOn(displayBounds: CGRect, window: WindowInfo) -> CATransaction.begin() CATransaction.setAnimationDuration(Self.dismissDuration) CATransaction.setAnimationTimingFunction(Self.smoothEasing) + // setCompletionBlock fires after the real final frame renders, + // not just `duration` ms after we kick the animation off, so we + // don't activate while the slide is still moving. + CATransaction.setCompletionBlock { [weak self] in + guard let self else { return } + // Settle: tiles are at their landing spots and the backdrop is + // invisible. Hold for a beat so the animation reads as "done" + // before any real-window reorder happens. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + guard let self else { return } + self.raiseAXWindow(pid: pid, windowID: windowID, title: title) + if let app = NSRunningApplication(processIdentifier: pid) { + app.activate() + } + self.raiseAXWindow(pid: pid, windowID: windowID, title: title) + // Give WindowServer time to actually reorder before we drop + // the overlay; without this the pre-activation window order + // flashes through between hide() and activation taking + // effect. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + guard let self else { return } + // Skip hide()'s fadeOutAndDown — backdrop is already gone. + self.window?.alphaValue = 0 + self.hide(activatePrevious: false) + self.isPicking = false + } + } + } for (i, t) in tiles.enumerated() { let dest = (i == selectedIndex) ? targetFrame @@ -774,14 +801,6 @@ private static func windowMostlyOn(displayBounds: CGRect, window: WindowInfo) -> } bg?.opacity = 0 CATransaction.commit() - - DispatchQueue.main.asyncAfter(deadline: .now() + Self.dismissDuration) { [weak self] in - guard let self else { return } - // Skip hide()'s own fadeOutAndDown — the backdrop is already gone. - self.window?.alphaValue = 0 - self.hide(activatePrevious: false) - self.isPicking = false - } } private func raiseAXWindow(pid: pid_t, windowID: CGWindowID, title: String?) { From 24c3417d0e9c892538bbee865323f58eaf5427b4 Mon Sep 17 00:00:00 2001 From: Eric Clemmons Date: Sun, 24 May 2026 15:28:07 -0500 Subject: [PATCH 4/5] Animate a macOS-style drop shadow onto the picked tile The system paints a drop shadow on the real window the moment activation runs. Without one on the tile, you see the shadow pop in outside the tile's edges as the overlay disappears. Animate a matching shadow onto the picked tile during the dismiss so the handoff is seamless. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/cmdcmd/Overlay.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Sources/cmdcmd/Overlay.swift b/Sources/cmdcmd/Overlay.swift index 2902c64..dc7428a 100644 --- a/Sources/cmdcmd/Overlay.swift +++ b/Sources/cmdcmd/Overlay.swift @@ -798,6 +798,16 @@ private static func windowMostlyOn(displayBounds: CGRect, window: WindowInfo) -> ? targetFrame : Self.contentLocalRect(forSourceCGFrame: t.window.frame, overlayWindow: w) t.setFrame(dest) + if i == selectedIndex { + // Animate a macOS-style window shadow onto the picked tile + // so the system's real drop shadow (which appears the moment + // activation runs) blends with what's already painted instead + // of popping in around the tile's edges. + t.layer.shadowColor = NSColor.black.cgColor + t.layer.shadowOpacity = 0.45 + t.layer.shadowRadius = 22 + t.layer.shadowOffset = CGSize(width: 0, height: -10) + } } bg?.opacity = 0 CATransaction.commit() From 8b67570f2b24da43f75658b6227bbbcb128c27ad Mon Sep 17 00:00:00 2001 From: Eric Clemmons Date: Sun, 24 May 2026 15:28:32 -0500 Subject: [PATCH 5/5] Reset tile opacity in pick so letter-mode matches click Letter-prefix typing dims non-matching tiles to 0.3 via applyTileLabels. When the final character resolved a unique match and triggered pick(), those dimmed tiles flew back to their source frames still at 0.3, so the animation looked ghostly compared to clicking. Snap every tile's opacity back to 1 at the start of pick() so the dismiss looks identical in both paths. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/cmdcmd/Overlay.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/cmdcmd/Overlay.swift b/Sources/cmdcmd/Overlay.swift index dc7428a..117df80 100644 --- a/Sources/cmdcmd/Overlay.swift +++ b/Sources/cmdcmd/Overlay.swift @@ -755,6 +755,10 @@ private static func windowMostlyOn(displayBounds: CGRect, window: WindowInfo) -> // their assigned z-order so the picked tile reads as "the one // being activated." tile.layer.zPosition = 1_000_000 + // Letter-mode dims non-matching tiles to 0.3 while the user types a + // prefix. Snap everyone back to full opacity before the dismiss so + // the fly-home animation matches the click path. + for t in allTiles { t.layer.opacity = 1.0 } CATransaction.commit() CATransaction.flush()