返回 CodeWhale
frame_rate_limiter.rs
根目录 / crates / tui / src / tui / frame_rate_limiter.rs
1 //! 120 FPS draw-rate cap for the TUI render loop.
2 //!
3 //! Adapted from
4 //! [`codex-rs/tui/src/tui/frame_rate_limiter.rs`](https://github.com/openai/codex)
5 //! — same intent, slightly simpler since our render loop is poll-based
6 //! rather than scheduler-based. We only need to clamp `terminal.draw` calls
7 //! to a minimum interval; the existing `needs_redraw` flag already coalesces
8 //! multiple state mutations into one draw when several events fire between
9 //! polls.
10 //!
11 //! ## Why
12 //!
13 //! When the model streams a long assistant response, every SSE chunk flips
14 //! `App.needs_redraw = true`. Without a cap, the main loop happily redraws
15 //! the entire screen on every chunk — sometimes >300 frames/sec for a few
16 //! hundred ms of streaming. The user can't perceive frames faster than
17 //! ~60-120 FPS, and ratatui's diff-and-flush has real cost (wrap, style,
18 //! crossterm `queue!`), so this is pure waste.
19 //!
20 //! ## Behavior
21 //!
22 //! - Default state: never clamps.
23 //! - After `mark_emitted(t)` is called, subsequent `clamp_deadline(t')`
24 //! returns `max(t', t + MIN_FRAME_INTERVAL)`.
25 //! - The render loop calls `clamp_deadline(now)` and:
26 //! - if the result == `now`, it's safe to draw immediately.
27 //! - if the result > `now`, the loop should sleep / shorten its poll
28 //! timeout to wake up at exactly that instant.
29 //!
30 //! See `crates/tui/src/tui/ui.rs` (`run_app`) for the integration point.
31
32 use std::time::Duration;
33 use std::time::Instant;
34
35 /// 120 FPS minimum frame interval (≈8.33ms).
36 pub const MIN_FRAME_INTERVAL: Duration = Duration::from_nanos(8_333_334);
37
38 /// 30 FPS minimum frame interval (≈33.33ms) used in low-motion mode.
39 pub const LOW_MOTION_MIN_FRAME_INTERVAL: Duration = Duration::from_nanos(33_333_333);
40
41 /// Remembers the most recent emitted draw, allowing deadlines to be clamped
42 /// forward so the next draw never lands sooner than `MIN_FRAME_INTERVAL`
43 /// after the last one.
44 #[derive(Debug, Default)]
45 pub struct FrameRateLimiter {
46 last_emitted_at: Option<Instant>,
47 /// When true, use the 30 FPS cap instead of 120 FPS.
48 low_motion: bool,
49 /// Optional measured display cadence. Never used under low_motion; when
50 /// set, clamps draws no faster than this interval (and never faster than
51 /// [`MIN_FRAME_INTERVAL`]).
52 adaptive_interval: Option<Duration>,
53 }
54
55 impl FrameRateLimiter {
56 /// Returns `requested`, clamped forward if it would exceed the maximum
57 /// frame rate.
58 #[must_use]
59 pub fn clamp_deadline(&self, requested: Instant) -> Instant {
60 let Some(last_emitted_at) = self.last_emitted_at else {
61 return requested;
62 };
63 let min_allowed = last_emitted_at
64 .checked_add(self.interval())
65 .unwrap_or(last_emitted_at);
66 requested.max(min_allowed)
67 }
68
69 /// Records that a draw was emitted at `emitted_at`.
70 pub fn mark_emitted(&mut self, emitted_at: Instant) {
71 self.last_emitted_at = Some(emitted_at);
72 }
73
74 /// `Some(d)` if the next draw must wait `d` from `now`. `None` if a draw
75 /// is allowed right now. Used by the render loop to shorten its poll
76 /// timeout so it wakes up exactly when drawing is allowed.
77 #[must_use]
78 pub fn time_until_next_draw(&self, now: Instant) -> Option<Duration> {
79 let clamped = self.clamp_deadline(now);
80 if clamped <= now {
81 None
82 } else {
83 Some(clamped - now)
84 }
85 }
86
87 /// Set low-motion mode: caps frame rate at 30 FPS instead of 120 FPS.
88 pub fn set_low_motion(&mut self, low_motion: bool) {
89 self.low_motion = low_motion;
90 }
91
92 /// Apply a measured display-refresh interval. Low-motion still wins.
93 /// When `interval` is `None`, keep the historical fixed caps.
94 pub fn set_adaptive_interval(&mut self, interval: Option<Duration>) {
95 self.adaptive_interval = interval;
96 }
97
98 fn interval(&self) -> Duration {
99 if self.low_motion {
100 return LOW_MOTION_MIN_FRAME_INTERVAL;
101 }
102 self.adaptive_interval
103 .unwrap_or(MIN_FRAME_INTERVAL)
104 .max(MIN_FRAME_INTERVAL)
105 }
106 }
107
108 #[cfg(test)]
109 mod tests {
110 use super::*;
111
112 #[test]
113 fn default_does_not_clamp() {
114 let t0 = Instant::now();
115 let limiter = FrameRateLimiter::default();
116 assert_eq!(limiter.clamp_deadline(t0), t0);
117 assert!(limiter.time_until_next_draw(t0).is_none());
118 }
119
120 #[test]
121 fn clamps_to_min_interval_since_last_emit() {
122 let t0 = Instant::now();
123 let mut limiter = FrameRateLimiter::default();
124
125 assert_eq!(limiter.clamp_deadline(t0), t0);
126 limiter.mark_emitted(t0);
127
128 let too_soon = t0 + Duration::from_millis(1);
129 assert_eq!(limiter.clamp_deadline(too_soon), t0 + MIN_FRAME_INTERVAL);
130 }
131
132 #[test]
133 fn time_until_next_draw_reports_remaining_window() {
134 let t0 = Instant::now();
135 let mut limiter = FrameRateLimiter::default();
136 limiter.mark_emitted(t0);
137
138 let after_4ms = t0 + Duration::from_millis(4);
139 let remaining = limiter.time_until_next_draw(after_4ms).unwrap();
140 // ≈ 4.33ms remaining (8.33 - 4)
141 assert!(
142 remaining > Duration::from_micros(4_000) && remaining < Duration::from_millis(5),
143 "expected ~4.33ms, got {remaining:?}"
144 );
145 }
146
147 #[test]
148 fn time_until_next_draw_none_after_interval_elapsed() {
149 let t0 = Instant::now();
150 let mut limiter = FrameRateLimiter::default();
151 limiter.mark_emitted(t0);
152
153 let well_past = t0 + Duration::from_millis(50);
154 assert!(limiter.time_until_next_draw(well_past).is_none());
155 }
156
157 #[test]
158 fn low_motion_clamps_to_30fps_interval() {
159 let t0 = Instant::now();
160 let mut limiter = FrameRateLimiter::default();
161 limiter.set_low_motion(true);
162 limiter.mark_emitted(t0);
163
164 let too_soon = t0 + Duration::from_millis(5);
165 // Under 30 FPS (~33.33 ms), a draw 5 ms after last emit is clamped.
166 assert_eq!(
167 limiter.clamp_deadline(too_soon),
168 t0 + LOW_MOTION_MIN_FRAME_INTERVAL
169 );
170
171 // After 34 ms, draw is allowed.
172 let after_34 = t0 + Duration::from_millis(34);
173 assert!(limiter.time_until_next_draw(after_34).is_none());
174 }
175
176 #[test]
177 fn adaptive_interval_clamps_but_never_under_min() {
178 let t0 = Instant::now();
179 let mut limiter = FrameRateLimiter::default();
180 // Ask for 200 FPS (5ms) — must still respect MIN_FRAME_INTERVAL.
181 limiter.set_adaptive_interval(Some(Duration::from_millis(5)));
182 limiter.mark_emitted(t0);
183 let too_soon = t0 + Duration::from_millis(1);
184 assert_eq!(limiter.clamp_deadline(too_soon), t0 + MIN_FRAME_INTERVAL);
185 }
186
187 #[test]
188 fn low_motion_wins_over_adaptive_interval() {
189 let t0 = Instant::now();
190 let mut limiter = FrameRateLimiter::default();
191 limiter.set_adaptive_interval(Some(Duration::from_millis(5)));
192 limiter.set_low_motion(true);
193 limiter.mark_emitted(t0);
194 let too_soon = t0 + Duration::from_millis(5);
195 assert_eq!(
196 limiter.clamp_deadline(too_soon),
197 t0 + LOW_MOTION_MIN_FRAME_INTERVAL
198 );
199 }
200
201 #[test]
202 fn low_motion_switching_respects_current_mode() {
203 let t0 = Instant::now();
204 let mut limiter = FrameRateLimiter::default();
205
206 // Default (120 FPS): mark at t0, 10 ms later is clamped to ~8.33ms
207 limiter.mark_emitted(t0);
208 let t10 = t0 + Duration::from_millis(10);
209 assert!(limiter.time_until_next_draw(t10).is_none()); // 10ms > 8.33ms
210
211 // Switch to low_motion; mark again
212 limiter.set_low_motion(true);
213 limiter.mark_emitted(t10);
214 let t20 = t10 + Duration::from_millis(10);
215 let remaining = limiter.time_until_next_draw(t20).unwrap();
216 // 30 FPS = 33.33 ms interval; 10ms elapsed → ~23.33 remaining
217 assert!(
218 remaining > Duration::from_millis(20) && remaining < Duration::from_millis(25),
219 "expected ~23.33ms remaining, got {remaining:?}"
220 );
221 }
222 }
223
223 lines RUST