crates/veilvoice-gui/src/soundbar.rs

what this file is for · veilvoice-gui · 779 lines · the same file on GitHub

The file as it is in the tree, in the colours you chose. A line number is a link, and so is every box in this file’s diagram: it opens here with the function it names marked.


// SPDX-License-Identifier: GPL-3.0-or-later
//! The animated mark: a row of bars that rise and fall.
//!
//! # The same mark as the website
//!
//! `website/index.html` draws this in CSS as `.veil` -- a row of `<span>`s with
//! `@keyframes pulse` taking each between 16% and 82% of the height over
//! 1.9 seconds, each with its own delay so the row ripples rather than pumping
//! in unison. The left half is drawn in the accent colour and the right half in
//! the "veiled" secondary, which is the product in one picture and matches the
//! icon.
//!
//! This is that, in egui, with the same period, the same height range and the
//! same delays. Two front-ends showing visibly different marks would be worse
//! than one showing none.
//!
//! # Why it is drawn rather than rendered from a GIF
//!
//! An animated image would be a committed binary blob, and this project's
//! artwork is generated from source precisely so that nothing in the repository
//! has to be taken on trust. Sixty lines of shape drawing is auditable; a GIF
//! is not.
//!
//! # Cost when it is switched off
//!
//! With motion disabled the bars are drawn once, at rest, and **no repaint is
//! requested**. That is the part that matters: an "off" switch that still
//! schedules a frame every 16 ms has turned the animation off visually and left
//! the battery cost behind. The caller decides by passing a [`Motion`], and the
//! only way to animate is to ask for it.
//!
//! # Cost when it is switched on, which is the interesting one
//!
//! Motion is on by default, and this is the only thing in the application that
//! moves without being asked to. Everything else draws when something happens.
//! So with the default settings, on the file tab, doing nothing, the window was
//! redrawing about sixty times a second for ever, and it was this: measured
//! with `Context::repaint_causes`, which named line 117 of this file as the
//! reason for 559 of 566 frames.
//!
//! An animated logo is not worth a permanently busy window, and two of the
//! costs are ones a user actually notices rather than ones a profiler does.
//! A laptop lid left open at this screen never lets the processor idle. And a
//! window being dragged is competing, every frame, with a full redraw it did
//! not need, which is what "it lags when I move it" is made of.
//!
//! So the mark now moves in the three circumstances where somebody can see it
//! moving, and rests otherwise:
//!
//! - **Not while the window is unfocused.** A background window is still.
//! - **Not while the window is being moved or resized.** Detected from the
//!   window's own rectangle changing between frames, and resumed a quarter of
//!   a second after it stops. This is the drag case specifically.
//! - **Not faster than the window's frame target.** [`crate::pace`] owns that
//!   number: the display's own rate by default, or whatever was chosen in
//!   Settings. This module carried its own constant of twenty a second, which
//!   made the mark the slowest-moving thing on any modern display and was the
//!   judder people reported (finding F-179).
//!
//! Resting is not the same as resetting. Freezing at the midpoint would make
//! every click into another window snap the row flat, so a paused mark holds
//! the shape it had when it paused and picks the cycle up from there.
//!
//! # In plain words
//!
//! The little row of bars in the corner that rises and falls.
//!
//! It is the same mark the website uses, drawn rather than loaded as a picture, so
//! it takes the colours of whichever scheme you have chosen.
//!
//! It stops moving if your system is set to reduce motion. That setting is a
//! request from somebody who has a reason for making it, and animation that
//! ignores it is animation that makes an application unusable for them.

use crate::prefs::Motion;
use crate::theme::palette as p;
use egui::{Color32, CornerRadius, Rect, Sense, Ui, Vec2};


/// Seconds for one full rise and fall. Matches the website's `1.9s`.
const PERIOD: f32 = 1.9;



/// How long the window must hold still before the mark starts moving again.
///
/// A drag delivers a new window rectangle every few milliseconds, so anything
/// shorter than this restarts the animation between two frames of the same
/// drag and achieves nothing. A quarter of a second is below the point where
/// somebody notices the mark was waiting.
const SETTLE: f64 = 0.25;



