返回 CodeWhale
frame_requester.rs
根目录 / crates / tui / src / tui / motion / frame_requester.rs
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
167 lines RUST