返回 DeepSeek-TUI-2026
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 }
50
51 impl FrameRateLimiter {
52 /// Returns `requested`, clamped forward if it would exceed the maximum
53 /// frame rate.
54 #[must_use]
55 pub fn clamp_deadline(&self, requested: Instant) -> Instant {
56 let Some(last_emitted_at) = self.last_emitted_at else {
57 return requested;
58 };
59 let min_allowed = last_emitted_at
60 .checked_add(self.interval())
61 .unwrap_or(last_emitted_at);
62 requested.max(min_allowed)
63 }
64
65 /// Records that a draw was emitted at `emitted_at`.
66 pub fn mark_emitted(&mut self, emitted_at: Instant) {
67 self.last_emitted_at = Some(emitted_at);
68 }
69
70 /// `Some(d)` if the next draw must wait `d` from `now`. `None` if a draw
71 /// is allowed right now. Used by the render loop to shorten its poll
72 /// timeout so it wakes up exactly when drawing is allowed.
73 #[must_use]
74 pub fn time_until_next_draw(&self, now: Instant) -> Option<Duration> {
75 let clamped = self.clamp_deadline(now);
76 if clamped <= now {
77 None
78 } else {
79 Some(clamped - now)
80 }
81 }
82
83 /// Set low-motion mode: caps frame rate at 30 FPS instead of 120 FPS.
84 pub fn set_low_motion(&mut self, low_motion: bool) {
85 self.low_motion = low_motion;
86 }
87
88 fn interval(&self) -> Duration {
89 if self.low_motion {
90 LOW_MOTION_MIN_FRAME_INTERVAL
91 } else {
92 MIN_FRAME_INTERVAL
93 }
94 }
95 }
96
97 #[cfg(test)]
98 mod tests {
99 use super::*;
100
101 #[test]
102 fn default_does_not_clamp() {
103 let t0 = Instant::now();
104 let limiter = FrameRateLimiter::default();
105 assert_eq!(limiter.clamp_deadline(t0), t0);
106 assert!(limiter.time_until_next_draw(t0).is_none());
107 }
108
109 #[test]
110 fn clamps_to_min_interval_since_last_emit() {
111 let t0 = Instant::now();
112 let mut limiter = FrameRateLimiter::default();
113
114 assert_eq!(limiter.clamp_deadline(t0), t0);
115 limiter.mark_emitted(t0);
116
117 let too_soon = t0 + Duration::from_millis(1);
118 assert_eq!(limiter.clamp_deadline(too_soon), t0 + MIN_FRAME_INTERVAL);
119 }
120
121 #[test]
122 fn time_until_next_draw_reports_remaining_window() {
123 let t0 = Instant::now();
124 let mut limiter = FrameRateLimiter::default();
125 limiter.mark_emitted(t0);
126
127 let after_4ms = t0 + Duration::from_millis(4);
128 let remaining = limiter.time_until_next_draw(after_4ms).unwrap();
129 // ≈ 4.33ms remaining (8.33 - 4)
130 assert!(
131 remaining > Duration::from_micros(4_000) && remaining < Duration::from_millis(5),
132 "expected ~4.33ms, got {remaining:?}"
133 );
134 }
135
136 #[test]
137 fn time_until_next_draw_none_after_interval_elapsed() {
138 let t0 = Instant::now();
139 let mut limiter = FrameRateLimiter::default();
140 limiter.mark_emitted(t0);
141
142 let well_past = t0 + Duration::from_millis(50);
143 assert!(limiter.time_until_next_draw(well_past).is_none());
144 }
145
146 #[test]
147 fn low_motion_clamps_to_30fps_interval() {
148 let t0 = Instant::now();
149 let mut limiter = FrameRateLimiter::default();
150 limiter.set_low_motion(true);
151 limiter.mark_emitted(t0);
152
153 let too_soon = t0 + Duration::from_millis(5);
154 // Under 30 FPS (~33.33 ms), a draw 5 ms after last emit is clamped.
155 assert_eq!(
156 limiter.clamp_deadline(too_soon),
157 t0 + LOW_MOTION_MIN_FRAME_INTERVAL
158 );
159
160 // After 34 ms, draw is allowed.
161 let after_34 = t0 + Duration::from_millis(34);
162 assert!(limiter.time_until_next_draw(after_34).is_none());
163 }
164
165 #[test]
166 fn low_motion_switching_respects_current_mode() {
167 let t0 = Instant::now();
168 let mut limiter = FrameRateLimiter::default();
169
170 // Default (120 FPS): mark at t0, 10 ms later is clamped to ~8.33ms
171 limiter.mark_emitted(t0);
172 let t10 = t0 + Duration::from_millis(10);
173 assert!(limiter.time_until_next_draw(t10).is_none()); // 10ms > 8.33ms
174
175 // Switch to low_motion; mark again
176 limiter.set_low_motion(true);
177 limiter.mark_emitted(t10);
178 let t20 = t10 + Duration::from_millis(10);
179 let remaining = limiter.time_until_next_draw(t20).unwrap();
180 // 30 FPS = 33.33 ms interval; 10ms elapsed → ~23.33 remaining
181 assert!(
182 remaining > Duration::from_millis(20) && remaining < Duration::from_millis(25),
183 "expected ~23.33ms remaining, got {remaining:?}"
184 );
185 }
186 }
187
187 lines RUST