From bbd80f2358e3873692c60e441d10e06a8c0f117f Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 13 Jul 2026 00:38:00 -0700 Subject: [PATCH] =?UTF-8?q?slab=20menubar:=20=E2=8C=83=E2=8C=83=20fits=20t?= =?UTF-8?q?he=20whole=20window=20=E2=80=94=20drive=20the=20compositor's=20?= =?UTF-8?q?zoom=20directly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CGSSetZoomParameters takes exactly the two things the public surface won't give up: an origin AND a factor. So the framing is entirely ours — fit the window, leave 10% of context, centre it — while the rendering stays the window server's, which is why it's smooth. UAZoomChangeFocus (pans but can't scale) and the ⌥⌘8 + closeViewDesiredZoomFactor route (the pref isn't read live) are both dead ends; this needs neither, and touches none of the user's Accessibility settings. The reason this looked like a dead API for so long: `factor` is a DOUBLE, not a float. Read through a Float* it comes back as exactly 0.0 every time — the low 32 bits of 1.0/2.0/4.0 as doubles are all zero — which reads like "no value" rather than "wrong type". `origin` is the viewport CENTRE, in CoreGraphics globals (top-left, y down), in points. Setting factor > 1 engages the zoom on its own; 1.0 exits. Verified end to end by reading the factor back out of the compositor: an 811x474 window on a 1408x881 screen gives fit 1.736 → factor 1.578, origin (611,410) = the window's centre. Measured: 1.578 at (611,410). Worth recording that the zoom is invisible to screenshots — it's a compositor transform, so screencapture and ScreenCaptureKit both return the UNZOOMED framebuffer. CGSGetZoomParameters is the only way to see it, which is what made the earlier blind iterations so slow. Compositor zoom outlives the process, so we zoom out on terminate. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Sources/SlabMenubar/AppDelegate.swift | 3 +- .../Sources/SlabMenubar/ZoomLens.swift | 197 ++++++++++++------ 2 files changed, 132 insertions(+), 68 deletions(-) diff --git a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift index 46c5857c08..d666e674c6 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift @@ -174,7 +174,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // ⌃⌃ zooms in on the window under the pointer; ⌃⌃ 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. - ZoomLens.ensureHotkeysEnabled() let lensTap = CtrlDoubleTap { [weak self] in guard let self = self, self.state.zoomLens else { return } ZoomLens.toggle() @@ -209,6 +208,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { scatterHotkey?.unregister() appearanceHotkey?.unregister() zoomLensTap?.stop() + // Compositor zoom outlives us — never quit leaving the screen magnified. + if ZoomLens.isZoomed { ZoomLens.zoomOut() } passphraseServer.stop() LedgerStore.shared.stop() NSWorkspace.shared.notificationCenter.removeObserver(self) diff --git a/slab/menubar-swift/Sources/SlabMenubar/ZoomLens.swift b/slab/menubar-swift/Sources/SlabMenubar/ZoomLens.swift index 46062149a3..148c8f2fc4 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/ZoomLens.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/ZoomLens.swift @@ -2,94 +2,141 @@ import AppKit import ApplicationServices import CoreGraphics -/// ⌃⌃ zooms the screen in on the window under the pointer, centred on it. ⌃⌃ -/// again zooms back out. +/// ⌃⌃ zooms in on the window under the pointer — the whole window, scaled to fit, +/// centred, with a margin of surrounding context. ⌃⌃ again zooms back out. /// -/// This drives macOS's *real* Accessibility Zoom rather than rendering a -/// magnifier of our own, and that is the entire point. A userspace lens has to -/// re-composite the screen every frame (capture → GPU → transform) and hand-draw -/// the cursor from mouse events, and both sit on top of latency the real zoom -/// doesn't have — CloseView lives inside the window server, where the cursor is -/// composited natively and there is no capture round-trip. We built the -/// userspace one first. It was janky, and no amount of polish gets it under that -/// floor. +/// This drives the window server's *own* zoom — the same compositor path +/// Accessibility Zoom uses — rather than rendering a magnifier of our own. That +/// distinction is the whole feature. A userspace lens has to re-composite the +/// screen every frame (capture → GPU → transform) and hand-draw the cursor from +/// mouse events, and both sit on latency the real zoom doesn't have. We built +/// that first. It was janky, and no polish gets it under that floor. /// -/// So: don't rebuild it, aim it. Two things had to be true, and both were worth -/// establishing by experiment rather than assumption: +/// `CGSSetZoomParameters` is the knob, and it takes exactly the two things the +/// public surface won't give up: an origin *and* a factor. So the framing is +/// entirely ours — fit the window, leave `contextMargin` of slack, centre it — +/// while the rendering stays the compositor's. /// -/// • Synthetic ⌃+scroll — the gesture a human uses — does NOT reach CloseView; -/// it listens below the level `CGEvent.post` injects at. Its *keyboard* -/// shortcut (⌥⌘8) does, because that runs through the ordinary system hotkey -/// path. So the toggle is a synthesized ⌥⌘8, which needs -/// `closeViewHotkeysEnabled` — see `ensureHotkeysEnabled()`. +/// Notes from prising it open, since none of this is documented: /// -/// • `UAZoomChangeFocus` is the public API for pointing the zoom at a rect. -/// It pans the viewport; it cannot set magnification. So the *framing* is -/// ours (centred on the window you're pointing at) and the *factor* stays -/// whatever the user set in System Settings — which is the right call anyway: -/// it's their preference, not ours to override. +/// • `factor` is a **double**, not a float. Read it through a `Float *` and it +/// comes back as exactly 0.0 every time (the low 32 bits of 1.0, 2.0, 4.0 as +/// doubles are all zero), which reads like a dead API rather than a type +/// error. It cost an hour. It is not dead. /// -/// `UAZoomEnabled()` reads as "currently zoomed in", not "feature available" — -/// verified by watching it flip false→true→false across a toggle. That makes it -/// our state, so the lens stays in sync even when the user zooms by hand. +/// • `origin` is the viewport's *centre*, in CoreGraphics globals (top-left, +/// y down) and in points, not pixels — at rest it reads as the centre of the +/// screen. +/// +/// • Setting a factor > 1 engages the zoom on its own. No ⌥⌘8, no +/// `closeViewHotkeysEnabled`, no preference writes: we never touch the +/// user's Accessibility settings. Setting factor 1.0 exits cleanly. +/// +/// • `UAZoomChangeFocus` is useless here — it's a no-op against a zoom that +/// wasn't engaged by CloseView itself. We set the origin directly instead. +/// +/// The zoom is compositor state, so it OUTLIVES this process: quit while zoomed +/// and the screen stays zoomed. Hence `zoomOut()` on terminate. (A user is never +/// truly stuck — ⌃+scroll still gets them out — but they shouldn't have to.) enum ZoomLens { - /// kVK_ANSI_8 — with ⌥⌘, CloseView's zoom toggle. - private static let eightKeyCode: CGKeyCode = 0x1C + /// How much of the screen to leave around the window. 1.1 → the window fills + /// ~90% of the screen's tighter axis, and the 10% left over is the context + /// that tells you *where* the window is. + private static let contextMargin: CGFloat = 1.1 + /// Below this the zoom isn't worth the interruption; above it, a tiny palette + /// window becomes an unreadable wall of pixels. + private static let minFactor: CGFloat = 1.2 + private static let maxFactor: CGFloat = 8.0 + /// Floating-point slop — the compositor reports 1.0029 for "not zoomed". + private static let zoomedThreshold: CGFloat = 1.01 - static var isZoomed: Bool { UAZoomEnabled() } + static var isZoomed: Bool { current().factor > zoomedThreshold } static func toggle() { if isZoomed { - pressZoomToggle() + zoomOut() return } - // Grab the window before zooming: once the view magnifies, the pointer's - // screen position still maps to the same global coordinates, but reading - // it first keeps the intent obvious. - let target = windowUnderCursor(excluding: getpid()) - pressZoomToggle() - - guard var rect = target else { return } - // CloseView needs a moment to actually be zoomed before it will accept a - // focus change; asking too early is a no-op. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { - let err = UAZoomChangeFocus(&rect, &rect, - UAZoomChangeFocusType(kUAZoomFocusTypeOther)) - if err != noErr { NSLog("slab zoom lens: UAZoomChangeFocus failed (\(err))") } + guard let window = windowUnderCursor(excluding: getpid()), + let screen = screen(bestContaining: window) else { + NSSound.beep() // pointer is over the desktop — nothing to aim at + return } + + // 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 / window.width, + screen.frame.height / window.height) + let factor = min(max(fit / contextMargin, minFactor), maxFactor) + let centre = CGPoint(x: window.midX, y: window.midY) + + apply(origin: centre, factor: factor) + } + + static func zoomOut() { + 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) } - private static func pressZoomToggle() { - let source = CGEventSource(stateID: .hidSystemState) - for down in [true, false] { - guard let event = CGEvent(keyboardEventSource: source, - virtualKey: eightKeyCode, keyDown: down) else { continue } - event.flags = [.maskAlternate, .maskCommand] - event.post(tap: .cghidEventTap) + // MARK: - the window server's zoom + + // Private CGS/SkyLight. Resolved by dlsym rather than linked, so a future + // macOS dropping the symbols degrades to a dead ⌃⌃ rather than a binary that + // won't launch. + private typealias MainConnectionID = @convention(c) () -> UInt32 + private typealias GetZoomParameters = + @convention(c) (UInt32, UnsafeMutablePointer, + UnsafeMutablePointer, UnsafeMutablePointer) -> Int32 + private typealias SetZoomParameters = + @convention(c) (UInt32, UnsafeMutablePointer, Double, Bool) -> Int32 + + private struct CGS { + let connection: UInt32 + let get: GetZoomParameters + let set: SetZoomParameters + } + + private static let cgs: CGS? = { + guard let handle = dlopen(nil, RTLD_NOW), + let conn = dlsym(handle, "CGSMainConnectionID"), + let get = dlsym(handle, "CGSGetZoomParameters"), + let set = dlsym(handle, "CGSSetZoomParameters") else { + NSLog("slab zoom lens: CGS zoom symbols missing — ⌃⌃ disabled") + return nil } + return CGS(connection: unsafeBitCast(conn, to: MainConnectionID.self)(), + get: unsafeBitCast(get, to: GetZoomParameters.self), + set: unsafeBitCast(set, to: SetZoomParameters.self)) + }() + + private static func current() -> (origin: CGPoint, factor: CGFloat, smoothing: Bool) { + guard let cgs = cgs else { return (.zero, 1, false) } + var origin = CGPoint.zero + var factor: Double = 1 + var smoothing = false + _ = cgs.get(cgs.connection, &origin, &factor, &smoothing) + return (origin, CGFloat(factor), smoothing) } - /// ⌥⌘8 only reaches CloseView if the zoom keyboard shortcuts are switched on, - /// and they're off by default. Turn them on once, then poke universalaccessd - /// — it caches the preference and won't notice a write on its own. Only when - /// we actually changed something: the daemon respawns on demand, but kicking - /// it on every launch would be gratuitous. - static func ensureHotkeysEnabled() { - let domain = "com.apple.universalaccess" as CFString - let key = "closeViewHotkeysEnabled" as CFString - CFPreferencesAppSynchronize(domain) - let current = CFPreferencesCopyAppValue(key, domain) as? NSNumber - guard current?.boolValue != true else { return } - - CFPreferencesSetAppValue(key, kCFBooleanTrue, domain) - CFPreferencesAppSynchronize(domain) - _ = ShellRunner.run("/usr/bin/killall", args: ["universalaccessd"], timeout: 5) - NSLog("slab zoom lens: enabled Accessibility Zoom keyboard shortcuts (⌥⌘8)") + private static func apply(origin: CGPoint, factor: CGFloat) { + 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 + var o = origin + let err = cgs.set(cgs.connection, &o, Double(factor), smoothing) + if err != 0 { NSLog("slab zoom lens: CGSSetZoomParameters failed (\(err))") } } + // MARK: - what to aim at + /// The topmost ordinary window under the pointer, in CoreGraphics globals — - /// the same space `UAZoomChangeFocus` wants. `optionOnScreenOnly` hands the - /// list back already sorted front-to-back, so the first hit wins. + /// the same space the zoom origin uses. `optionOnScreenOnly` hands the list + /// back already sorted front-to-back, so the first hit wins. private static func windowUnderCursor(excluding pid: pid_t) -> CGRect? { guard let point = CGEvent(source: nil)?.location, let info = CGWindowListCopyWindowInfo( @@ -109,4 +156,20 @@ enum ZoomLens { } return nil } + + private static func screen(bestContaining rect: CGRect) -> NSScreen? { + // rect is CoreGraphics (y down); NSScreen.frame is AppKit (y up). Flip the + // screens, not the rect — only their overlap matters here. + let top = NSScreen.screens.first?.frame.maxY ?? 0 + func asCG(_ f: CGRect) -> CGRect { + CGRect(x: f.minX, y: top - f.maxY, width: f.width, height: f.height) + } + return NSScreen.screens.max { + asCG($0.frame).intersection(rect).area < asCG($1.frame).intersection(rect).area + } ?? NSScreen.main + } +} + +private extension CGRect { + var area: CGFloat { isNull ? 0 : width * height } } -- 2.51.2