/// Per-bar phase offsets in seconds, matching the `animation-delay` values in
/// `website/index.html`. Twelve bars, deliberately not in order, so the row
/// ripples instead of sweeping.
const DELAYS: [f32; 12] = [

    0.000, 0.120, 0.240, 0.080, 0.300, 0.180, 0.060, 0.420, 0.220, 0.500, 0.140, 0.360,
];


/// Height as a fraction of the available box, matching `16%` and `82%`.
const MIN_FRACTION: f32 = 0.16;


const MAX_FRACTION: f32 = 0.82;



/// How far along its cycle a bar is, in 0..=1, eased the way CSS
/// `ease-in-out` eases.
fn height_fraction(time: f32, delay: f32) -> f32 {
    // `rem_euclid` so a negative time -- which `Context::input().time` will not
    // produce, but a test can -- still lands inside the period rather than
    // producing a negative phase.
    let phase = (time + delay).rem_euclid(PERIOD) / PERIOD;
    // A triangle wave, then smoothstep, which is close enough to
    // `ease-in-out` that no one could pick them apart side by side.
    let triangle = 1.0 - (phase * 2.0 - 1.0).abs();
    let eased = triangle * triangle * (3.0 - 2.0 * triangle);
    MIN_FRACTION + (MAX_FRACTION - MIN_FRACTION) * eased
}



/// Whether the window is holding still enough for the mark to move.
///
/// Two questions, both asked of the window rather than of the application:
/// does it have focus, and has its rectangle stopped changing.
///
/// The focus answer defaults to *yes* when the platform does not report one.
/// A mark frozen for ever on a system that never says "focused" is a worse
/// failure than one that animates when it did not have to, and there are
/// window managers that do not send focus events at all.
///
/// The rectangle answer is how a drag is detected. There is no "the user is
/// dragging me" signal in `egui` or `winit` to ask for; there is the window's
/// own position and size, which changes on every frame of a drag and on no
/// frame of anything else. Stored in `egui`'s temporary memory, so it lives
/// exactly as long as the context and costs no field on any struct.
fn window_is_settled(ctx: &egui::Context) -> bool {
    let focused = ctx.input(|i| i.viewport().focused).unwrap_or(true);
    if !focused {
        return false;
    }

    let id = egui::Id::new("veilvoice-soundbar-window-rect");
    let (rect, now) = ctx.input(|i| (i.viewport().outer_rect, i.time));
    let Some(rect) = rect else {
        // A platform that does not report the window rectangle cannot be
        // asked whether it is moving. Treat it as still rather than never
        // animating there.
        return true;
    };

    let last: Option<(egui::Rect, f64)> = ctx.memory(|m| m.data.get_temp(id));
    let moved_at = match last {
        Some((was, when)) if was == rect => when,
        _ => now,
    };
    ctx.memory_mut(|m| m.data.insert_temp(id, (rect, moved_at)));

    let settled = now - moved_at >= SETTLE;
    if !settled {
        // Come back and look again once it could have settled, or the mark
        // stays frozen after the drag ends until something else moves.
        ctx.request_repaint_after(std::time::Duration::from_secs_f64(SETTLE));
    }
    settled
}



/// The clock the bars are drawn against, which is not always the real one.
///
/// While the mark is moving this is the application clock. While it is resting
/// it is the moment it stopped, held in temporary memory, so the row keeps the
/// shape it had rather than snapping to the midpoint the instant the window
/// loses focus. Clicking away from the window and back should not make the
/// logo jump.
fn animation_clock(ctx: &egui::Context, moving: bool, time: f32) -> f32 {
    let id = egui::Id::new("veilvoice-soundbar-clock");
    if moving {
        ctx.memory_mut(|m| m.data.insert_temp(id, time));
        time
    } else {
        ctx.memory(|m| m.data.get_temp(id)).unwrap_or(time)
    }
}



