| 1 | //! Coalescing frame-request scheduler. |
| 2 | //! |
| 3 | //! Widgets ask for a future frame; this scheduler merges requests and emits |
| 4 | //! at most one wake deadline compatible with the existing frame-cap |
| 5 | //! philosophy in [`crate::tui::frame_rate_limiter`]. It does **not** run a |
| 6 | //! competing animation loop — the main `ui` poll loop remains the sole |
| 7 | //! emitter of `terminal.draw`. |
| 8 | |
| 9 | use std::time::Duration; |
| 10 | use std::time::Instant; |
| 11 | |
| 12 | use super::mode::MotionPolicy; |
| 13 | |
| 14 | /// Coalesced request for a future redraw. |
| 15 | #[derive(Debug, Default)] |
| 16 | pub struct FrameRequester { |
| 17 | /// Earliest instant a requester wants a frame. |
| 18 | next_due: Option<Instant>, |
| 19 | #[cfg(test)] |
| 20 | request_count: u64, |
| 21 | #[cfg(test)] |
| 22 | emit_count: u64, |
| 23 | } |
| 24 | |
| 25 | impl FrameRequester { |
| 26 | #[must_use] |
| 27 | pub fn new() -> Self { |
| 28 | Self::default() |
| 29 | } |
| 30 | |
| 31 | /// Request a frame as soon as the motion policy and frame cap allow. |
| 32 | pub fn request_frame(&mut self, now: Instant, policy: MotionPolicy) { |
| 33 | self.request_at(now, now, policy); |
| 34 | } |
| 35 | |
| 36 | /// Request a frame no earlier than `earliest`. |
| 37 | pub fn request_at(&mut self, now: Instant, earliest: Instant, policy: MotionPolicy) { |
| 38 | if !policy.should_request_animation_frames() { |
| 39 | return; |
| 40 | } |
| 41 | let capped = earliest.max(now); |
| 42 | #[cfg(test)] |
| 43 | { |
| 44 | self.request_count = self.request_count.saturating_add(1); |
| 45 | } |
| 46 | self.next_due = Some(match self.next_due { |
| 47 | Some(existing) => existing.min(capped), |
| 48 | None => capped, |
| 49 | }); |
| 50 | } |
| 51 | |
| 52 | /// Time until a coalesced frame should emit, if one is pending. |
| 53 | #[must_use] |
| 54 | pub fn due_in(&self, now: Instant) -> Option<Duration> { |
| 55 | let due = self.next_due?; |
| 56 | Some(due.saturating_duration_since(now)) |
| 57 | } |
| 58 | |
| 59 | /// Consume a due frame request. Returns true when the main loop should |
| 60 | /// set `needs_redraw` for animation (not for state changes). |
| 61 | pub fn take_due(&mut self, now: Instant, policy: MotionPolicy) -> bool { |
| 62 | if !policy.should_request_animation_frames() { |
| 63 | self.next_due = None; |
| 64 | return false; |
| 65 | } |
| 66 | let Some(due) = self.next_due else { |
| 67 | return false; |
| 68 | }; |
| 69 | if now < due { |
| 70 | return false; |
| 71 | } |
| 72 | self.next_due = None; |
| 73 | #[cfg(test)] |
| 74 | { |
| 75 | self.emit_count = self.emit_count.saturating_add(1); |
| 76 | } |
| 77 | true |
| 78 | } |
| 79 | |
| 80 | #[must_use] |
| 81 | #[cfg(test)] |
| 82 | pub fn request_count(&self) -> u64 { |
| 83 | self.request_count |
| 84 | } |
| 85 | |
| 86 | #[must_use] |
| 87 | #[cfg(test)] |
| 88 | pub fn emit_count(&self) -> u64 { |
| 89 | self.emit_count |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | #[cfg(test)] |
| 94 | mod tests { |
| 95 | use super::*; |
| 96 | use crate::tui::motion::MotionPolicy; |
| 97 | |
| 98 | #[test] |
| 99 | fn coalesces_multiple_requests_into_one_emit() { |
| 100 | let policy = MotionPolicy::from_settings(false, true, false); |
| 101 | let mut req = FrameRequester::new(); |
| 102 | let t0 = Instant::now(); |
| 103 | |
| 104 | for _ in 0..20 { |
| 105 | req.request_frame(t0, policy); |
| 106 | } |
| 107 | assert!(req.due_in(t0).is_some()); |
| 108 | assert_eq!(req.request_count(), 20); |
| 109 | assert!(req.take_due(t0, policy)); |
| 110 | assert_eq!(req.emit_count(), 1); |
| 111 | assert!(!req.take_due(t0, policy)); |
| 112 | } |
| 113 | |
| 114 | #[test] |
| 115 | fn reduced_motion_drops_animation_frame_requests() { |
| 116 | let policy = MotionPolicy::from_settings(true, true, false); |
| 117 | let mut req = FrameRequester::new(); |
| 118 | let t0 = Instant::now(); |
| 119 | req.request_frame(t0, policy); |
| 120 | assert!(req.due_in(t0).is_none()); |
| 121 | assert!(!req.take_due(t0, policy)); |
| 122 | } |
| 123 | |
| 124 | #[test] |
| 125 | fn turning_motion_off_discards_a_pending_frame() { |
| 126 | let full = MotionPolicy::from_settings(false, true, false); |
| 127 | let still = MotionPolicy::from_settings(false, false, false); |
| 128 | let mut req = FrameRequester::new(); |
| 129 | let now = Instant::now(); |
| 130 | req.request_frame(now, full); |
| 131 | assert!(!req.take_due(now, still)); |
| 132 | assert!(req.due_in(now).is_none()); |
| 133 | assert!(!req.take_due(now, full)); |
| 134 | } |
| 135 | } |
| 136 |