rust_widgets 2.8.2

Pure Rust cross-platform native GUI library with hardware-adaptive rendering, 180 widgets, touch/gesture support, i18n, and SVG-pipeline-accurate output
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
// SPDX-FileCopyrightText: Copyright (c) 2026 Mike Li/Mikewolfli/Wei Li(mikewolfli@163.com)
// SPDX-License-Identifier: MIT

//! The **master clock** a media timeline is measured against, and the state it is in.
//!
//! # Why this lives in `core` and not in `video`
//!
//! It was written in `src/video/` and that was a mistake with a concrete cost: `src/video/` is gated
//! on the `video` feature (a real codec dependency), so a **core** control could not use the clock
//! without pulling a decoder in. But nothing here touches a codec — it is arithmetic over a delta and
//! four thresholds, and `PlaybackState` is a handful of variants. Moving both here is what lets
//! `MediaPlayer` (always built) and `VideoEngine` (behind `video`) share one timeline instead of each
//! carrying its own.
//!
//! # The gap this closes (BLUE24 §12 U-2)
//!
//! The crate shipped two media engines with **two unrelated timelines**:
//!
//! ```text
//! src/video/engine.rs   VideoEngine::next_frame()   pulls a frame, current_time = frame.timestamp
//!                                                    ↑ the *frame's own* presentation time
//! src/audio/engine.rs   AudioEngine::tick(samples)   pushes samples, position += samples
//!                                                    ↑ a count of *samples played*
//! media_player.rs       MediaPlayer::position_ms     advanced by nothing at all
//! ```
//!
//! None is wrong on its own. What was missing is the thing that relates them: a question none could
//! answer — *what time is it now, and is this frame early, due, or late?* Without it a player can
//! only "play frames as fast as they decode" (running ahead on a fast machine and behind on a slow
//! one) and audio can only "play samples as fast as the device consumes them". The two drift, and
//! nothing measures the drift because there is no shared reference to measure against.
//!
//! # Why it is driven by `drive_frame`
//!
//! A clock needs a time source, and the frame loop is the one the crate already owns:
//! [`crate::drive_frame`] hands every consumer the same `delta_ms` on every platform. So the clock is
//! **not** a wall-clock reader — it advances by the same delta the frame advanced animations by, which
//! means a paused window's still frames cost the clock nothing and a test can drive it by hand
//! (`tick(16)` sixty times is 0.96 s, deterministically, with no sleeping).
//!
//! # What it is not
//!
//! It does not decode, does not own a frame, and does not touch a device. It answers two questions —
//! *where are we* ([`MediaClock::position`]) and *what should happen to this frame*
//! ([`MediaClock::verdict_for`]) — and the engines act on the answers. Keeping the arithmetic here
//! and the I/O in the engines is what makes the sync policy testable without a video file or a sound
//! card.

/// Playback state for media engines.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PlaybackState {
    /// No media loaded or stopped.
    #[default]
    Stopped,
    /// Currently playing.
    Playing,
    /// Paused.
    Paused,
    /// Buffering/waiting for data.
    Buffering,
    /// Playback finished.
    Ended,
}

impl PlaybackState {
    /// Returns true if the player is actively playing.
    pub fn is_active(&self) -> bool {
        matches!(self, PlaybackState::Playing | PlaybackState::Buffering)
    }

    /// Returns true if playback can be resumed.
    pub fn can_resume(&self) -> bool {
        matches!(self, PlaybackState::Paused)
    }

    /// Returns a human-readable label.
    pub fn label(&self) -> &'static str {
        match self {
            PlaybackState::Stopped => "Stopped",
            PlaybackState::Playing => "Playing",
            PlaybackState::Paused => "Paused",
            PlaybackState::Buffering => "Buffering",
            PlaybackState::Ended => "Ended",
        }
    }
}

/// What should happen to a frame at the clock's current position.
///
/// # Why a verdict and not a boolean
///
/// "Should I show this?" has three answers, not two, and collapsing the third into either of the
/// others is what makes a player misbehave in a way that looks like a performance problem:
///
/// * returning `Show` for a **late** frame makes playback fall behind and never recover — the
///   classic "video slows down and the audio overtakes it" failure;
/// * returning `Wait` for a late frame is the same failure spelled differently, because waiting for a
///   frame that is already overdue is waiting for nothing;
/// * returning `Show` for an **early** frame makes playback run as fast as the decoder can go, which
///   is the failure mode a fast machine exhibits and a slow one hides.
///
/// Naming all three is what lets the loop state the policy it implements rather than infer it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FrameVerdict {
    /// The frame is due now: hand it to the renderer.
    Show,
    /// The frame is not due yet: keep showing the previous one and ask again later.
    Wait,
    /// The frame is too far past due: discharge it without showing it.
    ///
    /// Dropping is not an optimisation — it is the *only* way to catch up after a stall. A player
    /// that never drops can only ever fall further behind, because the media's timeline is fixed and
    /// its own is not.
    Drop,
}