/// Draw the mark at `size`, returning the response so it can carry a tooltip.
///
/// `time` is the application clock in seconds. When `motion` disallows
/// movement every bar is drawn at its resting height and nothing is scheduled.
/// When motion is allowed but the window is unfocused or being dragged, the
/// bars hold their last shape and nothing is scheduled either.
/// The application mark: the bars inside the rounded square the icon uses.
///
/// The lock screen drew the bars alone, which is the header's treatment and
/// reads there because the header is full of other VeilVoice furniture. On an
/// otherwise empty locked window it read as a stray animation rather than as
/// the program identifying itself, so this puts them back in the badge the
/// icon puts them in, and a locked window now shows the logo.
///
/// Drawn rather than decoded, like everything else in this module. The icon on
/// disk is 32x32, and blowing that up to the size wanted here would be visibly
/// soft; the same shape as vectors is crisp at any size and adds no image
/// decoder to the application.
pub fn badge(ui: &mut Ui, side: f32, motion: Motion, time: f32) -> egui::Response {
    let (rect, response) = ui.allocate_exact_size(Vec2::splat(side), Sense::hover());
    if !ui.is_rect_visible(rect) {
        return response;
    }

    // The icon's proportions: a two-pixel border on a 32-pixel square, corners
    // cut by one. Kept as fractions so it scales.
    let radius = CornerRadius::same((side * 0.19).round().min(255.0) as u8);
    ui.painter()
        .rect_filled(rect, radius, crate::theme::palette::bg_dark());
    ui.painter().rect_stroke(
        rect,
        radius,
        egui::Stroke::new((side * 0.055).max(1.0), crate::theme::palette::border()),
        egui::StrokeKind::Inside,
    );

    // The bars sit in the middle two thirds, as they do in the icon.
    let inner = Rect::from_center_size(rect.center(), Vec2::new(side * 0.66, side * 0.52));
    let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner));
    draw(&mut child, inner.size(), motion, time);

    response
}



/// Draw the mark at `size`, returning the response so it can carry a tooltip.
///
/// `time` is the application clock in seconds. When `motion` disallows
/// movement every bar is drawn at its resting height and nothing is scheduled.
/// When motion is allowed but the window is unfocused or being dragged, the
/// bars hold their last shape and nothing is scheduled either.
pub fn draw(ui: &mut Ui, size: Vec2, motion: Motion, time: f32) -> egui::Response {
    let (rect, response) = ui.allocate_exact_size(size, Sense::hover());
    if !ui.is_rect_visible(rect) {
        return response;
    }

    // Asked once per draw, before any bar is positioned, so every bar in the
    // row agrees about which frame this is.
    let moving = motion.icon && window_is_settled(ui.ctx());
    let time = animation_clock(ui.ctx(), moving, time);

    let bars = DELAYS.len();
    // A gap of a quarter of a bar's width, as on the website (5 px bar, 3 px
    // gap is close to a third, but a quarter reads better at icon sizes).
    let unit = rect.width() / (bars as f32 * 1.25);
    let bar_width = unit;
    let gap = unit * 0.25;
    let total = bars as f32 * bar_width + (bars as f32 - 1.0) * gap;
    let left = rect.center().x - total / 2.0;

    let painter = ui.painter();
    let rounding = CornerRadius::same((bar_width * 0.4).min(2.0) as u8);

    for (index, delay) in DELAYS.iter().enumerate() {
        // At rest every bar sits at the midpoint, so a still mark reads as a
        // deliberate shape rather than as an animation caught mid-frame.
        let fraction = if motion.icon {
            height_fraction(time, *delay)
        } else {
            (MIN_FRACTION + MAX_FRACTION) / 2.0
        };
        let height = rect.height() * fraction;
        let x = left + index as f32 * (bar_width + gap);
        let bar = Rect::from_min_size(
            egui::pos2(x, rect.center().y - height / 2.0),
            egui::vec2(bar_width, height),
        );
        painter.rect_filled(bar, rounding, colour_for(index, bars));
    }

    // Only ask for another frame if something will actually differ in it.
    // `moving`, not `motion.icon`: an unfocused or dragging window is not
    // going to look different next frame, and asking anyway is how a window
    // that appears to be doing nothing keeps a processor busy.
    if moving {
        crate::pace::next_frame(ui.ctx());
    }

    response
}



