diff --git a/slab/menubar-swift/README.md b/slab/menubar-swift/README.md index 849ffe44c0..4790ead70b 100644 --- a/slab/menubar-swift/README.md +++ b/slab/menubar-swift/README.md @@ -43,6 +43,13 @@ network, SSD, and GPU. Hover expands it into readable local history rows with current values; click pins the same panel. It does not poll or display other machines. +## Window zoom + +Double-tap Control over a window to zoom; repeat to exit. Command–Option–arrow +focuses the neighboring prompt and smoothly refits the lens, including across +Deskflow hosts. Move the pointer deliberately to resume mouse following. +Full-height windows keep the lens active at 1×. Reduce Motion skips animation. + ## Prompt rocks `Sources/SlabMenubar/PromptSigilOverlay.swift` — the tumbling stones parked at diff --git a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift index cc1833e879..86a02e1b07 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift @@ -422,7 +422,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { navHoldTap?.stop() NavHoldHint.shared.endHold() // Compositor zoom outlives us — never quit leaving the screen magnified. - if ZoomLens.isZoomed { ZoomLens.zoomOut() } + ZoomLens.zoomOut() passphraseServer.stop() ResourceGraph.shared.stop() LedgerStore.shared.stop() @@ -2136,7 +2136,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { nowOn = false } state.zoomLens = nowOn - if !nowOn, ZoomLens.isZoomed { ZoomLens.toggle() } // don't strand a zoom + if !nowOn { ZoomLens.zoomOut() } refresh() } diff --git a/slab/menubar-swift/Sources/SlabMenubar/DeskflowSpatialNav.swift b/slab/menubar-swift/Sources/SlabMenubar/DeskflowSpatialNav.swift index 17006b82eb..106cca6f0b 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/DeskflowSpatialNav.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/DeskflowSpatialNav.swift @@ -63,17 +63,26 @@ enum DeskflowSpatialNav { screenToken($0.host) == screenToken(target) && $0.promptCount > 0 }) else { return } + let carryZoom = ZoomLens.isEngaged + let zoomRevision = ZoomLens.revision DispatchQueue.global(qos: .userInteractive).async { // The controller acknowledges as soon as its edge moves are queued. // Queue the destination immediately too, with only enough delay for // Deskflow to traverse the actual intermediate edges. This removes // the old conservative sleep from every cross-host arrow. guard routeOnActiveController(path) else { return } - post(ip: targetLedger.ip, endpoint: "/navigate", body: [ + let accepted = post(ip: targetLedger.ip, endpoint: "/navigate", body: [ "direction": (path.last ?? direction).rawValue, "alignment": Double(alignment), "delayMs": max(10, (path.count - 1) * 28 + 10), + "zoom": carryZoom, ]) + if accepted && carryZoom { + DispatchQueue.main.async { + guard ZoomLens.revision == zoomRevision else { return } + ZoomLens.zoomOut(animated: true) + } + } } } @@ -83,9 +92,10 @@ enum DeskflowSpatialNav { guard let raw = body["direction"] as? String, let direction = WindowNav.Direction(rawValue: raw) else { return false } let alignment = CGFloat((body["alignment"] as? NSNumber)?.doubleValue ?? 0.5) + let zoom = body["zoom"] as? Bool ?? false let delay = min(max((body["delayMs"] as? NSNumber)?.doubleValue ?? 0, 0), 250) / 1000 DispatchQueue.main.asyncAfter(deadline: .now() + delay) { - WindowNav.accept(direction, alignment: alignment) + WindowNav.accept(direction, alignment: alignment, zoom: zoom) } return true } diff --git a/slab/menubar-swift/Sources/SlabMenubar/WindowNav.swift b/slab/menubar-swift/Sources/SlabMenubar/WindowNav.swift index 3190974485..fa620752ef 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/WindowNav.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/WindowNav.swift @@ -53,7 +53,12 @@ enum WindowNav { return } - guard let focusCenter = focusedWindowCenter() else { + // The lens can start over a background pane. Walk from the pane the + // user is looking at, including while a previous AX raise is settling. + let lensWindow = ZoomLens.targetWindowID.flatMap { id in + wins.first { $0.id == Int(id) } + } + guard let focusCenter = lensWindow?.center ?? focusedWindowCenter() else { focus(wins[0]) NavHoldHint.shared.noteJump(local: true) return @@ -115,7 +120,7 @@ enum WindowNav { /// Fleet navigation endpoint: select the prompt nearest the edge through /// which the unipointer is arriving, keeping its row/column aligned with /// the source pane, then punctuate the focus change with the lens move. - static func accept(_ travelDirection: Direction, alignment: CGFloat) { + static func accept(_ travelDirection: Direction, alignment: CGFloat, zoom: Bool = false) { guard AXTiler.trusted else { return } let wins = windows() guard !wins.isEmpty else { return } @@ -124,7 +129,7 @@ enum WindowNav { entryScore($0, travelDirection: travelDirection, alignment: a) < entryScore($1, travelDirection: travelDirection, alignment: a) } ?? wins[0] - focus(target) + focus(target, engageZoom: zoom) } /// Called only on the active Deskflow controller. Each synthetic HID move @@ -241,7 +246,7 @@ enum WindowNav { return (winRef as! AXUIElement) } - private static func focus(_ window: Window) { + private static func focus(_ window: Window, engageZoom: Bool = false) { AXUIElementPerformAction(window.el, kAXRaiseAction as CFString) AXUIElementSetAttributeValue(window.el, kAXMainAttribute as CFString, kCFBooleanTrue) var pid: pid_t = 0 @@ -251,7 +256,9 @@ enum WindowNav { } // Raising and app activation complete on the next WindowServer turn; // the geometry is already stable, so the acquisition move can fire now. - if let screen = screen(containingCG: window.center) { + if !ZoomLens.followWindow(id: CGWindowID(window.id), frame: window.frame, + engage: engageZoom), + let screen = screen(containingCG: window.center) { ZoomSpecialMove.fire(around: window.frame, on: screen) } PopSound.playTransferClick() diff --git a/slab/menubar-swift/Sources/SlabMenubar/ZoomLens.swift b/slab/menubar-swift/Sources/SlabMenubar/ZoomLens.swift index effc2f8d92..f0b2e69349 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/ZoomLens.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/ZoomLens.swift @@ -4,7 +4,8 @@ import CoreGraphics /// ⌃⌃ zooms in on whatever the pointer is on — the whole thing, scaled to fit, /// centred, with a margin of surrounding context. Moving onto another window -/// follows, refits, and recentres it. ⌃⌃ again zooms back out. +/// follows, refits, and recentres it. ⌘⌥ arrows carry the lens with window +/// focus; moving the pointer deliberately resumes following. ⌃⌃ exits. /// /// "Whatever" means an ordinary window, or a menu bar item. Status items turn out /// to be real windows (Control Center hosts one apiece, up at the menu bar @@ -74,12 +75,29 @@ enum ZoomLens { private static var activeTarget: Target? private static var lastFollowAt: CFTimeInterval = 0 private static var panTimer: Timer? + private static var targetFactor: CGFloat = 1 + private static var keyboardPointerAnchor: CGPoint? + private(set) static var revision: UInt64 = 0 static var isZoomed: Bool { current().factor > zoomedThreshold } + static var isEngaged: Bool { activeTarget != nil } + static var targetWindowID: CGWindowID? { activeTarget?.id } + + /// Keyboard navigation owns the camera until the pointer deliberately moves. + @discardableResult + static func followWindow(id: CGWindowID, frame: CGRect, engage: Bool = false) -> Bool { + guard isEngaged || engage, + frame.width > 0, frame.height > 0, + !FileManager.default.fileExists(atPath: Paths.zoomLensDisabledFlag), + let screen = screen(bestContaining: frame) else { return false } + keyboardPointerAnchor = CGEvent(source: nil)?.location + zoom(to: Target(id: id, frame: frame), on: screen) + return true + } static func toggle() { - if isZoomed { - zoomOut() + if isEngaged || isZoomed || panTimer != nil { + zoomOut(animated: true) PopSound.play(rising: false) return } @@ -89,7 +107,8 @@ enum ZoomLens { return } - zoom(to: target, on: screen, animated: false) + keyboardPointerAnchor = nil + zoom(to: target, on: screen) PopSound.play(rising: true) } @@ -98,7 +117,16 @@ enum ZoomLens { /// sized windows still fit and land centred, rather than merely sliding the /// old zoom factor across the desktop. static func followCursor(to point: CGPoint) { - guard activeTarget != nil else { return } + guard activeTarget != nil, panTimer == nil else { return } + + // Holding the navigation chord or nudging a stationary mouse must not + // undo an arrow-key acquisition. Rebase after the compositor settles. + let flags = CGEventSource.flagsState(.combinedSessionState) + guard !flags.contains(.maskCommand) || !flags.contains(.maskAlternate) else { return } + if let anchor = keyboardPointerAnchor { + guard hypot(point.x - anchor.x, point.y - anchor.y) >= 12 else { return } + keyboardPointerAnchor = nil + } let now = CACurrentMediaTime() guard now - lastFollowAt >= followInterval else { return } @@ -106,7 +134,7 @@ enum ZoomLens { // Compositor zoom can also be changed outside Slab (for example with // Accessibility shortcuts). Notice that promptly and stop following. - guard isZoomed else { + guard targetFactor <= zoomedThreshold || isZoomed else { activeTarget = nil return } @@ -114,47 +142,42 @@ enum ZoomLens { guard let target = targetUnderCursor(excluding: getpid(), at: point), target != activeTarget, let screen = screen(bestContaining: target.frame) else { return } - zoom(to: target, on: screen, animated: true) + zoom(to: target, on: screen) } - private static func zoom(to target: Target, on screen: NSScreen, animated: Bool) { + private static func zoom(to target: Target, on screen: NSScreen) { // Fit the whole window on the tighter axis, then back off by the margin. // The looser axis keeps whatever slack the aspect ratio gives it — which // is why a window never fills the screen edge-to-edge, and why you can // still see what's around it. let fit = min(screen.frame.width / target.frame.width, screen.frame.height / target.frame.height) - // Never exceed `fit`: that is the largest factor at which the target's - // complete width and height remain visible. Usually `contextMargin` - // makes the factor smaller still, but for a tall/full-height window the - // requested margin can take it below 1×. The compositor cannot zoom out, - // so settle at 1× while retaining the window's unmodified aspect ratio. - let factor = min(max(fit / contextMargin, 1.0), fit, maxFactor) + // Full-height or oversized windows stay at 1×. Keep the lens engaged + // so the next arrow can still magnify a smaller neighboring pane. + let factor = max(1.0, min(fit / contextMargin, maxFactor)) let centre = CGPoint(x: target.frame.midX, y: target.frame.midY) activeTarget = target - if animated { - pan(to: centre, factor: factor) - } else { - panTimer?.invalidate() - panTimer = nil - apply(origin: centre, factor: factor) - } - // The particle bloom is punctuation for acquisition, not navigation. - // Rebuilding it at every cross-window drag handoff competes with the - // compositor pan and makes following feel sticky. - if !animated { ZoomSpecialMove.fire(around: target.frame, on: screen) } + targetFactor = factor + revision &+= 1 + pan(to: centre, factor: factor) } - static func zoomOut() { + static func zoomOut(animated: Bool = false) { panTimer?.invalidate() panTimer = nil activeTarget = nil + keyboardPointerAnchor = nil + targetFactor = 1 + revision &+= 1 lastFollowAt = 0 guard let screen = NSScreen.main else { return } // Factor 1.0 is the exit. The origin is irrelevant at 1×, but hand back // the screen centre so a subsequent zoom-by-hand starts somewhere sane. - apply(origin: CGPoint(x: screen.frame.midX, y: screen.frame.midY), factor: 1.0) + let top = NSScreen.screens.first?.frame.maxY ?? 0 + let centre = CGPoint(x: screen.frame.midX, y: top - screen.frame.midY) + if animated { pan(to: centre, factor: 1.0) } + else { apply(origin: centre, factor: 1.0) } } /// Ease both the viewport centre and magnification. Factor is interpolated @@ -162,6 +185,11 @@ enum ZoomLens { /// 8× should feel like 4×, not 5×. private static func pan(to destination: CGPoint, factor destinationFactor: CGFloat) { panTimer?.invalidate() + panTimer = nil + guard !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion else { + apply(origin: destination, factor: destinationFactor) + return + } let start = current() let began = CACurrentMediaTime() let startLogFactor = log(max(start.factor, 0.001)) @@ -181,7 +209,9 @@ enum ZoomLens { if unit >= 1 { timer.invalidate() panTimer = nil - PopSound.playTransferClick() + if keyboardPointerAnchor != nil { + keyboardPointerAnchor = CGEvent(source: nil)?.location + } } } panTimer = timer