feat(resize): scrim+spinner resize overlay in the shared presenter (Windows + GTK4)
ci / docs-site (push) Failing after 29s
ci / web (push) Successful in 43s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
decky / build-publish (push) Successful in 33s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 7s
docker / deploy-docs (push) Successful in 20s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m52s
apple / swift (push) Successful in 4m55s
ci / bench (push) Successful in 6m37s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m2s
arch / build-publish (push) Successful in 12m42s
android / android (push) Successful in 12m51s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m7s
deb / build-publish (push) Successful in 13m4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m24s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m56s
apple / screenshots (push) Successful in 19m31s
ci / rust (push) Failing after 39s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 1m4s
flatpak / build-publish (push) Failing after 8m4s
ci / docs-site (push) Failing after 29s
ci / web (push) Successful in 43s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
decky / build-publish (push) Successful in 33s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 7s
docker / deploy-docs (push) Successful in 20s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m52s
apple / swift (push) Successful in 4m55s
ci / bench (push) Successful in 6m37s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m2s
arch / build-publish (push) Successful in 12m42s
android / android (push) Successful in 12m51s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m7s
deb / build-publish (push) Successful in 13m4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m24s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m56s
apple / screenshots (push) Successful in 19m31s
ci / rust (push) Failing after 39s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 1m4s
flatpak / build-publish (push) Failing after 8m4s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -50,6 +50,10 @@ struct Drawn {
|
||||
hint: Option<String>,
|
||||
/// 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<Instant>,
|
||||
/// The banner's words (set per stream from the active-pad state).
|
||||
banner_text: Option<String>,
|
||||
/// 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<Instant>,
|
||||
}
|
||||
|
||||
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<f64> {
|
||||
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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Mode>,
|
||||
/// 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<Option<Outcome>
|
||||
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<Option<Outcome>
|
||||
};
|
||||
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<Option<Outcome>
|
||||
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<Option<Outcome>
|
||||
}
|
||||
}
|
||||
|
||||
// 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<Instant>,
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user