/// The left half in the accent colour, the right in the veiled secondary --
/// the same split the website and the icon use.
fn colour_for(index: usize, bars: usize) -> Color32 {
    if index * 2 < bars {
        p::blue()
    } else {
        // `0.85` matches the website's `opacity: 0.85` on the veiled half,
        // applied as a blend towards the background so it does not depend on
        // what is painted behind it.
        p::blend(p::bg(), p::purple(), 0.85)
    }
}


#[cfg(test)]
mod tests {
    use super::*;
    use crate::prefs::Prefs;

    fn moving() -> Motion {
        Motion::resolve(
            &Prefs {
                animations: true,
                animated_icon: true,
                ..Default::default()
            },
            false,
        )
    }

    /// A frame of input describing a window: is it focused, where is it, and
    /// what time is it. `None` for the rectangle stands for a platform that
    /// does not report one.
    fn frame(focused: Option<bool>, rect: Option<egui::Rect>, time: f64) -> egui::RawInput {
        let mut input = egui::RawInput {
            time: Some(time),
            ..Default::default()
        };
        let id = egui::ViewportId::ROOT;
        let viewport = input.viewports.entry(id).or_default();
        viewport.focused = focused;
        viewport.outer_rect = rect;
        input
    }

    /// The bar heights drawn for one frame of that input.
    fn heights_for(
        ctx: &egui::Context,
        input: egui::RawInput,
        motion: Motion,
        time: f32,
    ) -> Vec<u32> {
        let output = crate::headless_frame(ctx, input, |ui| {
            egui::CentralPanel::default().show(ui, |ui| {
                draw(ui, egui::vec2(120.0, 40.0), motion, time);
            });
        });
        let mut heights = Vec::new();
        for clipped in output.shapes {
            if let egui::Shape::Rect(r) = &clipped.shape {
                heights.push((r.rect.height() * 100.0) as u32);
            }
        }
        heights
    }

    /// The soonest repaint the context asked for after that frame.
    fn soonest_after(
        ctx: &egui::Context,
        input: egui::RawInput,
        motion: Motion,
        time: f32,
    ) -> std::time::Duration {
        let output = crate::headless_frame(ctx, input, |ui| {
            egui::CentralPanel::default().show(ui, |ui| {
                draw(ui, egui::vec2(120.0, 40.0), motion, time);
            });
        });
        output
            .viewport_output
            .values()
            .map(|v| v.repaint_delay)
            .min()
            .unwrap_or(std::time::Duration::MAX)
    }

    const SOMEWHERE: fn() -> egui::Rect =
        || egui::Rect::from_min_size(egui::pos2(100.0, 100.0), egui::vec2(800.0, 600.0));

    fn still() -> Motion {
        Motion::resolve(
            &Prefs {
                animations: true,
                animated_icon: false,
                ..Default::default()
            },
            false,
        )
    }

    #[test]
    fn a_bar_stays_inside_the_documented_height_range() {
        // Walk several periods at a fine step: nothing may leave 16%..82%,
        // because a bar taller than its box is a bar drawn over the text
        // beside it.
        let mut lowest = f32::MAX;
        let mut highest = f32::MIN;
        for step in 0..2000 {
            let time = step as f32 * 0.01;
            for delay in DELAYS {
                let f = height_fraction(time, delay);
                assert!(f.is_finite(), "non-finite height at t={time}");
                lowest = lowest.min(f);
                highest = highest.max(f);
            }
        }
        assert!(
            lowest >= MIN_FRACTION - 1e-4,
            "went below the floor: {lowest}"
        );
        assert!(
            highest <= MAX_FRACTION + 1e-4,
            "went above the ceiling: {highest}"
        );
        // And it must actually use the range, or the animation is invisible.
        assert!(lowest < MIN_FRACTION + 0.02, "never reached the floor");
        assert!(highest > MAX_FRACTION - 0.02, "never reached the ceiling");
    }

    #[test]
    fn the_cycle_repeats_exactly_once_per_period() {
        for delay in DELAYS {
            for t in [0.0f32, 0.3, 1.1, 7.7] {
                let a = height_fraction(t, delay);
                let b = height_fraction(t + PERIOD, delay);
                assert!((a - b).abs() < 1e-4, "not periodic at t={t}");
            }
        }
    }