/// The master clock: one timeline, advanced by the frame loop.
///
/// See the module docs for why this exists and what it deliberately does not do.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MediaClock {
    /// Where playback is, in seconds. Always in `0.0..=duration`.
    position: f64,
    /// How long the media is, in seconds. `0.0` means "unknown", which clamps nothing.
    duration: f64,
    /// Playback rate: `1.0` is real time, `2.0` is double speed.
    ///
    /// A non-finite or non-positive rate is refused by [`MediaClock::set_rate`] rather than stored,
    /// because a rate of `0` is a pause expressed as a rate and a negative rate would make
    /// [`MediaClock::position`] decrease while [`MediaClock::verdict_for`]'s window moves the wrong
    /// way — two mechanisms for one idea (the state has a `Paused` already).
    rate: f64,
    /// Whether the clock is running.
    state: PlaybackState,
    /// Frames dropped by [`MediaClock::verdict_for`] since the last [`MediaClock::take_dropped`].
    ///
    /// Counted rather than merely dropped so "is playback keeping up?" is answerable. A player that
    /// silently discards frames looks identical to one that is rendering all of them.
    dropped: u64,
}

impl Default for MediaClock {
    fn default() -> Self {
        Self::new()
    }
}

impl MediaClock {
    /// A stopped clock at position `0`, with no known duration.
    pub fn new() -> Self {
        Self { position: 0.0, duration: 0.0, rate: 1.0, state: PlaybackState::Stopped, dropped: 0 }
    }

    /// States how long the media is, so [`MediaClock::position`] stops at the end.
    ///
    /// A non-finite or negative duration is treated as "unknown" rather than stored: a container
    /// that failed to parse yields `NaN`, and a clock that clamped against `NaN` would panic on every
    /// tick (the same hazard `VideoEngine::seek` documents for its own bound).
    pub fn set_duration(&mut self, duration_secs: f64) {
        self.duration =
            if duration_secs.is_finite() && duration_secs > 0.0 { duration_secs } else { 0.0 };
    }

    /// The media duration this clock was told about, or `0.0` when unknown.
    pub fn duration(&self) -> f64 {
        self.duration
    }

    /// How fast the clock runs. `1.0` is real time.
    pub fn rate(&self) -> f64 {
        self.rate
    }

    /// Sets the playback rate, ignoring a value that is not finite and positive.
    ///
    /// Returns the rate actually in effect, so a caller that passed something unusable can see that
    /// nothing changed rather than assuming it did.
    pub fn set_rate(&mut self, rate: f64) -> f64 {
        if rate.is_finite() && rate > 0.0 {
            self.rate = rate;
        }
        self.rate
    }

    /// The current playback state.
    pub fn state(&self) -> PlaybackState {
        self.state
    }

    /// Starts the clock, or resumes it from a pause.
    ///
    /// Starting a clock whose media has ended rewinds it first: "play" on a finished track means
    /// "play it again", and leaving the position at the end would make the first tick immediately
    /// report `Ended` again — a play button that does nothing.
    pub fn play(&mut self) {
        if self.state == PlaybackState::Ended {
            self.position = 0.0;
            self.dropped = 0;
        }
        self.state = PlaybackState::Playing;
    }

    /// Pauses the clock where it is.
    pub fn pause(&mut self) {
        if self.state == PlaybackState::Playing {
            self.state = PlaybackState::Paused;
        }
    }

    /// Stops the clock and rewinds it.
    pub fn stop(&mut self) {
        self.state = PlaybackState::Stopped;
        self.position = 0.0;
        self.dropped = 0;
    }

    /// Moves the position, clamped to the known range.
    ///
    /// The state is **not** changed: seeking while paused stays paused and seeking while playing
    /// stays playing, which is what a scrubber needs. Landing exactly on the end reports `Ended`,
    /// because that is what the position means.
    pub fn seek(&mut self, position_secs: f64) {
        if !position_secs.is_finite() {
            return;
        }
        let upper = if self.duration > 0.0 { self.duration } else { f64::MAX };
        self.position = position_secs.clamp(0.0, upper);
        if self.duration > 0.0 && self.position >= self.duration {
            self.state = PlaybackState::Ended;
        }
    }

