diff --git a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift index a95b0bdca3..900a5be9dc 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift @@ -239,13 +239,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { let fontGuard = TerminalFontZoomGuard() if fontGuard.start() { terminalFontZoomGuard = fontGuard } - // ⌃⌃ zooms in on the window under the pointer; ⌃⌃ again zooms back out. + // ⌃⌃ zooms in on the window under the pointer; moving onto another + // window follows and reframes it; ⌃⌃ again zooms back out. // The tap listens always — the flag is checked at fire time, not here, so // toggling the feature from the menu doesn't need to tear a tap down. - let lensTap = CtrlDoubleTap { [weak self] in - guard let self = self, self.state.zoomLens else { return } - ZoomLens.toggle() - } + let lensTap = CtrlDoubleTap( + onDoubleTap: { [weak self] in + guard let self = self, self.state.zoomLens else { return } + ZoomLens.toggle() + }, + onPointerMove: { [weak self] point in + guard let self = self, self.state.zoomLens else { return } + ZoomLens.followCursor(to: point) + }) if lensTap.start() { zoomLensTap = lensTap } // setDesktopImageURL only writes the wallpaper on the active Space of diff --git a/slab/menubar-swift/Sources/SlabMenubar/CtrlDoubleTap.swift b/slab/menubar-swift/Sources/SlabMenubar/CtrlDoubleTap.swift index 3ea79f5629..f34c43ad71 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/CtrlDoubleTap.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/CtrlDoubleTap.swift @@ -29,12 +29,17 @@ final class CtrlDoubleTap { private static let window: CFTimeInterval = 0.40 private let onDoubleTap: () -> Void + private let onPointerMove: (CGPoint) -> Void private var tap: CFMachPort? private var source: CFRunLoopSource? private var lastTapAt: CFTimeInterval = 0 + private var pendingPointerLocation: CGPoint? + private var pointerDeliveryScheduled = false - init(onDoubleTap: @escaping () -> Void) { + init(onDoubleTap: @escaping () -> Void, + onPointerMove: @escaping (CGPoint) -> Void = { _ in }) { self.onDoubleTap = onDoubleTap + self.onPointerMove = onPointerMove } /// Returns false if the tap couldn't be created — which in practice always @@ -47,7 +52,11 @@ final class CtrlDoubleTap { (1 << CGEventType.keyDown.rawValue) | (1 << CGEventType.leftMouseDown.rawValue) | (1 << CGEventType.rightMouseDown.rawValue) | - (1 << CGEventType.otherMouseDown.rawValue) + (1 << CGEventType.otherMouseDown.rawValue) | + (1 << CGEventType.mouseMoved.rawValue) | + (1 << CGEventType.leftMouseDragged.rawValue) | + (1 << CGEventType.rightMouseDragged.rawValue) | + (1 << CGEventType.otherMouseDragged.rawValue) let callback: CGEventTapCallBack = { _, type, event, refcon in guard let refcon = refcon else { return Unmanaged.passUnretained(event) } @@ -91,6 +100,12 @@ final class CtrlDoubleTap { return } + if type == .mouseMoved || type == .leftMouseDragged + || type == .rightMouseDragged || type == .otherMouseDragged { + queuePointerMove(event.location) + return + } + // The two taps must be DIRECTLY consecutive — nothing at all in between. // Not merely "no key held under ⌃": any keystroke or click whatsoever // breaks the run, so that ⌃ a ⌃ can never read as ⌃⌃ just because the @@ -129,4 +144,20 @@ final class CtrlDoubleTap { lastTapAt = now } } + + /// Mouse devices can report hundreds of samples per second. Collapse a + /// burst into one main-runloop delivery so following the pointer never + /// makes the listen-only event tap slow enough for macOS to disable it. + private func queuePointerMove(_ point: CGPoint) { + pendingPointerLocation = point + guard !pointerDeliveryScheduled else { return } + pointerDeliveryScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.pointerDeliveryScheduled = false + guard let latest = self.pendingPointerLocation else { return } + self.pendingPointerLocation = nil + self.onPointerMove(latest) + } + } } diff --git a/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift b/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift index 15505d84b8..0d7c2e6625 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift @@ -484,7 +484,7 @@ enum MenuBuilder { let lens = item("Zoom lens (⌃⌃)", selector: #selector(AppDelegate.toggleZoomLens), target: target) lens.state = state.zoomLens ? .on : .off - lens.toolTip = "Tap ⌃ twice to zoom in on the window under the pointer, centred on it. ⌃⌃ again zooms back out. Drives the real macOS Accessibility Zoom, at whatever magnification you've set in System Settings." + lens.toolTip = "Tap ⌃ twice for a zoom-and-flame special move on the window under the pointer. Move or drag onto another window to ease across, refit, recenter, and fire it again. ⌃⌃ zooms back out." sub.addItem(lens) let preferIterm = item("Spawn in iTerm2", selector: #selector(AppDelegate.togglePreferIterm), target: target) diff --git a/slab/menubar-swift/Sources/SlabMenubar/PopSound.swift b/slab/menubar-swift/Sources/SlabMenubar/PopSound.swift index 934c1fc04b..c8da090640 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/PopSound.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/PopSound.swift @@ -42,6 +42,17 @@ enum PopSound { } } + /// The tiny mechanical catch when a pointer-follow pan settles onto its new + /// window. Kept separate from the rising/falling lens pop: this says "locked + /// on", not "zoom mode changed". + static func playTransferClick() { + queue.async { + guard let buffer = renderTransferClick(), start() else { return } + player.scheduleBuffer(buffer, at: nil, options: .interrupts) + player.play() + } + } + /// Lazy, so an app that never zooms never spins up an audio engine. private static func start() -> Bool { if running { return true } @@ -79,4 +90,29 @@ enum PopSound { } return buffer } + + private static func renderTransferClick() -> AVAudioPCMBuffer? { + guard let format = format else { return nil } + let clickDuration = 0.052 + let frames = AVAudioFrameCount(sampleRate * clickDuration) + guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frames), + let samples = buffer.floatChannelData?[0] else { return nil } + buffer.frameLength = frames + + // A seeded noise tick supplies the physical "catch"; the short falling + // partial underneath gives it a definite pitch without becoming a beep. + var noise: UInt32 = 0x51ab_cafe + var phase = 0.0 + for i in 0.. zoomedThreshold } @@ -67,31 +82,102 @@ enum ZoomLens { return } guard let target = targetUnderCursor(excluding: getpid()), - let screen = screen(bestContaining: target) else { + let screen = screen(bestContaining: target.frame) else { NSSound.beep() // pointer is over bare desktop — nothing to aim at return } + zoom(to: target, on: screen, animated: false) + PopSound.play(rising: true) + } + + /// While the lens is up, crossing onto a different window makes that window + /// the new subject. Recompute both magnification and origin so differently + /// 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 } + + let now = CACurrentMediaTime() + guard now - lastFollowAt >= followInterval else { return } + lastFollowAt = now + + // Compositor zoom can also be changed outside Slab (for example with + // Accessibility shortcuts). Notice that promptly and stop following. + guard isZoomed else { + activeTarget = nil + return + } + + 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) + } + + private static func zoom(to target: Target, on screen: NSScreen, animated: Bool) { // 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.width, - screen.frame.height / target.height) + let fit = min(screen.frame.width / target.frame.width, + screen.frame.height / target.frame.height) let factor = min(max(fit / contextMargin, minFactor), maxFactor) - let centre = CGPoint(x: target.midX, y: target.midY) + let centre = CGPoint(x: target.frame.midX, y: target.frame.midY) - apply(origin: centre, factor: factor) - PopSound.play(rising: true) + activeTarget = target + if animated { + pan(to: centre, factor: factor) + } else { + panTimer?.invalidate() + panTimer = nil + apply(origin: centre, factor: factor) + } + ZoomSpecialMove.fire(around: target.frame, on: screen) } static func zoomOut() { + panTimer?.invalidate() + panTimer = nil + activeTarget = nil + 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) } + /// Ease both the viewport centre and magnification. Factor is interpolated + /// logarithmically because zoom is perceived as a ratio: halfway from 2× to + /// 8× should feel like 4×, not 5×. + private static func pan(to destination: CGPoint, factor destinationFactor: CGFloat) { + panTimer?.invalidate() + let start = current() + let began = CACurrentMediaTime() + let startLogFactor = log(max(start.factor, 0.001)) + let endLogFactor = log(max(destinationFactor, 0.001)) + + let timer = Timer(timeInterval: 1.0 / 60.0, repeats: true) { timer in + let elapsed = CACurrentMediaTime() - began + let unit = min(max(CGFloat(elapsed / panDuration), 0), 1) + // Smoothstep: zero velocity at both ends, with no sluggish wind-up. + let eased = unit * unit * (3 - 2 * unit) + let origin = CGPoint( + x: start.origin.x + (destination.x - start.origin.x) * eased, + y: start.origin.y + (destination.y - start.origin.y) * eased) + let factor = exp(startLogFactor + (endLogFactor - startLogFactor) * eased) + apply(origin: origin, factor: factor, smoothing: start.smoothing) + + if unit >= 1 { + timer.invalidate() + panTimer = nil + PopSound.playTransferClick() + } + } + panTimer = timer + RunLoop.main.add(timer, forMode: .common) + } + // MARK: - the window server's zoom // Private CGS/SkyLight. Resolved by dlsym rather than linked, so a future @@ -132,11 +218,11 @@ enum ZoomLens { return (origin, CGFloat(factor), smoothing) } - private static func apply(origin: CGPoint, factor: CGFloat) { + private static func apply(origin: CGPoint, factor: CGFloat, smoothing suppliedSmoothing: Bool? = nil) { guard let cgs = cgs else { return } // Preserve the user's smoothing choice; it's their Accessibility setting, // not ours to flip. - let smoothing = current().smoothing + let smoothing = suppliedSmoothing ?? current().smoothing var o = origin let err = cgs.set(cgs.connection, &o, Double(factor), smoothing) if err != 0 { NSLog("slab zoom lens: CGSSetZoomParameters failed (\(err))") } @@ -152,8 +238,9 @@ enum ZoomLens { /// window zooms *that* window and not the one you happen to be typing in. /// What you're pointing at is what you meant — that's the whole contract, and /// it's why nothing here asks who's frontmost. - private static func targetUnderCursor(excluding pid: pid_t) -> CGRect? { - guard let point = CGEvent(source: nil)?.location, + private static func targetUnderCursor(excluding pid: pid_t, + at suppliedPoint: CGPoint? = nil) -> Target? { + guard let point = suppliedPoint ?? CGEvent(source: nil)?.location, let info = CGWindowListCopyWindowInfo( [.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else { return nil } @@ -164,6 +251,7 @@ enum ZoomLens { for w in info { guard let layer = w[kCGWindowLayer as String] as? Int, let owner = w[kCGWindowOwnerPID as String] as? pid_t, + let number = w[kCGWindowNumber as String] as? CGWindowID, let b = w[kCGWindowBounds as String] as? [String: CGFloat], let x = b["X"], let y = b["Y"], let width = b["Width"], let height = b["Height"] else { continue } @@ -175,10 +263,12 @@ enum ZoomLens { // Smaller than this is a shadow, tooltip or other chrome that // happens to sit at layer 0 — never what "this window" means. guard width >= 64, height >= 64 else { continue } - return rect + return Target(id: number, frame: rect) } - if isStatusItem(layer: layer, rect: rect) { return rect } + if isStatusItem(layer: layer, rect: rect) { + return Target(id: number, frame: rect) + } } // Nothing addressable, but we might still be ON the menu bar — the left @@ -186,7 +276,7 @@ enum ZoomLens { // straight into one full-width Window Server surface), so there is // nothing to hit-test. Zoom a slice of the bar around the pointer instead // of beeping. - return menuBarSlice(around: point) + return menuBarSlice(around: point).map { Target(id: nil, frame: $0) } } /// A menu bar status item — Control Center hosts one window per item, up at diff --git a/slab/menubar-swift/Sources/SlabMenubar/ZoomSpecialMove.swift b/slab/menubar-swift/Sources/SlabMenubar/ZoomSpecialMove.swift new file mode 100644 index 0000000000..b89cef60a1 --- /dev/null +++ b/slab/menubar-swift/Sources/SlabMenubar/ZoomSpecialMove.swift @@ -0,0 +1,203 @@ +import AppKit +import QuartzCore + +/// The visual punctuation on a zoom-lens acquisition: one crisp edge flash, +/// a hot bloom, and a brief spray of flame-like embers around the target. +/// +/// This deliberately does not capture or redraw the window. It is a transparent, +/// click-through Core Animation layer that rides through the Window Server's +/// native zoom with the subject, so the lens keeps its low latency and never +/// asks for Screen Recording permission. +enum ZoomSpecialMove { + private static var panel: NSPanel? + private static var sequence = 0 + + static func fire(around cgFrame: CGRect, on screen: NSScreen) { + precondition(Thread.isMainThread) + let window = panel ?? makePanel() + panel = window + window.setFrame(screen.frame, display: true) + + guard let root = window.contentView?.layer else { return } + root.sublayers?.forEach { $0.removeFromSuperlayer() } + + let localFrame = appKitFrame(for: cgFrame) + .offsetBy(dx: -screen.frame.minX, dy: -screen.frame.minY) + let padding: CGFloat = 42 + let container = CALayer() + container.frame = localFrame.insetBy(dx: -padding, dy: -padding) + root.addSublayer(container) + + let subject = container.bounds.insetBy(dx: padding, dy: padding) + let radius = min(18, min(subject.width, subject.height) * 0.06) + let path = CGPath(roundedRect: subject, cornerWidth: radius, + cornerHeight: radius, transform: nil) + + // Wide hot bloom: the soft outer body of the move. + let bloom = CAShapeLayer() + bloom.path = path + bloom.fillColor = NSColor.clear.cgColor + bloom.strokeColor = NSColor(srgbRed: 1.0, green: 0.20, blue: 0.015, + alpha: 0.92).cgColor + bloom.lineWidth = 7 + bloom.shadowColor = NSColor(srgbRed: 1.0, green: 0.08, blue: 0.0, + alpha: 1).cgColor + bloom.shadowOpacity = 0.95 + bloom.shadowRadius = 28 + bloom.shadowOffset = .zero + container.addSublayer(bloom) + + // A one-pixel pale rim supplies the "sharpen" sensation without + // replacing the actual window pixels with a captured texture. + let edge = CAShapeLayer() + edge.path = path + edge.fillColor = NSColor.clear.cgColor + edge.strokeColor = NSColor(srgbRed: 1.0, green: 0.92, blue: 0.60, + alpha: 1).cgColor + edge.lineWidth = 1.5 + edge.shadowColor = NSColor.white.cgColor + edge.shadowOpacity = 0.9 + edge.shadowRadius = 4 + edge.shadowOffset = .zero + container.addSublayer(edge) + + let emitter = CAEmitterLayer() + emitter.frame = container.bounds + emitter.emitterPosition = CGPoint(x: subject.midX, y: subject.midY) + emitter.emitterSize = subject.size + emitter.emitterShape = .rectangle + emitter.emitterMode = .outline + emitter.renderMode = .additive + emitter.emitterCells = [flameCell(), emberCell()] + container.addSublayer(emitter) + + window.alphaValue = 1 + window.orderFrontRegardless() + animate(layer: bloom, peak: 1.0, duration: 0.72) + animate(layer: edge, peak: 1.0, duration: 0.48) + + // Emit hard for a few frames, then leave only the particles already in + // flight. A sequence token prevents an older burst hiding a newer one. + sequence += 1 + let mySequence = sequence + DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) { + emitter.birthRate = 0 + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.95) { + guard sequence == mySequence else { return } + window.orderOut(nil) + root.sublayers?.forEach { $0.removeFromSuperlayer() } + } + } + + private static func animate(layer: CALayer, peak: Float, duration: CFTimeInterval) { + let animation = CAKeyframeAnimation(keyPath: "opacity") + animation.values = [0, peak, peak * 0.72, 0] + animation.keyTimes = [0, 0.08, 0.28, 1] + animation.timingFunctions = [ + CAMediaTimingFunction(name: .easeOut), + CAMediaTimingFunction(name: .linear), + CAMediaTimingFunction(name: .easeOut), + ] + animation.duration = duration + layer.opacity = 0 + layer.add(animation, forKey: "special-move") + } + + private static func flameCell() -> CAEmitterCell { + let cell = CAEmitterCell() + cell.contents = flameParticle + cell.birthRate = 92 + cell.lifetime = 0.62 + cell.lifetimeRange = 0.22 + cell.velocity = 92 + cell.velocityRange = 44 + cell.emissionLongitude = .pi / 2 + cell.emissionRange = .pi / 7 + cell.scale = 0.17 + cell.scaleRange = 0.08 + cell.scaleSpeed = -0.10 + cell.alphaSpeed = -1.18 + cell.spinRange = 0.8 + return cell + } + + private static func emberCell() -> CAEmitterCell { + let cell = CAEmitterCell() + cell.contents = emberParticle + cell.birthRate = 48 + cell.lifetime = 0.82 + cell.lifetimeRange = 0.32 + cell.velocity = 126 + cell.velocityRange = 72 + cell.emissionLongitude = .pi / 2 + cell.emissionRange = .pi / 3 + cell.scale = 0.075 + cell.scaleRange = 0.045 + cell.scaleSpeed = -0.035 + cell.alphaSpeed = -0.92 + cell.spin = 1.1 + cell.spinRange = 2.4 + return cell + } + + private static let flameParticle: CGImage? = particleImage( + size: CGSize(width: 28, height: 44), + inner: NSColor(srgbRed: 1.0, green: 0.98, blue: 0.62, alpha: 0.96), + middle: NSColor(srgbRed: 1.0, green: 0.25, blue: 0.015, alpha: 0.72)) + + private static let emberParticle: CGImage? = particleImage( + size: CGSize(width: 16, height: 16), + inner: NSColor(srgbRed: 1.0, green: 1.0, blue: 0.78, alpha: 1), + middle: NSColor(srgbRed: 1.0, green: 0.12, blue: 0.0, alpha: 0.82)) + + private static func particleImage(size: CGSize, inner: NSColor, + middle: NSColor) -> CGImage? { + let width = Int(size.width) + let height = Int(size.height) + guard let context = CGContext( + data: nil, width: width, height: height, bitsPerComponent: 8, + bytesPerRow: width * 4, space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue), + let gradient = CGGradient( + colorsSpace: CGColorSpaceCreateDeviceRGB(), + colors: [inner.cgColor, middle.cgColor, NSColor.clear.cgColor] as CFArray, + locations: [0, 0.34, 1]) else { return nil } + let centre = CGPoint(x: size.width / 2, y: size.height * 0.42) + context.scaleBy(x: 1, y: size.height / size.width) + context.drawRadialGradient( + gradient, + startCenter: CGPoint(x: centre.x, y: centre.y * size.width / size.height), + startRadius: 0, + endCenter: CGPoint(x: centre.x, y: centre.y * size.width / size.height), + endRadius: size.width / 2, + options: [.drawsAfterEndLocation]) + return context.makeImage() + } + + private static func appKitFrame(for cgFrame: CGRect) -> CGRect { + let desktopTop = NSScreen.screens.map(\.frame.maxY).max() ?? 0 + return CGRect(x: cgFrame.minX, y: desktopTop - cgFrame.maxY, + width: cgFrame.width, height: cgFrame.height) + } + + private static func makePanel() -> NSPanel { + let window = NSPanel( + contentRect: .zero, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false) + window.isOpaque = false + window.backgroundColor = .clear + window.hasShadow = false + window.level = .screenSaver + window.ignoresMouseEvents = true + window.hidesOnDeactivate = false + window.collectionBehavior = [.canJoinAllSpaces, .stationary, + .ignoresCycle, .fullScreenAuxiliary] + let view = NSView() + view.wantsLayer = true + window.contentView = view + return window + } +}