    /// The bars must not all be at the same height, or the row pumps as one
    /// block instead of rippling.
    #[test]
    fn the_bars_are_out_of_phase_with_each_other() {
        let heights: Vec<f32> = DELAYS.iter().map(|d| height_fraction(0.0, *d)).collect();
        let spread = heights.iter().cloned().fold(f32::MIN, f32::max)
            - heights.iter().cloned().fold(f32::MAX, f32::min);
        assert!(spread > 0.1, "the bars move together: spread {spread}");
    }

    /// A negative clock must not produce a negative phase.
    #[test]
    fn a_negative_time_is_handled() {
        for t in [-0.5f32, -100.0] {
            let f = height_fraction(t, 0.0);
            assert!((MIN_FRACTION..=MAX_FRACTION).contains(&f), "t={t} gave {f}");
        }
    }

    /// The point of the toggle: still means still, and identical at every
    /// moment. A "still" mark that still differed frame to frame would mean
    /// the switch had not actually turned anything off.
    #[test]
    fn a_stilled_mark_is_the_same_at_every_moment() {
        let ctx = egui::Context::default();
        let a = render_heights(&ctx, still(), 0.0);
        let b = render_heights(&ctx, still(), 12.34);
        assert_eq!(a, b, "the stilled mark changed between frames");

        let c = render_heights(&ctx, moving(), 0.0);
        let d = render_heights(&ctx, moving(), 0.6);
        assert_ne!(c, d, "the moving mark did not move");
    }

    /// And it must not keep the CPU awake once it is off.
    ///
    /// An "off" switch that still schedules a frame every 33 ms has turned the
    /// animation off visually and left the battery cost behind, which on a
    /// laptop is most of the reason somebody turned it off. Compared against
    /// the moving case rather than against an absolute sentinel, because what
    /// egui uses to mean "nothing pending" is its business and not a contract.
    #[test]
    fn a_stilled_mark_requests_no_repaint() {
        // Measured from a *settled* context. egui asks for an immediate
        // repaint on the first frames whatever is drawn, because layout needs
        // a second pass to converge -- so a single `run` reports zero for the
        // still case too, and the test would pass or fail for a reason that
        // has nothing to do with the mark.
        let soonest = |motion: Motion| {
            let ctx = egui::Context::default();
            let mut delay = std::time::Duration::MAX;
            for _ in 0..4 {
                let output = crate::headless_frame(&ctx, Default::default(), |ui| {
                    egui::CentralPanel::default().show(ui, |ui| {
                        draw(ui, egui::vec2(120.0, 32.0), motion, 1.0);
                    });
                });
                delay = output
                    .viewport_output
                    .values()
                    .map(|v| v.repaint_delay)
                    .min()
                    .unwrap_or(std::time::Duration::MAX);
            }
            delay
        };

        let moving_delay = soonest(moving());
        let still_delay = soonest(still());

        assert!(
            moving_delay <= std::time::Duration::from_millis(100),
            "a moving mark did not ask to be repainted promptly: {moving_delay:?}"
        );
        assert!(
            still_delay > moving_delay,
            "a stilled mark asked to be repainted as eagerly as a moving one \
             ({still_delay:?} vs {moving_delay:?})"
        );
        assert!(
            still_delay >= std::time::Duration::from_secs(1),
            "a stilled mark is still driving the frame rate: {still_delay:?}"
        );
    }

    /// Drawing must not panic at any size, including degenerate ones a layout
    /// can genuinely produce while a window is being resized.
    #[test]
    fn any_size_can_be_drawn() {
        let ctx = egui::Context::default();
        for size in [
            egui::vec2(0.0, 0.0),
            egui::vec2(1.0, 1.0),
            egui::vec2(120.0, 32.0),
            egui::vec2(4000.0, 2.0),
        ] {
            let _ = crate::headless_frame(&ctx, Default::default(), |ui| {
                egui::CentralPanel::default().show(ui, |ui| {
                    draw(ui, size, moving(), 3.0);
                });
            });
        }
    }

    /// The delays are copied from the website's markup; if that list changes
    /// and this one does not, the two marks stop matching.

