use std::time::Duration;
use std::time::Instant;
pub const MIN_FRAME_INTERVAL: Duration = Duration::from_nanos(8_333_334);
pub const LOW_MOTION_MIN_FRAME_INTERVAL: Duration = Duration::from_nanos(33_333_333);
#[derive(Debug, Default)]
pub struct FrameRateLimiter {
last_emitted_at: Option<Instant>,
low_motion: bool,
}
impl FrameRateLimiter {
#[must_use]
pub fn clamp_deadline(&self, requested: Instant) -> Instant {
let Some(last_emitted_at) = self.last_emitted_at else {
return requested;
};
let min_allowed = last_emitted_at
.checked_add(self.interval())
.unwrap_or(last_emitted_at);
requested.max(min_allowed)
}
pub fn mark_emitted(&mut self, emitted_at: Instant) {
self.last_emitted_at = Some(emitted_at);
}
#[must_use]
pub fn time_until_next_draw(&self, now: Instant) -> Option<Duration> {
let clamped = self.clamp_deadline(now);
if clamped <= now {
None
} else {
Some(clamped - now)
}
}
pub fn set_low_motion(&mut self, low_motion: bool) {
self.low_motion = low_motion;
}
fn interval(&self) -> Duration {
if self.low_motion {
LOW_MOTION_MIN_FRAME_INTERVAL
} else {
MIN_FRAME_INTERVAL
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_does_not_clamp() {
let t0 = Instant::now();
let limiter = FrameRateLimiter::default();
assert_eq!(limiter.clamp_deadline(t0), t0);
assert!(limiter.time_until_next_draw(t0).is_none());
}
#[test]
fn clamps_to_min_interval_since_last_emit() {
let t0 = Instant::now();
let mut limiter = FrameRateLimiter::default();
assert_eq!(limiter.clamp_deadline(t0), t0);
limiter.mark_emitted(t0);
let too_soon = t0 + Duration::from_millis(1);
assert_eq!(limiter.clamp_deadline(too_soon), t0 + MIN_FRAME_INTERVAL);
}
#[test]
fn time_until_next_draw_reports_remaining_window() {
let t0 = Instant::now();
let mut limiter = FrameRateLimiter::default();
limiter.mark_emitted(t0);
let after_4ms = t0 + Duration::from_millis(4);
let remaining = limiter.time_until_next_draw(after_4ms).unwrap();
assert!(
remaining > Duration::from_micros(4_000) && remaining < Duration::from_millis(5),
"expected ~4.33ms, got {remaining:?}"
);
}
#[test]
fn time_until_next_draw_none_after_interval_elapsed() {
let t0 = Instant::now();
let mut limiter = FrameRateLimiter::default();
limiter.mark_emitted(t0);
let well_past = t0 + Duration::from_millis(50);
assert!(limiter.time_until_next_draw(well_past).is_none());
}
#[test]
fn low_motion_clamps_to_30fps_interval() {
let t0 = Instant::now();
let mut limiter = FrameRateLimiter::default();
limiter.set_low_motion(true);
limiter.mark_emitted(t0);
let too_soon = t0 + Duration::from_millis(5);
assert_eq!(
limiter.clamp_deadline(too_soon),
t0 + LOW_MOTION_MIN_FRAME_INTERVAL
);
let after_34 = t0 + Duration::from_millis(34);
assert!(limiter.time_until_next_draw(after_34).is_none());
}
#[test]
fn low_motion_switching_respects_current_mode() {
let t0 = Instant::now();
let mut limiter = FrameRateLimiter::default();
limiter.mark_emitted(t0);
let t10 = t0 + Duration::from_millis(10);
assert!(limiter.time_until_next_draw(t10).is_none());
limiter.set_low_motion(true);
limiter.mark_emitted(t10);
let t20 = t10 + Duration::from_millis(10);
let remaining = limiter.time_until_next_draw(t20).unwrap();
assert!(
remaining > Duration::from_millis(20) && remaining < Duration::from_millis(25),
"expected ~23.33ms remaining, got {remaining:?}"
);
}
}