    /// Advances the clock by one frame's delta, in milliseconds.
    ///
    /// Returns the position after advancing. A clock that is not `Playing` does not move: that is the
    /// whole of "pausing costs nothing", and it is also why a still window's frames do not make media
    /// time run on.
    ///
    /// Reaching the end sets [`PlaybackState::Ended`] and **stops** the position at the duration.
    /// `Ended` rather than a wrap: repeating needs a repeat mode the crate does not have, and
    /// inventing one here would be a second mechanism for a question the caller has not asked.
    pub fn tick(&mut self, delta_ms: u32) -> f64 {
        if self.state != PlaybackState::Playing {
            return self.position;
        }
        let advance = (delta_ms as f64 / 1000.0) * self.rate;
        self.position += advance;
        if self.duration > 0.0 && self.position >= self.duration {
            self.position = self.duration;
            self.state = PlaybackState::Ended;
        }
        self.position
    }

    /// The current position, in seconds.
    pub fn position(&self) -> f64 {
        self.position
    }

    /// Decides what should happen to a frame whose presentation time is `frame_pts`.
    ///
    /// # The policy, and why the tolerance is a parameter
    ///
    /// * `frame_pts` more than `tolerance` **ahead** of the clock → [`FrameVerdict::Wait`];
    /// * `frame_pts` more than `tolerance` **behind** the clock → [`FrameVerdict::Drop`];
    /// * otherwise → [`FrameVerdict::Show`].
    ///
    /// `tolerance` is the caller's, not a constant here, because it is the difference between a
    /// display that refreshes at 60 Hz (16 ms of slop is invisible) and one at 24 Hz (it is not), and
    /// because a test needs it to be small enough to be exact. A caller with no opinion passes half a
    /// frame's duration, which is the largest error that cannot be seen.
    pub fn verdict_for(&self, frame_pts: f64, tolerance: f64) -> FrameVerdict {
        if !frame_pts.is_finite() {
            // A frame with no usable timestamp cannot be placed on the timeline. Showing it would put
            // it wherever the loop happened to be; dropping it loses one frame of a broken stream.
            // Dropping is the honest choice — it is what the stream's own defect produces.
            return FrameVerdict::Drop;
        }
        let tolerance = if tolerance.is_finite() && tolerance >= 0.0 { tolerance } else { 0.0 };
        let lead = frame_pts - self.position;
        if lead > tolerance {
            FrameVerdict::Wait
        } else if -lead > tolerance {
            FrameVerdict::Drop
        } else {
            FrameVerdict::Show
        }
    }

    /// Records that a frame was dropped, so "is playback keeping up?" is answerable.
    pub fn note_dropped(&mut self) {
        self.dropped = self.dropped.saturating_add(1);
    }