    #[test]
    fn an_unfocused_window_does_not_animate() {
        // The case that makes a laptop warm: the application is open behind
        // something else and nobody can see the mark at all.
        let ctx = egui::Context::default();
        let rect = SOMEWHERE();
        // Settle it first, focused, so the "unmoved for a quarter second"
        // condition is satisfied and focus is the only thing being tested.
        for step in 0..4 {
            heights_for(
                &ctx,
                frame(Some(true), Some(rect), step as f64 * 0.2),
                moving(),
                step as f32 * 0.2,
            );
        }

        let first = heights_for(&ctx, frame(Some(false), Some(rect), 2.0), moving(), 2.0);
        let later = heights_for(&ctx, frame(Some(false), Some(rect), 2.9), moving(), 2.9);
        assert_eq!(
            first, later,
            "the mark moved with the window unfocused, half a period apart"
        );

        let delay = soonest_after(&ctx, frame(Some(false), Some(rect), 3.5), moving(), 3.5);
        assert!(
            delay >= std::time::Duration::from_secs(1),
            "an unfocused window is still driving the frame rate: {delay:?}"
        );
    }

    #[test]
    fn a_window_being_dragged_does_not_animate() {
        // The reported symptom: dragging the window is jerky. Every frame of
        // a drag delivers a new rectangle, and each one used to come with a
        // full redraw this animation had asked for.
        let ctx = egui::Context::default();
        for step in 0..4 {
            heights_for(
                &ctx,
                frame(Some(true), Some(SOMEWHERE()), step as f64 * 0.2),
                moving(),
                step as f32 * 0.2,
            );
        }
        let settled = heights_for(
            &ctx,
            frame(Some(true), Some(SOMEWHERE()), 1.0),
            moving(),
            1.0,
        );

        // Now move it, one frame at a time, as a drag does.
        let mut dragged = Vec::new();
        for step in 0..6 {
            let moved = SOMEWHERE().translate(egui::vec2(step as f32 * 7.0, 0.0));
            let time = 1.0 + step as f64 * 0.05;
            dragged.push(heights_for(
                &ctx,
                frame(Some(true), Some(moved), time),
                moving(),
                time as f32,
            ));
        }
        for (index, shape) in dragged.iter().enumerate() {
            assert_eq!(
                shape, &settled,
                "the mark advanced on drag frame {index}, which is the redraw \
                 that competes with the window move"
            );
        }
    }

    #[test]
    fn the_mark_starts_again_once_the_window_stops() {
        // Pausing during a drag is only correct if it resumes afterwards. A
        // permanently frozen logo is a different bug, not a fix.
        let ctx = egui::Context::default();
        let rect = SOMEWHERE();
        let paused = heights_for(
            &ctx,
            frame(Some(true), Some(rect.translate(egui::vec2(9.0, 0.0))), 1.0),
            moving(),
            1.0,
        );

        // Hold still for longer than SETTLE, then look again half a period on.
        let mut latest = paused.clone();
        for step in 1..8 {
            let time = 1.0 + step as f64 * 0.2;
            latest = heights_for(
                &ctx,
                frame(Some(true), Some(rect), time),
                moving(),
                time as f32,
            );
        }
        assert_ne!(
            latest, paused,
            "the mark never resumed after the window stopped moving"
        );
    }

    #[test]
    fn a_paused_mark_keeps_the_shape_it_had() {
        // Freezing at the resting midpoint would make every click into another
        // window snap the row flat, which is a new visible glitch traded for
        // the old invisible one.
        let ctx = egui::Context::default();
        let rect = SOMEWHERE();
        let mut running = Vec::new();
        for step in 0..6 {
            let time = step as f64 * 0.2;
            running = heights_for(
                &ctx,
                frame(Some(true), Some(rect), time),
                moving(),
                time as f32,
            );
        }
        let frozen = heights_for(&ctx, frame(Some(false), Some(rect), 1.4), moving(), 1.4);
        assert_eq!(
            frozen, running,
            "losing focus changed the shape of the mark rather than holding it"
        );
    }

