| 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 |