    /// Returns the frames dropped since the last call, resetting the count.
    ///
    /// Taken rather than read, because the number a caller wants is *this period's* drops — and a
    /// counter that only grows makes "the last second dropped three frames" impossible to state.
    pub fn take_dropped(&mut self) -> u64 {
        core::mem::take(&mut self.dropped)
    }
}

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

    /// A clock at 60 Hz for one second is exactly sixty ticks — the whole point of driving the clock
    /// from the frame loop instead of a wall clock: this is deterministic and needs no sleeping.
    #[test]
    fn the_clock_advances_by_the_frames_delta() {
        let mut clock = MediaClock::new();
        clock.set_duration(10.0);
        clock.play();
        for _ in 0..60 {
            clock.tick(16);
        }
        // 60 x 16 ms = 0.96 s, not 1.0 — the assertion is exact because the arithmetic is.
        assert!((clock.position() - 0.96).abs() < 1e-9, "position was {}", clock.position());
    }

    /// A paused clock does not move, whatever the frame loop does.
    #[test]
    fn a_paused_clock_does_not_advance() {
        let mut clock = MediaClock::new();
        clock.set_duration(10.0);
        clock.play();
        clock.tick(16);
        let paused_at = clock.position();
        clock.pause();
        for _ in 0..60 {
            clock.tick(16);
        }
        assert_eq!(clock.position(), paused_at, "a still frame must not run media time on");
        assert_eq!(clock.state(), PlaybackState::Paused);
    }

    /// Reaching the end stops at the duration and reports `Ended`.
    #[test]
    fn the_clock_stops_at_the_end_and_reports_ended() {
        let mut clock = MediaClock::new();
        clock.set_duration(1.0);
        clock.play();
        // A single long frame overshoots; the position must clamp, not run past the end.
        clock.tick(5_000);
        assert_eq!(clock.position(), 1.0, "the position clamps at the duration");
        assert_eq!(clock.state(), PlaybackState::Ended);
        // And a further tick is a no-op rather than a wrap.
        clock.tick(16);
        assert_eq!(clock.position(), 1.0);
    }

    /// Pressing play on a finished track starts it again.
    ///
    /// Without the rewind, `play()` after `Ended` leaves the position at the end, so the next tick
    /// immediately re-reports `Ended` — a play button that visibly does nothing.
    #[test]
    fn play_after_the_end_rewinds() {
        let mut clock = MediaClock::new();
        clock.set_duration(1.0);
        clock.play();
        clock.tick(2_000);
        assert_eq!(clock.state(), PlaybackState::Ended);
        clock.play();
        assert_eq!(clock.position(), 0.0, "play on a finished track means play it again");
        assert_eq!(clock.state(), PlaybackState::Playing);
    }

    /// The three verdicts, at the boundaries.
    #[test]
    fn a_frame_is_waited_for_shown_or_dropped_by_how_far_it_is_from_now() {
        let mut clock = MediaClock::new();
        clock.set_duration(10.0);
        clock.play();
        for _ in 0..25 {
            clock.tick(16); // 0.4 s in
        }
        assert!((clock.position() - 0.4).abs() < 1e-9);

        // Due now, and anything inside the tolerance either way.
        assert_eq!(clock.verdict_for(0.4, 0.008), FrameVerdict::Show);
        assert_eq!(clock.verdict_for(0.405, 0.008), FrameVerdict::Show);
        assert_eq!(clock.verdict_for(0.395, 0.008), FrameVerdict::Show);
        // Ahead of the clock: not due yet.
        assert_eq!(clock.verdict_for(0.5, 0.008), FrameVerdict::Wait);
        // Behind it: overdue, and waiting for it would be waiting for nothing.
        assert_eq!(clock.verdict_for(0.1, 0.008), FrameVerdict::Drop);
    }

    /// A frame with no usable timestamp is dropped rather than placed arbitrarily.
    #[test]
    fn an_unusable_timestamp_is_dropped() {
        let clock = MediaClock::new();
        assert_eq!(clock.verdict_for(f64::NAN, 0.008), FrameVerdict::Drop);
        assert_eq!(clock.verdict_for(f64::INFINITY, 0.008), FrameVerdict::Drop);
    }

    /// The rate scales the advance, and an unusable rate is refused rather than stored.
    #[test]
    fn the_rate_scales_the_advance_and_refuses_zero() {
        let mut clock = MediaClock::new();
        clock.set_duration(100.0);
        clock.play();
        assert_eq!(clock.set_rate(2.0), 2.0);
        clock.tick(1000);
        assert!((clock.position() - 2.0).abs() < 1e-9, "double speed is two seconds per second");

        // A rate of zero is a pause spelled as a rate, and a negative one would run the media
        // backwards; both are refused, and the caller is told what is in effect.
        assert_eq!(clock.set_rate(0.0), 2.0);
        assert_eq!(clock.set_rate(-1.0), 2.0);
        assert_eq!(clock.set_rate(f64::NAN), 2.0);
    }

    /// A duration that failed to parse must not poison the clock.
    #[test]
    fn an_unusable_duration_clamps_nothing_and_does_not_panic() {
        let mut clock = MediaClock::new();
        clock.set_duration(f64::NAN);
        assert_eq!(clock.duration(), 0.0, "an unknown duration is not a NaN bound");
        clock.play();
        clock.tick(1000);
        assert!(
            (clock.position() - 1.0).abs() < 1e-9,
            "with no duration the clock runs on rather than clamping against a NaN"
        );
    }

    /// Dropped frames are counted, and the count is taken rather than read.
    #[test]
    fn drops_are_counted_and_taken() {
        let mut clock = MediaClock::new();
        clock.note_dropped();
        clock.note_dropped();
        assert_eq!(clock.take_dropped(), 2, "this period dropped two");
        assert_eq!(clock.take_dropped(), 0, "and the count was taken, not read");
    }

    /// Seeking is clamped, keeps the state, and landing on the end reports `Ended`.
    #[test]
    fn seek_is_clamped_and_keeps_the_state() {
        let mut clock = MediaClock::new();
        clock.set_duration(10.0);
        clock.play();
        clock.seek(5.0);
        assert_eq!(clock.position(), 5.0);
        assert_eq!(clock.state(), PlaybackState::Playing, "seeking does not pause");

        clock.seek(-3.0);
        assert_eq!(clock.position(), 0.0, "clamped to the start");
        clock.seek(99.0);
        assert_eq!(clock.position(), 10.0, "clamped to the end");
        assert_eq!(clock.state(), PlaybackState::Ended, "landing on the end is the end");

        // A NaN seek is ignored rather than poisoning the position.
        clock.play();
        clock.seek(4.0);
        clock.seek(f64::NAN);
        assert_eq!(clock.position(), 4.0);
    }
}