    #[test]
    fn a_platform_that_reports_nothing_still_animates() {
        // No focus and no window rectangle: a mark frozen for ever there would
        // be a worse failure than one that animates when it need not.
        let ctx = egui::Context::default();
        let mut delay = std::time::Duration::MAX;
        for step in 0..4 {
            delay = soonest_after(
                &ctx,
                frame(None, None, step as f64 * 0.1),
                moving(),
                step as f32 * 0.1,
            );
        }
        assert!(
            delay <= std::time::Duration::from_millis(1000 / u64::from(crate::pace::ASSUMED) + 10),
            "a platform reporting neither focus nor geometry stopped the mark: {delay:?}"
        );
    }

    #[test]
    fn the_mark_is_paced_by_the_window_and_not_by_a_number_of_its_own() {
        let ctx = egui::Context::default();
        let rect = SOMEWHERE();

        // With the target on the display, the mark asks for the next frame
        // now and vsync spaces it: what comes back is zero, or as near as
        // egui's own one-frame subtraction allows.
        crate::pace::Pace::new(crate::pace::Target::Display);
        let mut delay = std::time::Duration::MAX;
        for step in 0..6 {
            delay = soonest_after(
                &ctx,
                frame(Some(true), Some(rect), step as f64 * 0.2),
                moving(),
                step as f32 * 0.2,
            );
        }
        assert_eq!(
            delay,
            std::time::Duration::ZERO,
            "on the display's own rate the mark must not put a timer in the way"
        );

        // With a target chosen in Settings, the mark asks for that interval.
        // What comes back is up to one frame short of it, deliberately on
        // egui's side: it subtracts a predicted frame so a repaint does not
        // land late. A test comparing them for equality would be asserting
        // the frame rate of the machine it runs on.
        crate::pace::Pace::new(crate::pace::Target::Fixed(30));
        let ctx = egui::Context::default();
        for step in 0..6 {
            delay = soonest_after(
                &ctx,
                frame(Some(true), Some(rect), step as f64 * 0.2),
                moving(),
                step as f32 * 0.2,
            );
        }
        let requested = std::time::Duration::from_millis(1000 / 30);
        let one_frame = std::time::Duration::from_millis(17);
        assert!(
            delay <= requested,
            "asked to be repainted later than the chosen target: \
             {delay:?} against {requested:?}"
        );
        assert!(
            delay + one_frame >= requested,
            "asked to be repainted far sooner than the chosen target: \
             {delay:?} against {requested:?}"
        );

        // And back, so a later test in this file does not inherit a timer.
        crate::pace::Pace::new(crate::pace::Target::Display);
    }

    #[test]
    fn the_delays_match_the_website_markup() {
        let html = std::fs::read_to_string(
            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../website/index.html"),
        )
        .expect("index.html should be readable");
        let start = html
            .find("class=\"veil\"")
            .expect("no veil mark on the page");
        let end = html[start..].find("</div>").expect("unterminated veil") + start;
        let block = &html[start..end];

        let mut found: Vec<f32> = Vec::new();
        for piece in block.split("animation-delay:").skip(1) {
            let ms: String = piece.chars().take_while(|c| c.is_ascii_digit()).collect();
            if let Ok(v) = ms.parse::<f32>() {
                found.push(v / 1000.0);
            }
        }
        assert_eq!(
            found.len(),
            DELAYS.len(),
            "the website has {} bars, the app has {}",
            found.len(),
            DELAYS.len()
        );
        for (i, (site, app)) in found.iter().zip(DELAYS.iter()).enumerate() {
            assert!(
                (site - app).abs() < 1e-4,
                "bar {i}: website {site}s, app {app}s"
            );
        }
    }

    /// Render once and read the bar heights back out of the paint list.
    fn render_heights(ctx: &egui::Context, motion: Motion, time: f32) -> Vec<u32> {
        let output = crate::headless_frame(ctx, Default::default(), |ui| {
            egui::CentralPanel::default().show(ui, |ui| {
                draw(ui, egui::vec2(120.0, 40.0), motion, time);
            });
        });
        let mut heights = Vec::new();
        for clipped in output.shapes {
            if let egui::Shape::Rect(r) = &clipped.shape {
                // Quantised, so floating-point noise does not make two
                // identical frames look different.
                heights.push((r.rect.height() * 100.0) as u32);
            }
        }
        heights
    }
}