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