From 76794fe31ac9b970d56beb3c4d35040cc1fc484b Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 13 Sep 2026 20:18:13 -0400 Subject: [PATCH] slab preview: no black on resize, display-rate, flags survive reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card's web view no longer resizes during a live pane drag: the stage only rescales, and one reframe runs after the pane holds still, under a snapshot of the last frame that stays up until the piece has painted at its new size. The maxfps cap is gone — the card runs at display rate. Also from the earlier pass: the resting card is the whole viewport composited down rather than a Ken Burns crop, the open card takes the pointer and keyboard, the rock steps aside while it is open, and bios now carries every preserved embedding flag (nolabel, autoreload, maxfps) through the URL rewrite the auto-reload comes back to. Co-Authored-By: Claude Fable 5.1 --- .../Sources/SlabMenubar/PromptPreview.swift | 283 +++++++++++------- .../SlabMenubar/PromptSigilOverlay.swift | 8 + system/public/aesthetic.computer/bios.mjs | 12 + 3 files changed, 199 insertions(+), 104 deletions(-) diff --git a/slab/menubar-swift/Sources/SlabMenubar/PromptPreview.swift b/slab/menubar-swift/Sources/SlabMenubar/PromptPreview.swift index aaaa28c912..e5cefb21d2 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/PromptPreview.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/PromptPreview.swift @@ -13,14 +13,25 @@ // // What it shows is `scan_url` — the same address the rock encodes — so the two // surfaces can never disagree about which piece this session is about. It runs -// capped (`maxfps`) because a wall of nine panes each animating at display rate -// is a warm laptop for no one's benefit. +// at display rate: a piece is judged by how it moves, and a card that shows +// four frames a second answers "is it moving?" but never "is it right?". // // The piece runs at the pane's own viewport the whole time. The resting card is -// a small window onto it — a slow Ken Burns crop at 1:1, the way chat.mjs shows -// a `#painting` — and pointing at the card opens that window up to the whole -// viewport. Nothing is resized on the way: a live resize of a web view costs a -// reframe and a black frame or two, and the old card paid both on every hover. +// that whole viewport composited down to thumbnail size, and pointing at the +// card scales it back up to 1:1 over the pane. Nothing is resized on the way: a +// live resize of a web view costs a reframe and a black frame or two, and the +// old card paid both on every hover. +// +// The pane itself resizing is the one time the web view must follow, and it +// follows late: while the terminal is being dragged the stage only rescales, +// and the real reframe happens once, after the drag settles, under a snapshot +// of the last frame that stays up until the piece has painted at its new size. +// A WKWebView mid-resize shows its window's backing colour — here, the card's +// black — and that flash was what a resize used to look like. +// +// While the card is open it is the piece, not a picture of it: the pointer +// reaches the web view and so does the keyboard. Pointing at your own work and +// having it ignore you is the wrong kind of preview. // // The chrome over it is not decoration. A web view that is covered, throttled // or simply one save behind shows a frame that looks exactly like a live one, @@ -135,10 +146,13 @@ final class PromptPreview { /// terminal never looks completely papered over. private static let hoverMargin: CGFloat = 12 - /// Frames per second while nobody is looking. Four is enough to see that a - /// piece is moving — which is the only question a glance asks — and cheap - /// enough to leave running on every pane at once. - private static let restFPS = 4 + /// How long a pane has to hold still before the web view is reframed to + /// it. Live-resize ticks arrive every frame; this collapses a drag into one + /// reframe at the end of it. + private static let resizeSettle: TimeInterval = 0.3 + /// How long the snapshot stays over the reframed web view. The runtime's own + /// resize handler debounces, then repaints; this covers both. + private static let coverHold: TimeInterval = 0.6 /// Inset from the pane's left edge, and drop below its title bar. The card /// parks *inside* the pane rather than over the title: the top-left of a @@ -163,18 +177,19 @@ final class PromptPreview { /// which is what a screen on a desk looks like. private static let cardRadius: CGFloat = 3 - /// One slow lap of the resting crop around the piece. Matches the - /// `#painting` embeds in chat.mjs, which this card is a cousin of. - private static let kenBurnsCycle: TimeInterval = 8 - /// How often the crop moves. A pan of a few points a second at 24 steps - /// reads as continuous; the web view is not repainted by it, only - /// re-composited. - private static let kenBurnsInterval: TimeInterval = 1.0 / 24 /// How long the card takes to open or close. private static let openDuration: TimeInterval = 0.16 - private let window: NSWindow + private let window: PromptPreviewWindow private let webView: WKWebView + /// Holds the web view at viewport size and is what gets scaled: the web + /// view's own layer belongs to WebKit, and a transform set on it is a + /// transform WebKit may take back. + private let stage = NSView() + /// The last frame, held over the stage while the web view is reframed. + private let cover = NSImageView() + private var settleTimer: Timer? + private var coverTimer: Timer? private let badgeHost: NSHostingView private let border = CALayer() /// The card proper — the part of the viewport the eye is shown. The @@ -212,10 +227,10 @@ final class PromptPreview { /// the card reframes nothing: what was cropped is simply shown. private var viewport = PromptPreview.restSize - /// Where in the lap this card's crop is; a random phase so a wall of cards - /// does not drift in lockstep. - private let kenBurnsSeed = Double.random(in: 0..<1) - private var kenBurnsTimer: Timer? + /// Whoever was frontmost when the card took the keyboard, so closing the + /// card hands focus back to the terminal it was over rather than leaving + /// a menubar app as the active one. + private var yieldTo: NSRunningApplication? init() { let config = WKWebViewConfiguration() @@ -225,23 +240,23 @@ final class PromptPreview { config.suppressesIncrementalRendering = false webView = WKWebView(frame: .zero, configuration: config) webView.setValue(false, forKey: "drawsBackground") - // Positioned by hand: the crop is an offset, never a resize. - webView.autoresizingMask = [] + webView.autoresizingMask = [.width, .height] badgeHost = NSHostingView(rootView: PromptPreviewBadge(state: PromptPreviewState(), expanded: false)) - window = NSWindow(contentRect: NSRect(origin: .zero, size: Self.restSize), - styleMask: .borderless, backing: .buffered, defer: false) + window = PromptPreviewWindow(contentRect: NSRect(origin: .zero, size: Self.restSize), + styleMask: .borderless, backing: .buffered, defer: false) window.isOpaque = false window.backgroundColor = .clear // AppKit's window shadow is a soft, untunable bloom. Ours is drawn. window.hasShadow = false window.level = NSWindow.Level(Int(CGWindowLevelForKey(.normalWindow)) + 1) - // Click-through, like the rock's render surface: hover is discovered by - // the controller's pointer monitor, never by taking events away from - // the terminal. + // Click-through at rest, like the rock's render surface: hover is + // discovered by the controller's pointer monitor. Open, the card takes + // the pointer — see `setHovered`. window.ignoresMouseEvents = true + window.acceptsMouseMovedEvents = true window.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle] // The window is the whole viewport plus the room its shadow falls @@ -258,8 +273,17 @@ final class PromptPreview { card.layer?.masksToBounds = true card.layer?.cornerRadius = Self.cardRadius card.layer?.backgroundColor = NSColor.black.cgColor - webView.frame = card.bounds - card.addSubview(webView) + stage.wantsLayer = true + stage.frame = card.bounds + webView.frame = stage.bounds + stage.addSubview(webView) + card.addSubview(stage) + cover.imageScaling = .scaleAxesIndependently + cover.isHidden = true + cover.wantsLayer = true + cover.frame = card.bounds + cover.autoresizingMask = [.width, .height] + card.addSubview(cover) border.borderWidth = 1 border.cornerRadius = Self.cardRadius @@ -301,7 +325,7 @@ final class PromptPreview { // green arrow in the corner of a 128-point window is a control out of // reach, sitting on the piece it came to announce. The card takes the // deploy silently instead. - let url = "\(base)\(separator)nogap=true&nolabel=true&autoreload=true&maxfps=\(Self.restFPS)" + let url = "\(base)\(separator)nogap=true&nolabel=true&autoreload=true" guard let target = URL(string: url) else { return } webView.load(URLRequest(url: target)) } @@ -336,12 +360,28 @@ final class PromptPreview { /// top-left corner, so it opens *into* the pane rather than walking across /// the screen — and it opens onto the piece already running at the pane's /// own size, so nothing reframes, reloads or goes black on the way. + /// + /// Open, the card is live: it takes the pointer, and it takes the keyboard + /// too, so the piece under the pointer is the one being played. Closing + /// gives both back to whoever had them. func setHovered(_ hovering: Bool) { guard hovering != expanded else { return } expanded = hovering layoutCard(animated: true) redrawChrome() - syncKenBurns() + window.ignoresMouseEvents = !hovering + if hovering { + if !window.isVisible { window.orderFrontRegardless() } + let front = NSWorkspace.shared.frontmostApplication + if front?.processIdentifier != ProcessInfo.processInfo.processIdentifier { yieldTo = front } + NSApp.activate(ignoringOtherApps: true) + window.makeKey() + window.makeFirstResponder(webView) + } else { + if window.isKeyWindow { window.resignKey() } + if let back = yieldTo, !back.isTerminated { back.activate() } + yieldTo = nil + } } /// Park the card in the pane's top-left, under the title bar. `bounds` is @@ -370,9 +410,10 @@ final class PromptPreview { return CGSize(width: width, height: height) } - /// Size the window and the web view to the viewport. This is the only - /// place the web view changes size, and it happens only when the pane - /// does — which is when the piece would have reframed anyway. + /// Size the window to the viewport, and ask for the web view to follow — + /// later. Before anything is loaded the stage takes the size at once, since + /// there is no frame to protect; after that the reframe waits for the pane + /// to hold still (`settleResize`), and until then the stage is only scaled. private func layoutWindow() { viewport = Self.viewport(in: paneSize) let frame = NSRect(x: paneOrigin.x, @@ -380,31 +421,80 @@ final class PromptPreview { width: viewport.width + Self.shadowDrop, height: viewport.height + Self.shadowDrop) if window.frame != frame { window.setFrame(frame, display: false) } - if webView.frame.size != viewport { - webView.frame = NSRect(origin: webView.frame.origin, size: viewport) + guard stageSize != viewport else { settleTimer?.invalidate(); settleTimer = nil; return } + if loadedURL.isEmpty { + stage.frame = NSRect(origin: .zero, size: viewport) + return + } + settleTimer?.invalidate() + settleTimer = Timer.scheduledTimer(withTimeInterval: Self.resizeSettle, repeats: false) { [weak self] _ in + self?.settleResize() } } - /// The card's size right now: the whole viewport when open, the resting - /// card otherwise. Its top-left never moves. + /// The size the web view is actually rendering at. Equal to `viewport` + /// except during and just after a pane resize. + private var stageSize: CGSize { stage.frame.size } + + /// The one reframe a resize costs, taken under a snapshot of the frame the + /// card is showing right now. The snapshot is what the eye sees until the + /// piece has painted at the new size; the black the web view shows in + /// between happens underneath it. + private func settleResize() { + settleTimer = nil + let target = viewport + guard stageSize != target else { return } + let reframe = { [weak self] in + guard let self else { return } + self.stage.frame = NSRect(origin: .zero, size: target) + self.layoutCard(animated: false) + self.coverTimer?.invalidate() + self.coverTimer = Timer.scheduledTimer(withTimeInterval: Self.coverHold, repeats: false) { [weak self] _ in + self?.cover.isHidden = true + self?.cover.image = nil + self?.coverTimer = nil + } + } + guard window.isVisible else { reframe(); return } + webView.takeSnapshot(with: nil) { [weak self] image, _ in + guard let self else { return } + if let image { + self.cover.image = image + self.cover.isHidden = false + } + reframe() + } + } + + /// How far the stage is scaled: to fill the viewport when open (one, once + /// the web view has caught up with the pane), and otherwise the largest + /// scale at which the whole of it fits the resting card. + private var scale: CGFloat { + let s = stageSize + guard s.width > 0, s.height > 0 else { return 1 } + return expanded + ? min(viewport.width / s.width, viewport.height / s.height) + : min(1, Self.restSize.width / s.width, Self.restSize.height / s.height) + } + + /// The card's size right now: the stage at `scale` — the whole viewport + /// when open, a thumbnail with the piece's own proportions at rest, rather + /// than letterboxing it. Its top-left never moves. private var cardSize: CGSize { - expanded ? viewport - : CGSize(width: min(Self.restSize.width, viewport.width), - height: min(Self.restSize.height, viewport.height)) + let s = scale, size = stageSize + return CGSize(width: (size.width * s).rounded(), height: (size.height * s).rounded()) } - /// Fit the card, its shadow and its border to `cardSize`, and slide the - /// web view so the card shows the right part of it — everything when - /// open, the current crop when closed. Animated, the card unfolds over the - /// piece; the piece itself never changes size, which is what keeps the + /// Fit the card, its shadow and its border to `cardSize`, and scale the + /// stage so the whole viewport fills the card — at 1:1 when open, composited + /// down when closed. Animated, the card unfolds and the piece grows with + /// it; the web view itself never changes size, which is what keeps the /// unfolding free of the black frames a live resize costs. private func layoutCard(animated: Bool) { guard let content = window.contentView else { return } let size = cardSize let rect = NSRect(x: 0, y: content.bounds.height - size.height, width: size.width, height: size.height) - let crop = expanded ? CGPoint.zero : kenBurnsCrop(at: Date()) - let webOrigin = webOrigin(cardHeight: size.height, crop: crop) // The card is what everything else means by "the preview" — the pointer // test, the ownership test. Both read its final rect, not the frame // mid-animation, so a pointer that opened the card is inside it at once. @@ -415,12 +505,12 @@ final class PromptPreview { let shadowRect = NSRect(x: rect.minX + Self.shadowDrop, y: rect.minY - Self.shadowDrop, width: size.width, height: size.height) let borderRect = NSRect(origin: .zero, size: size) + let transform = stageTransform() if animated { NSAnimationContext.runAnimationGroup { context in context.duration = Self.openDuration context.timingFunction = CAMediaTimingFunction(name: .easeOut) card.animator().frame = rect - webView.animator().frame = NSRect(origin: webOrigin, size: viewport) } CATransaction.begin() CATransaction.setAnimationDuration(Self.openDuration) @@ -428,67 +518,44 @@ final class PromptPreview { shadow.frame = shadowRect border.frame = borderRect CATransaction.commit() + if let layer = stage.layer { + // A view's backing layer takes no implicit animation, so the + // scale is animated by hand — from wherever it is on screen + // right now, so a hover reversed mid-flight turns around + // rather than jumping to one end first. + let from = layer.presentation()?.transform ?? layer.transform + let grow = CABasicAnimation(keyPath: "transform") + grow.fromValue = NSValue(caTransform3D: from) + grow.toValue = NSValue(caTransform3D: transform) + grow.duration = Self.openDuration + grow.timingFunction = CAMediaTimingFunction(name: .easeOut) + layer.transform = transform + layer.add(grow, forKey: "scale") + } } else { card.frame = rect - webView.frame = NSRect(origin: webOrigin, size: viewport) CATransaction.begin() CATransaction.setDisableActions(true) shadow.frame = shadowRect border.frame = borderRect + stage.layer?.removeAnimation(forKey: "scale") + stage.layer?.transform = transform CATransaction.commit() } layoutBadge() } - /// Where the web view sits inside a card `cardHeight` tall so that the - /// crop's top-left (measured from the piece's top-left, the way a picture - /// is cropped) lands in the card's top-left. AppKit's origin is bottom-left, - /// so the top edges are aligned by lifting the view by the height it - /// overhangs, less the crop. - private func webOrigin(cardHeight: CGFloat, crop: CGPoint) -> NSPoint { - NSPoint(x: -crop.x, y: cardHeight - viewport.height + crop.y) - } - - /// The resting crop's position at `time`: a slow circle around the piece - /// at 1:1, the same lap the `#painting` embeds in chat.mjs take. Nothing - /// is scaled — the card is a window onto the piece, not a thumbnail of it. - private func kenBurnsCrop(at time: Date) -> CGPoint { - let size = cardSize - let maxX = max(0, viewport.width - size.width) - let maxY = max(0, viewport.height - size.height) - guard maxX > 0 || maxY > 0 else { return .zero } - let progress = (time.timeIntervalSince1970 / Self.kenBurnsCycle + kenBurnsSeed) - .truncatingRemainder(dividingBy: 1) - let panX = (cos((progress + 0.25) * .pi * 2) + 1) / 2 - let panY = (sin((progress + 0.65) * .pi * 2) + 1) / 2 - return CGPoint(x: (maxX * panX).rounded(), y: (maxY * panY).rounded()) - } - - /// The crop moves only while there is something to move over and someone - /// might see it: a closed card on screen. Open, hidden or too small to - /// crop, the timer is off. - private func syncKenBurns() { - let size = cardSize - let wants = window.isVisible && !expanded - && (viewport.width > size.width || viewport.height > size.height) - if wants { - guard kenBurnsTimer == nil else { return } - let timer = Timer(timeInterval: Self.kenBurnsInterval, repeats: true) { [weak self] _ in - self?.stepKenBurns() - } - timer.tolerance = Self.kenBurnsInterval / 4 - RunLoop.main.add(timer, forMode: .common) - kenBurnsTimer = timer - } else { - kenBurnsTimer?.invalidate() - kenBurnsTimer = nil - } - } - - private func stepKenBurns() { - guard !expanded else { return } - let origin = webOrigin(cardHeight: cardSize.height, crop: kenBurnsCrop(at: Date())) - if webView.frame.origin != origin { webView.setFrameOrigin(origin) } + /// Scale the stage about its own bottom-left, which is also the card's: + /// the layer scales about its centre, so the shrunken stage is slid back + /// into the corner by half of what it lost on each axis. Both states put + /// the stage's corner on the card's corner, which is what lets the two + /// animate together without either drifting. + private func stageTransform() -> CATransform3D { + let s = scale, size = stageSize + var t = CATransform3DMakeTranslation(-(size.width * (1 - s)) / 2, + -(size.height * (1 - s)) / 2, 0) + t = CATransform3DScale(t, s, s, 1) + return t } private func layoutBadge() { @@ -518,17 +585,17 @@ final class PromptPreview { if visible { if !window.isVisible { window.orderFrontRegardless() } } else if window.isVisible { - window.orderOut(nil) setHovered(false) + window.orderOut(nil) } - syncKenBurns() } var isOnScreen: Bool { window.isVisible } func close() { - kenBurnsTimer?.invalidate() - kenBurnsTimer = nil + setHovered(false) + settleTimer?.invalidate() + coverTimer?.invalidate() webView.stopLoading() // Point the view at nothing before tearing down: a WKWebView left // holding a running page keeps its content process alive past the @@ -537,3 +604,11 @@ final class PromptPreview { window.orderOut(nil) } } + +/// A borderless window that can take the keyboard. AppKit's borderless +/// windows refuse key status by default, which is right for a card that is +/// only looked at and wrong for one being played. +final class PromptPreviewWindow: NSWindow { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { false } +} diff --git a/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift b/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift index bc8eda6b20..72eadc93f0 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift @@ -2156,6 +2156,14 @@ final class PromptSigilOverlayController { if let move = NSEvent.addGlobalMonitorForEvents(matching: .mouseMoved, handler: { [weak self] _ in self?.handleMouseMoved() }) { mouseMonitors.append(move) } + // An open preview card takes the pointer, and a global monitor is not + // told about moves over Slab's own windows — so without this twin the + // card would never learn the pointer had left it. + if let localMove = NSEvent.addLocalMonitorForEvents(matching: .mouseMoved, handler: { + [weak self] event in + self?.handleMouseMoved() + return event + }) { mouseMonitors.append(localMove) } // Clicks received by Slab's own non-activating card panel do not reach // a global monitor. Keep a local twin so card → native share is // reliable regardless of which side of macOS's event routing wins. diff --git a/system/public/aesthetic.computer/bios.mjs b/system/public/aesthetic.computer/bios.mjs index e99ff0f078..41a43bc728 100644 --- a/system/public/aesthetic.computer/bios.mjs +++ b/system/public/aesthetic.computer/bios.mjs @@ -987,6 +987,9 @@ async function boot(parsed, bpm = 60, resolution, debug) { updateAutoReload = true; } if (resolution.shellhtml === true) preservedParams.shellhtml = "true"; + // The frame-rate cap is the embedding's too: a preview card that reloads + // without it runs at display rate, which is the one thing it was told not to do. + if (resolution.maxfps) preservedParams.maxfps = String(resolution.maxfps); if (resolution.tv === true) preservedParams.tv = "true"; if (resolution.device === true) preservedParams.device = "true"; if (resolution.solo === true) preservedParams.solo = "true"; @@ -14519,6 +14522,15 @@ async function boot(parsed, bpm = 60, resolution, debug) { dawParams.set(param, currentParams.get(param)); } } + // The embedding's own flags ride along too — what boot handed over + // as `preservedParams` (nolabel, autoreload, maxfps, …). This is the + // URL the update auto-reload comes back to, so a flag dropped here + // is a flag the next load never sees: a preview card that asked + // to be rid of the corner label got it back on its first reload + // exactly this way, while `nogap`, listed above, survived. + for (const [name, value] of Object.entries(preservedParams || {})) { + if (value && !dawParams.has(name)) dawParams.set(name, value); + } const queryString = dawParams.toString(); // Keep caret "bag" URLs literal (^pads): ^ is legal in a URL path per // WHATWG, but the omnibox encodes it to %5E on first navigation. Decode -- 2.51.2