From 89aa6767f9e161df0472781a9ae2b4e07ff9b015 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 11 Jul 2026 20:40:03 +0200 Subject: [PATCH] feat(resize): scrim+spinner resize overlay in the shared presenter (Windows + GTK4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mid-stream Match-window trigger + Resolution tri-state already shipped on BOTH desktop clients via the session-always punktfunk-session binary (pf-presenter D1/D2, C1). This ports the last Apple-parity piece — the resize-in-progress indicator (clients/apple ResizeIndicator/ResizeIndicatorView) — into the SHARED pf-presenter overlay, so one implementation covers both the Windows and GTK4 session windows. - ResizeIndicator (run.rs): the Apple state machine in Rust — `steering` (a switch was requested) shows it, `decoded` (a frame reached the target size) clears it, `tick` times it out after 2.5 s for a switch the host rejected/capped. The live drag stays sharp; only the host's 0.3-2 s virtual-display + encoder rebuild gap is covered. A present-while-resizing path keeps the spinner animating through that frame-less gap. - DecodedImage::dimensions() (pf-client-core): the END signal — a decoded frame at the target size means the sharp new-mode picture is on glass (the accept ack alone lands ahead of the host's rebuild). Mirrors is_keyframe()'s cfg arms. - FrameCtx.resizing (pf-presenter/overlay.rs) + Skia draw (pf-console-ui): a full-screen 55% scrim + the shared rotating theme::spinner + "Resizing…" label. The overlay composites its own RGBA quad and can't sample the video to blur it as SwiftUI does, so a scrim stands in for the blur — same intent, one draw. resizing_since clocks the spinner; Drawn.resize_step defeats the damage gate so it redraws each frame. Verified on Linux: pf-presenter/pf-console-ui/session/linux-client build + clippy -D warnings clean; 8 pf-presenter tests green incl. 2 new ResizeIndicator tests. Windows session-binary compile (cfg-symmetric) + live on-glass both pending. Co-Authored-By: Claude Opus 4.8 --- crates/pf-client-core/src/video.rs | 14 ++ crates/pf-console-ui/src/skia_overlay.rs | 61 ++++++++- crates/pf-presenter/src/overlay.rs | 5 + crates/pf-presenter/src/run.rs | 156 ++++++++++++++++++++++- 4 files changed, 228 insertions(+), 8 deletions(-) diff --git a/crates/pf-client-core/src/video.rs b/crates/pf-client-core/src/video.rs index da0103dd..a2915917 100644 --- a/crates/pf-client-core/src/video.rs +++ b/crates/pf-client-core/src/video.rs @@ -183,6 +183,20 @@ impl DecodedImage { DecodedImage::D3d11(f) => f.keyframe, } } + + /// The decoded image's pixel dimensions. The presenter's resize indicator uses these + /// as the mid-stream-resize END signal: a frame arriving at the target size means the + /// new-mode picture is on glass (the ack alone lands before the host's rebuild does). + pub fn dimensions(&self) -> (u32, u32) { + match self { + DecodedImage::Cpu(f) => (f.width, f.height), + #[cfg(target_os = "linux")] + DecodedImage::Dmabuf(f) => (f.width, f.height), + DecodedImage::VkFrame(f) => (f.width, f.height), + #[cfg(windows)] + DecodedImage::D3d11(f) => (f.width, f.height), + } + } } /// The Y′CbCr→RGB conversion as three vec4 rows for a shader constant buffer / push-constant diff --git a/crates/pf-console-ui/src/skia_overlay.rs b/crates/pf-console-ui/src/skia_overlay.rs index d2cd942a..ca489991 100644 --- a/crates/pf-console-ui/src/skia_overlay.rs +++ b/crates/pf-console-ui/src/skia_overlay.rs @@ -50,6 +50,10 @@ struct Drawn { hint: Option, /// The start banner's alpha, quantized — a fade step is a redraw, steady is not. banner_step: u8, + /// The resize scrim's spinner phase, quantized — a nonzero, ever-changing step while a + /// mid-stream resize is in flight forces the per-frame redraw the spinner needs; `0` + /// when no resize is showing (so a still stream stays damage-free). + resize_step: u16, } /// Where the console starts (the session binary's `--browse` forms). @@ -85,6 +89,9 @@ pub struct SkiaOverlay { streaming_since: Option, /// The banner's words (set per stream from the active-pad state). banner_text: Option, + /// When the current mid-stream resize scrim began showing — drives its spinner phase. + /// `None` = no resize in flight (`FrameCtx::resizing` was false last frame). + resizing_since: Option, } struct Gpu { @@ -114,6 +121,7 @@ impl SkiaOverlay { shell: None, streaming_since: None, banner_text: None, + resizing_since: None, } } @@ -340,10 +348,15 @@ impl Overlay for SkiaOverlay { })); } - // --- Stream chrome: stats OSD + capture hint + the start banner --------------- + // --- Stream chrome: stats OSD + capture hint + start banner + resize scrim ----- let banner_alpha = self.banner_alpha(ctx); let banner_step = (banner_alpha * 32.0).round() as u8; - if ctx.stats.is_none() && ctx.hint.is_none() && banner_step == 0 { + let resize_phase = self.resize_phase(ctx); + // 120 steps/s: every ~16 ms frame lands on a fresh step, so the spinner keeps + // spinning through the damage gate; `+ 1` keeps an active resize's step nonzero + // even on its first frame (phase 0) so the guard below doesn't skip it. + let resize_step = resize_phase.map_or(0, |p| (p * 120.0) as u16 + 1); + if ctx.stats.is_none() && ctx.hint.is_none() && banner_step == 0 && resize_step == 0 { self.drawn = Drawn::default(); // forget content so re-show re-renders return Ok(None); } @@ -353,6 +366,7 @@ impl Overlay for SkiaOverlay { stats: ctx.stats.map(str::to_owned), hint: ctx.hint.map(str::to_owned), banner_step, + resize_step, }; if want == self.drawn { // Unchanged — hand the presenter the already-rendered image. @@ -374,6 +388,10 @@ impl Overlay for SkiaOverlay { let canvas = slot.surface.canvas(); canvas.clear(Color4f::new(0.0, 0.0, 0.0, 0.0)); let font = self.font.as_ref().expect("init ran"); + // The resize scrim sits UNDER the OSD/hint so those stay legible over it. + if let Some(phase) = resize_phase { + draw_resize_scrim(canvas, font, ctx.width, ctx.height, phase); + } if let Some(stats) = &want.stats { draw_osd_panel(canvas, font, stats, 12.0, 12.0); } @@ -445,6 +463,18 @@ impl SkiaOverlay { ((BANNER_S - age) / BANNER_FADE_S).min(1.0) } + /// The mid-stream-resize spinner's phase (elapsed seconds since the scrim came up), or + /// `None` when no resize is in flight. Latches the start on the first `resizing` frame + /// and clears it the moment the run loop drops the flag (the target frame landed or the + /// switch timed out), so the next resize starts its spinner from zero. + fn resize_phase(&mut self, ctx: &FrameCtx) -> Option { + if !ctx.resizing { + self.resizing_since = None; + return None; + } + Some(self.resizing_since.get_or_insert_with(Instant::now).elapsed().as_secs_f64()) + } + /// Make `slots[i]` a render target of exactly `width`×`height` (rebuilt on resize). fn ensure_slot(&mut self, i: usize, width: u32, height: u32) -> Result<()> { if self.slots[i] @@ -540,6 +570,33 @@ fn draw_osd_panel(canvas: &Canvas, font: &Font, text: &str, x: f32, y: f32) { } } +/// The mid-stream-resize cover: a full-screen dark scrim, the shared rotating spinner, and +/// a "Resizing…" label centered over it — so the host's 0.3–2 s virtual-display + encoder +/// rebuild reads as a deliberate pause rather than the stream stretching to the changed +/// window. This is the presenter's analog of the Apple client's blur overlay: the overlay +/// composites its own RGBA quad and cannot sample the video to blur it, so an opaque scrim +/// hides the stretched in-between frame instead (same intent, one draw). +fn draw_resize_scrim(canvas: &Canvas, font: &Font, width: u32, height: u32, phase: f64) { + let (wf, hf) = (width as f32, height as f32); + canvas.draw_rect( + Rect::from_wh(wf, hf), + &Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.55), None), + ); + // Spinner slightly above center; the label sits below it. + let (cx, cy) = (f64::from(width) / 2.0, f64::from(height) / 2.0); + let r = (f64::from(width.min(height)) * 0.045).clamp(16.0, 44.0); + crate::theme::spinner(canvas, cx, cy - r, r, phase); + let (_, metrics) = font.metrics(); + let label = "Resizing\u{2026}"; + let tw = font.measure_str(label, None).0; + canvas.draw_str( + label, + Point::new((wf - tw) / 2.0, (cy + r * 0.9) as f32 - metrics.ascent), + font, + &Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.9), None), + ); +} + /// The capture hint / start banner: a centered pill near the bottom edge. fn draw_hint_pill(canvas: &Canvas, font: &Font, text: &str, width: u32, height: u32, alpha: f32) { let (_, metrics) = font.metrics(); diff --git a/crates/pf-presenter/src/overlay.rs b/crates/pf-presenter/src/overlay.rs index 31e64bbe..c2f6e8a3 100644 --- a/crates/pf-presenter/src/overlay.rs +++ b/crates/pf-presenter/src/overlay.rs @@ -37,6 +37,11 @@ pub struct FrameCtx<'a> { pub stats: Option<&'a str>, /// The capture hint (bottom-center pill, "click to capture…"); `None` = hidden. pub hint: Option<&'a str>, + /// A mid-stream Match-window resize is in flight (design/midstream-resolution-resize.md, + /// client UX): draw a full-screen scrim + spinner so the host's 0.3–2 s virtual-display + /// and encoder rebuild reads as an intentional pause rather than the stream stretching to + /// the changed window. Cleared the instant the sharp new-resolution frame is on glass. + pub resizing: bool, /// The active gamepad's name (the console library's controller chip). pub pad: Option<&'a str>, /// The active pad's resolved kind — drives the console UI's button glyphs diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs index d2113ba8..5b3134e7 100644 --- a/crates/pf-presenter/src/run.rs +++ b/crates/pf-presenter/src/run.rs @@ -201,6 +201,9 @@ struct StreamState { /// The connector mode last shown in the HUD/title — a change (an accepted switch's /// ack, or a corrective rollback) refreshes both. shown_mode: Option, + /// Resize-in-progress overlay (scrim + spinner) — armed by [`resize_tick`] when it + /// requests a switch, cleared when a decoded frame reaches the target (or on timeout). + resize_overlay: ResizeIndicator, } impl StreamState { @@ -249,6 +252,7 @@ impl StreamState { resize_sent_at: None, resize_requested: None, shown_mode: None, + resize_overlay: ResizeIndicator::default(), } } @@ -808,6 +812,11 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result resize_tick(st, &mut window, &opts.window_title, persist.as_mut()); } } + // Resize overlay timeout: a switch the host rejected/capped never delivers the exact + // target frame — drop the scrim so it can't linger. A no-op unless one is showing. + if let Some(st) = stream.as_mut() { + st.resize_overlay.tick(Instant::now()); + } // --- Console UI: damage-driven overlay re-render for this iteration -------------- if let Some(o) = overlay.as_mut() { @@ -832,11 +841,15 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result }; let pad = gamepad.active(); let pads = gamepad.pads(); + let resizing = stream + .as_ref() + .is_some_and(|st| st.connector.is_some() && st.resize_overlay.active()); let ctx = FrameCtx { width: pw, height: ph, stats, hint, + resizing, pad: pad.as_ref().map(|p| p.name.as_str()), pad_pref: pad.as_ref().map(|p| p.pref), pads: &pads, @@ -870,6 +883,10 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result newest = Some(f); } if let Some(f) = newest { + // Resize END: a frame at the steered target size means the sharp new-mode + // picture is here — lift the scrim. A no-op unless a switch is in flight. + let (fw, fh) = f.image.dimensions(); + st.resize_overlay.decoded(fw, fh); let DecodedFrame { pts_ns, decoded_ns, @@ -1047,12 +1064,15 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result } } - // Browse with no video driving presents (library / connecting): composite the - // overlay every iteration — FIFO vsync-throttles this to the display rate. - if matches!(mode, ModeCtl::Browse(_)) - && !presented_video - && stream.as_ref().is_none_or(|s| s.connector.is_none()) - { + // Composite the overlay every iteration when no video frame drove a present but + // something on-screen still animates: browse-idle (library / connecting), OR a + // mid-stream resize scrim + spinner (the host's virtual-display + encoder rebuild + // leaves a gap with no frames — without this the spinner would freeze). FIFO + // vsync-throttles this to the display rate; the 15 ms wait keeps it smooth. + let resize_scrim = stream.as_ref().is_some_and(|s| s.resize_overlay.active()); + let browse_idle = matches!(mode, ModeCtl::Browse(_)) + && stream.as_ref().is_none_or(|s| s.connector.is_none()); + if !presented_video && (resize_scrim || browse_idle) { presenter.present(&window, FrameInput::Redraw, overlay_frame.as_ref())?; } }; @@ -1141,6 +1161,9 @@ fn resize_tick( } st.resize_requested = Some((w, h)); st.resize_sent_at = Some(Instant::now()); + // Show the scrim + spinner until a frame at this size lands (or the timeout): + // the live drag itself stays sharp; only the host's rebuild gap is covered. + st.resize_overlay.steering(w, h, Instant::now()); } } } @@ -1188,6 +1211,71 @@ fn resize_decision( ResizeAction::Settled(Some(target)) } +/// Resize-in-progress overlay state (design/midstream-resolution-resize.md — client UX), +/// ported from the Apple client's `ResizeIndicator`. A mid-stream Match-window switch takes +/// the host 0.3–2 s to rebuild its virtual display + encoder, and the first new-mode frame +/// is an IDR the decoder re-inits on. Rather than let the stream stretch to the changed +/// window during that gap, the presenter EMBRACES the delay: a deliberate scrim + spinner +/// the instant a switch is requested, cleared the instant the sharp new-resolution frame is +/// on screen — so the wait reads as intentional, not as lag. +/// +/// Driven entirely by signals the presenter already has (no new protocol): +/// * START — [`resize_tick`] reports the size it just requested (`steering`). +/// * END — the decode pipeline reports each frame's dimensions; when they reach the +/// target the new picture is here (`decoded`). The accepted-switch ack alone can't +/// end it: the ack round-trips in milliseconds, ahead of the host's rebuild. +/// * TIMEOUT — the safety net for a switch that never delivers the exact target (a +/// gamescope reject, an advertised-mode cap, or a corrective ack landing a different +/// size); `tick` clears it after [`ResizeIndicator::TIMEOUT`]. +/// +/// Pure + clock-injected so the transition logic is unit-tested without a live session. +#[derive(Default)] +struct ResizeIndicator { + /// The size the follower is steering toward — cleared once a decoded frame reaches it. + /// `Some` ⇔ the scrim + spinner should be shown. + target: Option<(u32, u32)>, + /// When the current active span began — the timeout is measured from here. + since: Option, +} + +impl ResizeIndicator { + /// How long to keep the overlay up if the target frame never arrives. + const TIMEOUT: Duration = Duration::from_millis(2500); + + /// Whether the scrim + spinner should be shown. + fn active(&self) -> bool { + self.target.is_some() + } + + /// A switch to `w`×`h` was just requested — show the overlay now. The timeout re-arms + /// only when the target actually changes, so a drag that walks through several sizes + /// (each its own request) never trips the timeout mid-gesture. + fn steering(&mut self, w: u32, h: u32, now: Instant) { + if self.target != Some((w, h)) { + self.since = Some(now); + } + self.target = Some((w, h)); + } + + /// A decoded frame arrived at `w`×`h`. Clears the overlay once it matches the steered + /// target — the sharp new-resolution picture is on glass. + fn decoded(&mut self, w: u32, h: u32) { + if self.target == Some((w, h)) { + self.target = None; + self.since = None; + } + } + + /// Timeout safety net: stop showing once [`TIMEOUT`](Self::TIMEOUT) has elapsed with no + /// matching frame (a rejected or host-capped switch never delivers the exact target). + fn tick(&mut self, now: Instant) { + if self.since.is_some_and(|s| now.duration_since(s) >= Self::TIMEOUT) { + self.target = None; + self.since = None; + } + } +} + /// Apply the capture state to the window: pointer lock (relative mouse + hidden cursor) /// and — on Windows — a keyboard grab, so system chords (Alt+Tab, the Windows key) reach /// the host while captured instead of the local shell. SDL implements the grab there @@ -1389,6 +1477,62 @@ mod tests { ); } + #[test] + fn resize_indicator_shows_until_the_target_frame_or_timeout() { + let t0 = Instant::now(); + let ms = Duration::from_millis; + + // Idle at rest. + let mut ind = ResizeIndicator::default(); + assert!(!ind.active()); + + // A requested switch shows the overlay immediately. + ind.steering(1000, 600, t0); + assert!(ind.active()); + + // A frame at a DIFFERENT size (a stale old-mode frame still draining) doesn't lift it. + ind.decoded(1280, 720); + assert!(ind.active(), "an off-target frame keeps the scrim up"); + + // The sharp new-resolution frame arrives → cleared. + ind.decoded(1000, 600); + assert!(!ind.active(), "the target frame lifts the scrim"); + ind.tick(t0 + ms(10_000)); // a late tick after clearing is inert + assert!(!ind.active()); + + // A switch whose target frame never arrives (rejected / host-capped) times out. + let mut ind = ResizeIndicator::default(); + ind.steering(1000, 600, t0); + ind.tick(t0 + ResizeIndicator::TIMEOUT - ms(1)); + assert!(ind.active(), "still within the timeout window"); + ind.tick(t0 + ResizeIndicator::TIMEOUT); + assert!(!ind.active(), "timeout lifts a switch that never delivered"); + } + + #[test] + fn resize_indicator_retargets_and_rearms_the_timeout_mid_drag() { + let t0 = Instant::now(); + let ms = Duration::from_millis; + + // A drag that walks through sizes (each a fresh request) re-arms the timeout, so a + // slow gesture never trips it: at t0 steer A, then near-timeout steer B, then a B + // frame lands well after A's timeout would have fired. + let mut ind = ResizeIndicator::default(); + ind.steering(1000, 600, t0); + let near = t0 + ResizeIndicator::TIMEOUT - ms(1); + ind.steering(1200, 700, near); // new target → timeout re-armed from `near` + ind.tick(t0 + ResizeIndicator::TIMEOUT + ms(1)); // past A's window, within B's + assert!(ind.active(), "retarget re-armed the timeout — no mid-drag flicker"); + + // Re-steering the SAME size does NOT re-arm (so a repeated identical request can't + // hold the scrim open forever). + let mut ind = ResizeIndicator::default(); + ind.steering(1000, 600, t0); + ind.steering(1000, 600, t0 + ms(500)); // same target, later — `since` unchanged + ind.tick(t0 + ResizeIndicator::TIMEOUT); + assert!(!ind.active(), "an unchanged target keeps the original timeout"); + } + fn sample() -> (Stats, PresentedWindow) { ( Stats {