返回 CodeWhale
display_refresh.rs
根目录 / crates / tui / src / tui / display_refresh.rs
1 //! One-shot primary-display refresh probe with fail-closed defaults.
2 //!
3 //! Adapted from Grok's host display-refresh probe: measure once per process,
4 //! clamp to sane bounds, and never panic into the render loop. SSH / missing
5 //! FFI paths fall back to the fixed [`crate::tui::frame_rate_limiter`] defaults.
6
7 use std::sync::OnceLock;
8 use std::time::{Duration, Instant};
9
10 /// Inclusive lower bound for accepted refresh rates.
11 pub const MIN_HZ: u32 = 30;
12 /// Inclusive upper bound for accepted refresh rates.
13 pub const MAX_HZ: u32 = 240;
14 /// Safe fallback when probing is skipped or fails (≈8 fps / 120 ms underwater
15 /// atmosphere — not the draw-rate cap). Calmed from the historical 80 ms for
16 /// v0.9.4: the field still breathes, but the ambient cadence no longer feels
17 /// restless next to real content.
18 pub const FALLBACK_ANIMATION_MS: u64 = 120;
19 /// Absolute floor for adaptive animation intervals (≈ 4 fps).
20 pub const MIN_ANIMATION_HZ: u32 = 4;
21 /// Absolute ceiling for adaptive animation intervals (≈ 30 fps).
22 pub const MAX_ANIMATION_HZ: u32 = 30;
23
24 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
25 pub enum DisplayRefreshSource {
26 None,
27 // Only constructed inside `#[cfg(target_os = "macos")]` in `probe_inner`
28 // below; non-macOS builds never build a value of this variant.
29 #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
30 MacosCoreGraphics,
31 EnvOverride,
32 }
33
34 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
35 pub struct DisplayRefreshProbeResult {
36 pub hz: Option<u32>,
37 pub source: DisplayRefreshSource,
38 /// Stable skip/error token when `hz` is `None`.
39 pub skip_reason: &'static str,
40 pub duration_ms: u64,
41 }
42
43 impl DisplayRefreshProbeResult {
44 #[must_use]
45 pub fn outcome(self) -> &'static str {
46 if self.hz.is_some() {
47 "ok"
48 } else if self.skip_reason == "error" {
49 "error"
50 } else {
51 "skipped"
52 }
53 }
54 }
55
56 // Keep outcome() public for diagnostics even when the TUI only logs the probe
57 // struct fields today.
58 const _: fn(DisplayRefreshProbeResult) -> &'static str = DisplayRefreshProbeResult::outcome;
59
60 /// Once per process. Infallible.
61 pub fn probe_display_refresh() -> DisplayRefreshProbeResult {
62 static CACHE: OnceLock<DisplayRefreshProbeResult> = OnceLock::new();
63 *CACHE.get_or_init(probe_uncached)
64 }
65
66 fn probe_uncached() -> DisplayRefreshProbeResult {
67 let start = Instant::now();
68 let (hz, source, skip_reason) = probe_inner();
69 DisplayRefreshProbeResult {
70 hz,
71 source,
72 skip_reason,
73 duration_ms: start.elapsed().as_millis() as u64,
74 }
75 }
76
77 fn probe_inner() -> (Option<u32>, DisplayRefreshSource, &'static str) {
78 if let Some(hz) = env_override_hz() {
79 return match accept_hz(hz) {
80 Some(hz) => (Some(hz), DisplayRefreshSource::EnvOverride, ""),
81 None => (None, DisplayRefreshSource::EnvOverride, "out_of_range"),
82 };
83 }
84 if is_remote_session() {
85 return (None, DisplayRefreshSource::None, "ssh");
86 }
87 #[cfg(target_os = "macos")]
88 {
89 match probe_macos() {
90 Ok(hz) => match accept_hz(hz) {
91 Some(hz) => (Some(hz), DisplayRefreshSource::MacosCoreGraphics, ""),
92 None => (
93 None,
94 DisplayRefreshSource::MacosCoreGraphics,
95 "out_of_range",
96 ),
97 },
98 Err(reason) => (None, DisplayRefreshSource::MacosCoreGraphics, reason),
99 }
100 }
101 #[cfg(not(target_os = "macos"))]
102 {
103 (None, DisplayRefreshSource::None, "unsupported")
104 }
105 }
106
107 fn env_override_hz() -> Option<u32> {
108 let raw = std::env::var("CODEWHALE_DISPLAY_HZ").ok()?;
109 raw.trim().parse().ok()
110 }
111
112 fn is_remote_session() -> bool {
113 std::env::var_os("SSH_CONNECTION").is_some()
114 || std::env::var_os("SSH_CLIENT").is_some()
115 || std::env::var_os("SSH_TTY").is_some()
116 }
117
118 fn accept_hz(hz: u32) -> Option<u32> {
119 if (MIN_HZ..=MAX_HZ).contains(&hz) {
120 Some(hz)
121 } else {
122 None
123 }
124 }
125
126 #[cfg(target_os = "macos")]
127 fn probe_macos() -> Result<u32, &'static str> {
128 // CoreGraphics is available on macOS; use a minimal safe FFI for the main
129 // display mode. Fail closed on any error.
130 //
131 // CGDisplayModeGetRefreshRate returns 0 for some virtual displays — treat
132 // that as skipped rather than forcing a zero cadence.
133 unsafe extern "C" {
134 fn CGMainDisplayID() -> u32;
135 fn CGDisplayCopyDisplayMode(display: u32) -> *mut std::ffi::c_void;
136 fn CGDisplayModeGetRefreshRate(mode: *mut std::ffi::c_void) -> f64;
137 fn CGDisplayModeRelease(mode: *mut std::ffi::c_void);
138 }
139 unsafe {
140 let display = CGMainDisplayID();
141 let mode = CGDisplayCopyDisplayMode(display);
142 if mode.is_null() {
143 return Err("no_mode");
144 }
145 let rate = CGDisplayModeGetRefreshRate(mode);
146 CGDisplayModeRelease(mode);
147 if !rate.is_finite() || rate <= 0.0 {
148 return Err("zero_rate");
149 }
150 let hz = rate.round() as u32;
151 if hz == 0 {
152 return Err("zero_rate");
153 }
154 Ok(hz)
155 }
156 }
157
158 /// Convert a measured display Hz into a bounded animation interval.
159 ///
160 /// Policy: target roughly `display_hz / 8` for atmosphere (calm, not steppy
161 /// on high-Hz panels), clamped to [`MIN_ANIMATION_HZ`]..=[`MAX_ANIMATION_HZ`].
162 /// Missing measurement falls back to [`FALLBACK_ANIMATION_MS`] (≈8 fps /
163 /// 120 ms atmosphere). `low_motion` always wins (2.4s).
164 #[must_use]
165 pub fn animation_interval_for_hz(display_hz: Option<u32>, low_motion: bool) -> Duration {
166 if low_motion {
167 return Duration::from_millis(2_400);
168 }
169 match display_hz {
170 // No measurement, or a standard 60 Hz panel: keep the 120 ms
171 // atmosphere cadence so low-Hz hosts do not feel steppier.
172 None | Some(0..=60) => Duration::from_millis(FALLBACK_ANIMATION_MS),
173 // High-Hz panels: ~1/8 of refresh, bounded so we never thrash or stall.
174 Some(hz) => {
175 let target = (hz / 8).clamp(MIN_ANIMATION_HZ, MAX_ANIMATION_HZ);
176 let ms = (1000u32 / target.max(1)).max(1);
177 // Never slower than the fallback (only raise cadence).
178 Duration::from_millis(u64::from(ms).min(FALLBACK_ANIMATION_MS))
179 }
180 }
181 }
182
183 /// Convenience: probe once and return the animation interval for the current
184 /// motion policy. Safe to call every frame — probe is OnceLock-cached.
185 #[must_use]
186 pub fn adaptive_animation_interval_ms(low_motion: bool) -> u64 {
187 let probe = probe_display_refresh();
188 animation_interval_for_hz(probe.hz, low_motion).as_millis() as u64
189 }
190
191 /// Map measured Hz into a frame-rate limiter minimum interval, never exceeding
192 /// the historical 120 FPS draw cap and never undercutting low-motion 30 FPS.
193 #[must_use]
194 pub fn draw_min_interval_for_hz(display_hz: Option<u32>, low_motion: bool) -> Duration {
195 use super::frame_rate_limiter::{LOW_MOTION_MIN_FRAME_INTERVAL, MIN_FRAME_INTERVAL};
196 if low_motion {
197 return LOW_MOTION_MIN_FRAME_INTERVAL;
198 }
199 let Some(hz) = display_hz else {
200 return MIN_FRAME_INTERVAL;
201 };
202 // Cap draw rate at min(display_hz, 120). Never faster than MIN_FRAME_INTERVAL.
203 let capped = hz.clamp(30, 120);
204 let nanos = 1_000_000_000u64 / u64::from(capped);
205 Duration::from_nanos(nanos).max(MIN_FRAME_INTERVAL)
206 }
207
208 /// Content-driven draw cadence: atmosphere rate when only ambience moves;
209 /// full rate for stream / selection / input / hover.
210 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
211 pub enum DrawCadenceTier {
212 /// Only ambient life / ocean breath — use atmosphere interval.
213 Atmosphere,
214 /// Streaming, selection, input, or interactive hover — full draw cap.
215 Interactive,
216 }
217
218 /// Choose the draw min-interval for the current content tier.
219 #[must_use]
220 pub fn content_driven_draw_interval(
221 tier: DrawCadenceTier,
222 display_hz: Option<u32>,
223 low_motion: bool,
224 ) -> Duration {
225 match tier {
226 DrawCadenceTier::Atmosphere => animation_interval_for_hz(display_hz, low_motion),
227 DrawCadenceTier::Interactive => draw_min_interval_for_hz(display_hz, low_motion),
228 }
229 }
230
231 /// Infer cadence tier from coarse app activity signals.
232 #[must_use]
233 pub fn cadence_tier_from_signals(
234 streaming_or_loading: bool,
235 selection_active: bool,
236 input_nonempty: bool,
237 pointer_hover_active: bool,
238 ) -> DrawCadenceTier {
239 if streaming_or_loading || selection_active || input_nonempty || pointer_hover_active {
240 DrawCadenceTier::Interactive
241 } else {
242 DrawCadenceTier::Atmosphere
243 }
244 }
245
246 #[cfg(test)]
247 mod tests {
248 use super::*;
249 use crate::tui::frame_rate_limiter::{LOW_MOTION_MIN_FRAME_INTERVAL, MIN_FRAME_INTERVAL};
250
251 #[test]
252 fn falls_back_to_default_when_probe_has_no_hz() {
253 let interval = animation_interval_for_hz(None, false);
254 assert_eq!(interval, Duration::from_millis(FALLBACK_ANIMATION_MS));
255 assert_eq!(FALLBACK_ANIMATION_MS, 120);
256 }
257
258 #[test]
259 fn low_motion_wins_over_measured_hz() {
260 let interval = animation_interval_for_hz(Some(144), true);
261 assert_eq!(interval, Duration::from_millis(2_400));
262 }
263
264 #[test]
265 fn high_hz_display_raises_cadence_but_stays_bounded() {
266 let interval = animation_interval_for_hz(Some(144), false);
267 // 144/8 = 18 → ~55ms: calmer than the old 1/5 divisor, still smooth.
268 assert!(interval >= Duration::from_millis(33));
269 assert!(interval <= Duration::from_millis(250));
270 }
271
272 #[test]
273 fn sixty_hz_keeps_historical_atmosphere_cadence() {
274 let interval = animation_interval_for_hz(Some(60), false);
275 // Standard panels stay on the 120 ms atmosphere floor.
276 assert_eq!(interval, Duration::from_millis(FALLBACK_ANIMATION_MS));
277 }
278
279 #[test]
280 fn accept_hz_rejects_out_of_range() {
281 assert_eq!(accept_hz(10), None);
282 assert_eq!(accept_hz(60), Some(60));
283 assert_eq!(accept_hz(500), None);
284 }
285
286 #[test]
287 fn draw_cap_never_exceeds_historical_min_interval() {
288 let interval = draw_min_interval_for_hz(Some(240), false);
289 assert!(interval >= MIN_FRAME_INTERVAL);
290 }
291
292 #[test]
293 fn draw_cap_respects_low_motion() {
294 assert_eq!(
295 draw_min_interval_for_hz(Some(144), true),
296 LOW_MOTION_MIN_FRAME_INTERVAL
297 );
298 }
299
300 #[test]
301 fn probe_is_infallible_and_cached() {
302 let a = probe_display_refresh();
303 let b = probe_display_refresh();
304 assert_eq!(a, b);
305 // Either measured or skipped — never panics.
306 assert!(a.outcome() == "ok" || a.outcome() == "skipped" || a.outcome() == "error");
307 }
308 }
309
309 lines RUST