| 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 | /// Whether any widget asked for a frame since the last take. |
| 20 | pending: bool, |
| 21 | request_count: u64, |
| 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 | if !policy.should_request_animation_frames() { |
| 34 | // Reduced/Still: do not schedule decorative frames. State-change |
| 35 | // redraws still go through `needs_redraw` directly. |
| 36 | return; |
| 37 | } |
| 38 | self.request_at(now, now, policy); |
| 39 | } |
| 40 | |
| 41 | /// Request a frame no earlier than `earliest`. |
| 42 | pub fn request_at(&mut self, now: Instant, earliest: Instant, policy: MotionPolicy) { |
| 43 | if !policy.should_request_animation_frames() { |
| 44 | return; |
| 45 | } |
| 46 | let capped = earliest.max(now); |
| 47 | self.pending = true; |
| 48 | self.request_count = self.request_count.saturating_add(1); |
| 49 | self.next_due = Some(match self.next_due { |
| 50 | Some(existing) => existing.min(capped), |
| 51 | None => capped, |
| 52 | }); |
| 53 | } |
| 54 | |
| 55 | /// Time until a coalesced frame should emit, if one is pending. |
| 56 | #[must_use] |
| 57 | pub fn due_in(&self, now: Instant) -> Option<Duration> { |
| 58 | let due = self.next_due?; |
| 59 | if !self.pending { |
| 60 | return None; |
| 61 | } |
| 62 | Some(due.saturating_duration_since(now)) |
| 63 | } |
| 64 | |
| 65 | /// Consume a due frame request. Returns true when the main loop should |
| 66 | /// set `needs_redraw` for animation (not for state changes). |
| 67 | pub fn take_due(&mut self, now: Instant, policy: MotionPolicy) -> bool { |
| 68 | if !self.pending || !policy.should_request_animation_frames() { |
| 69 | self.pending = false; |
| 70 | self.next_due = None; |
| 71 | return false; |
| 72 | } |
| 73 | let Some(due) = self.next_due else { |
| 74 | return false; |
| 75 | }; |
| 76 | if now < due { |
| 77 | return false; |
| 78 | } |
| 79 | self.pending = false; |
| 80 | self.next_due = None; |
| 81 | self.emit_count = self.emit_count.saturating_add(1); |
| 82 | true |
| 83 | } |
| 84 | |
| 85 | /// Apply the frame-rate limiter interval so animation requests never beat |
| 86 | /// the draw cap. |
| 87 | #[allow(dead_code)] // frame-cap bridge for poll-loop hosts (TUI-DOG-008) |
| 88 | pub fn clamp_to_frame_cap(&mut self, last_draw_at: Instant, policy: MotionPolicy) { |
| 89 | let Some(due) = self.next_due else { |
| 90 | return; |
| 91 | }; |
| 92 | let min_allowed = last_draw_at |
| 93 | .checked_add(policy.min_frame_interval()) |
| 94 | .unwrap_or(last_draw_at); |
| 95 | if due < min_allowed { |
| 96 | self.next_due = Some(min_allowed); |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | #[allow(dead_code)] // reset between modal/session boundaries (TUI-DOG-008) |
| 101 | pub fn reset(&mut self) { |
| 102 | self.pending = false; |
| 103 | self.next_due = None; |
| 104 | } |
| 105 | |
| 106 | #[must_use] |
| 107 | #[allow(dead_code)] // telemetry/introspection for motion QA (TUI-DOG-008) |
| 108 | pub fn request_count(&self) -> u64 { |
| 109 | self.request_count |
| 110 | } |
| 111 | |
| 112 | #[must_use] |
| 113 | #[allow(dead_code)] // telemetry/introspection for motion QA (TUI-DOG-008) |
| 114 | pub fn emit_count(&self) -> u64 { |
| 115 | self.emit_count |
| 116 | } |
| 117 | |
| 118 | #[must_use] |
| 119 | #[allow(dead_code)] // pending probe for poll-loop hosts (TUI-DOG-008) |
| 120 | pub fn is_pending(&self) -> bool { |
| 121 | self.pending |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | #[cfg(test)] |
| 126 | mod tests { |
| 127 | use super::*; |
| 128 | use crate::tui::motion::MotionPolicy; |
| 129 | |
| 130 | #[test] |
| 131 | fn coalesces_multiple_requests_into_one_emit() { |
| 132 | let policy = MotionPolicy::from_settings(false, true, false); |
| 133 | let mut req = FrameRequester::new(); |
| 134 | let t0 = Instant::now(); |
| 135 | |
| 136 | for _ in 0..20 { |
| 137 | req.request_frame(t0, policy); |
| 138 | } |
| 139 | assert!(req.is_pending()); |
| 140 | assert_eq!(req.request_count(), 20); |
| 141 | assert!(req.take_due(t0, policy)); |
| 142 | assert_eq!(req.emit_count(), 1); |
| 143 | assert!(!req.take_due(t0, policy)); |
| 144 | } |
| 145 | |
| 146 | #[test] |
| 147 | fn reduced_motion_drops_animation_frame_requests() { |
| 148 | let policy = MotionPolicy::from_settings(true, true, false); |
| 149 | let mut req = FrameRequester::new(); |
| 150 | let t0 = Instant::now(); |
| 151 | req.request_frame(t0, policy); |
| 152 | assert!(!req.is_pending()); |
| 153 | assert!(!req.take_due(t0, policy)); |
| 154 | } |
| 155 | |
| 156 | #[test] |
| 157 | fn clamp_respects_low_motion_frame_cap() { |
| 158 | let policy = MotionPolicy::from_settings(false, true, false); |
| 159 | let mut req = FrameRequester::new(); |
| 160 | let t0 = Instant::now(); |
| 161 | req.request_frame(t0, policy); |
| 162 | req.clamp_to_frame_cap(t0, policy); |
| 163 | let due = req.due_in(t0).unwrap(); |
| 164 | assert!(due >= policy.min_frame_interval() || due.is_zero()); |
| 165 | } |
| 166 | } |
| 167 |