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