返回 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::time::{Duration, Instant};
8
9 /// Inclusive lower bound for accepted refresh rates.
10 pub const MIN_HZ: u32 = 30;
11 /// Inclusive upper bound for accepted refresh rates.
12 pub const MAX_HZ: u32 = 240;
13 /// Safe fallback when probing is skipped or fails (≈8 fps / 120 ms underwater
14 /// atmosphere — not the draw-rate cap). Calmed from the historical 80 ms for
15 /// v0.9.4: the field still breathes, but the ambient cadence no longer feels
16 /// restless next to real content.
17 pub const FALLBACK_ANIMATION_MS: u64 = 120;
18 /// Absolute floor for adaptive animation intervals (≈ 4 fps).
19 pub const MIN_ANIMATION_HZ: u32 = 4;
20 /// Absolute ceiling for adaptive animation intervals (≈ 30 fps).
21 pub const MAX_ANIMATION_HZ: u32 = 30;
22 /// Ghostty keeps interactive feedback responsive, but ambient water must not
23 /// force a full-screen 60 FPS repaint while the user is idle.
24 pub const GHOSTTY_ATMOSPHERE_HZ: u32 = 30;
25 pub const GHOSTTY_INTERACTIVE_HZ: u32 = 60;
26
27 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
28 pub enum DisplayRefreshSource {
29 None,
30 // Only constructed inside `#[cfg(target_os = "macos")]` in `probe_inner`
31 // below; non-macOS builds never build a value of this variant.
32 #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
33 MacosCoreGraphics,
34 EnvOverride,
35 }
36
37 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
38 pub struct DisplayRefreshProbeResult {
39 pub hz: Option<u32>,
40 pub source: DisplayRefreshSource,
41 /// Stable skip/error token when `hz` is `None`.
42 pub skip_reason: &'static str,
43 pub duration_ms: u64,
44 }
45
46 impl DisplayRefreshProbeResult {
47 #[must_use]
48 pub fn outcome(self) -> &'static str {
49 if self.hz.is_some() {
50 "ok"
51 } else if self.skip_reason == "error" {
52 "error"
53 } else {
54 "skipped"
55 }
56 }
57 }
58
59 // Keep outcome() public for diagnostics even when the TUI only logs the probe
60 // struct fields today.
61 const _: fn(DisplayRefreshProbeResult) -> &'static str = DisplayRefreshProbeResult::outcome;
62
63 /// Once per process. Infallible.
64 ///
65 /// Under `cfg(test)` the host panel is not consulted. The probe is
66 /// `OnceLock`-cached and reads real hardware, so a cadence test on a 60 Hz
67 /// developer machine computed a different interval than the same test on CI,
68 /// with no way to pin the input the way `low_motion` and `fancy_animations`
69 /// are already pinned (#5359). Tests get the unmeasured result — the same one
70 /// a headless CI runner sees — and any test that wants a specific panel says
71 /// so with [`DisplayRefreshPin`].
72 pub fn probe_display_refresh() -> DisplayRefreshProbeResult {
73 #[cfg(test)]
74 {
75 pinned_probe()
76 }
77 #[cfg(not(test))]
78 {
79 static CACHE: std::sync::OnceLock<DisplayRefreshProbeResult> = std::sync::OnceLock::new();
80 *CACHE.get_or_init(probe_uncached)
81 }
82 }
83
84 /// What a test observes when it has not pinned a panel: no measurement, so
85 /// [`animation_interval_for_hz`] falls back to [`FALLBACK_ANIMATION_MS`].
86 #[cfg(test)]
87 const UNMEASURED: DisplayRefreshProbeResult = DisplayRefreshProbeResult {
88 hz: None,
89 source: DisplayRefreshSource::None,
90 skip_reason: "not_probed_under_test",
91 duration_ms: 0,
92 };
93
94 #[cfg(test)]
95 thread_local! {
96 static PINNED: std::cell::Cell<Option<DisplayRefreshProbeResult>> =
97 const { std::cell::Cell::new(None) };
98 }
99
100 #[cfg(test)]
101 fn pinned_probe() -> DisplayRefreshProbeResult {
102 PINNED.with(|pinned| pinned.get().unwrap_or(UNMEASURED))
103 }
104
105 /// Pin the probe for the current thread until dropped.
106 ///
107 /// Thread-local rather than process-global: the cadence tests run in parallel
108 /// with everything else, and a shared cell would let one test's panel decide
109 /// another's interval.
110 #[cfg(test)]
111 pub(crate) struct DisplayRefreshPin {
112 previous: Option<DisplayRefreshProbeResult>,
113 }
114
115 #[cfg(test)]
116 impl DisplayRefreshPin {
117 /// Pin a measured panel at `hz`, as if the host reported it.
118 pub(crate) fn measured(hz: u32) -> Self {
119 let accepted = accept_hz(hz);
120 Self::install(DisplayRefreshProbeResult {
121 hz: accepted,
122 source: DisplayRefreshSource::EnvOverride,
123 skip_reason: if accepted.is_some() {
124 ""
125 } else {
126 "out_of_range"
127 },
128 duration_ms: 0,
129 })
130 }
131
132 fn install(next: DisplayRefreshProbeResult) -> Self {
133 let previous = PINNED.with(|pinned| pinned.replace(Some(next)));
134 Self { previous }
135 }
136 }
137
138 #[cfg(test)]
139 impl Drop for DisplayRefreshPin {
140 fn drop(&mut self) {
141 PINNED.with(|pinned| pinned.set(self.previous));
142 }
143 }
144
145 fn probe_uncached() -> DisplayRefreshProbeResult {
146 let start = Instant::now();
147 let (hz, source, skip_reason) = probe_inner();
148 DisplayRefreshProbeResult {
149 hz,
150 source,
151 skip_reason,
152 duration_ms: start.elapsed().as_millis() as u64,
153 }
154 }
155
156 fn probe_inner() -> (Option<u32>, DisplayRefreshSource, &'static str) {
157 if let Some(hz) = env_override_hz() {
158 return match accept_hz(hz) {
159 Some(hz) => (Some(hz), DisplayRefreshSource::EnvOverride, ""),
160 None => (None, DisplayRefreshSource::EnvOverride, "out_of_range"),
161 };
162 }
163 if is_remote_session() {
164 return (None, DisplayRefreshSource::None, "ssh");
165 }
166 #[cfg(target_os = "macos")]
167 {
168 match probe_macos() {
169 Ok(hz) => match accept_hz(hz) {
170 Some(hz) => (Some(hz), DisplayRefreshSource::MacosCoreGraphics, ""),
171 None => (
172 None,
173 DisplayRefreshSource::MacosCoreGraphics,
174 "out_of_range",
175 ),
176 },
177 Err(reason) => (None, DisplayRefreshSource::MacosCoreGraphics, reason),
178 }
179 }
180 #[cfg(not(target_os = "macos"))]
181 {
182 (None, DisplayRefreshSource::None, "unsupported")
183 }
184 }
185
186 fn env_override_hz() -> Option<u32> {
187 let raw = std::env::var("CODEWHALE_DISPLAY_HZ").ok()?;
188 raw.trim().parse().ok()
189 }
190
191 fn is_remote_session() -> bool {
192 std::env::var_os("SSH_CONNECTION").is_some()
193 || std::env::var_os("SSH_CLIENT").is_some()
194 || std::env::var_os("SSH_TTY").is_some()
195 }
196
197 fn terminal_is_ghostty_values(term_program: &str, term: &str) -> bool {
198 term_program.trim().eq_ignore_ascii_case("ghostty")
199 || term.to_ascii_lowercase().contains("ghostty")
200 }
201
202 /// Whether the current terminal is Ghostty. Tests keep this uncached because
203 /// they pin environment values; production caches the immutable startup env.
204 #[must_use]
205 pub fn terminal_is_ghostty() -> bool {
206 #[cfg(test)]
207 {
208 terminal_is_ghostty_values(
209 &std::env::var("TERM_PROGRAM").unwrap_or_default(),
210 &std::env::var("TERM").unwrap_or_default(),
211 )
212 }
213 #[cfg(not(test))]
214 {
215 static CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
216 *CACHE.get_or_init(|| {
217 terminal_is_ghostty_values(
218 &std::env::var("TERM_PROGRAM").unwrap_or_default(),
219 &std::env::var("TERM").unwrap_or_default(),
220 )
221 })
222 }
223 }
224
225 fn accept_hz(hz: u32) -> Option<u32> {
226 if (MIN_HZ..=MAX_HZ).contains(&hz) {
227 Some(hz)
228 } else {
229 None
230 }
231 }
232
233 #[cfg(target_os = "macos")]
234 fn probe_macos() -> Result<u32, &'static str> {
235 // CoreGraphics is available on macOS; use a minimal safe FFI for the main
236 // display mode. Fail closed on any error.
237 //
238 // CGDisplayModeGetRefreshRate returns 0 for some virtual displays — treat
239 // that as skipped rather than forcing a zero cadence.
240 unsafe extern "C" {
241 fn CGMainDisplayID() -> u32;
242 fn CGDisplayCopyDisplayMode(display: u32) -> *mut std::ffi::c_void;
243 fn CGDisplayModeGetRefreshRate(mode: *mut std::ffi::c_void) -> f64;
244 fn CGDisplayModeRelease(mode: *mut std::ffi::c_void);
245 }
246 // SAFETY: `mode` is null-checked and released after use.
247 unsafe {
248 let display = CGMainDisplayID();
249 let mode = CGDisplayCopyDisplayMode(display);
250 if mode.is_null() {
251 return Err("no_mode");
252 }
253 let rate = CGDisplayModeGetRefreshRate(mode);
254 CGDisplayModeRelease(mode);
255 if !rate.is_finite() || rate <= 0.0 {
256 return Err("zero_rate");
257 }
258 let hz = rate.round() as u32;
259 if hz == 0 {
260 return Err("zero_rate");
261 }
262 Ok(hz)
263 }
264 }
265
266 /// Convert a measured display Hz into a bounded animation interval.
267 ///
268 /// Policy: target roughly `display_hz / 8` for atmosphere (calm, not steppy
269 /// on high-Hz panels), clamped to [`MIN_ANIMATION_HZ`]..=[`MAX_ANIMATION_HZ`].
270 /// Missing measurement falls back to [`FALLBACK_ANIMATION_MS`] (≈8 fps /
271 /// 120 ms atmosphere). `low_motion` always wins (2.4s).
272 #[must_use]
273 pub fn animation_interval_for_hz(display_hz: Option<u32>, low_motion: bool) -> Duration {
274 if low_motion {
275 return Duration::from_millis(2_400);
276 }
277 match display_hz {
278 // No measurement, or a standard 60 Hz panel: keep the 120 ms
279 // atmosphere cadence so low-Hz hosts do not feel steppier.
280 None | Some(0..=60) => Duration::from_millis(FALLBACK_ANIMATION_MS),
281 // High-Hz panels: ~1/8 of refresh, bounded so we never thrash or stall.
282 Some(hz) => {
283 let target = (hz / 8).clamp(MIN_ANIMATION_HZ, MAX_ANIMATION_HZ);
284 let ms = (1000u32 / target.max(1)).max(1);
285 // Never slower than the fallback (only raise cadence).
286 Duration::from_millis(u64::from(ms).min(FALLBACK_ANIMATION_MS))
287 }
288 }
289 }
290
291 /// Convenience: probe once and return the animation interval for the current
292 /// motion policy. Safe to call every frame — probe is OnceLock-cached.
293 #[must_use]
294 pub fn adaptive_animation_interval_ms(low_motion: bool) -> u64 {
295 let probe = probe_display_refresh();
296 animation_interval_for_hz(probe.hz, low_motion).as_millis() as u64
297 }
298
299 /// Map measured Hz into a frame-rate limiter minimum interval, never exceeding
300 /// the historical 120 FPS draw cap and never undercutting low-motion 30 FPS.
301 #[must_use]
302 pub fn draw_min_interval_for_hz(display_hz: Option<u32>, low_motion: bool) -> Duration {
303 use super::frame_rate_limiter::{LOW_MOTION_MIN_FRAME_INTERVAL, MIN_FRAME_INTERVAL};
304 if low_motion {
305 return LOW_MOTION_MIN_FRAME_INTERVAL;
306 }
307 let Some(hz) = display_hz else {
308 return MIN_FRAME_INTERVAL;
309 };
310 // Cap draw rate at min(display_hz, 120). Never faster than MIN_FRAME_INTERVAL.
311 let capped = hz.clamp(30, 120);
312 let nanos = 1_000_000_000u64 / u64::from(capped);
313 Duration::from_nanos(nanos).max(MIN_FRAME_INTERVAL)
314 }
315
316 /// Content-driven draw cadence: atmosphere rate when only ambience moves;
317 /// full rate for stream / selection / input / hover.
318 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
319 pub enum DrawCadenceTier {
320 /// Only ambient life / ocean breath — use atmosphere interval.
321 Atmosphere,
322 /// Streaming, selection, input, or interactive hover — full draw cap.
323 Interactive,
324 }
325
326 /// Choose the draw min-interval for the current content tier.
327 #[must_use]
328 pub fn content_driven_draw_interval(
329 tier: DrawCadenceTier,
330 display_hz: Option<u32>,
331 low_motion: bool,
332 ) -> Duration {
333 if !low_motion && terminal_is_ghostty() {
334 let hz = match tier {
335 DrawCadenceTier::Atmosphere => GHOSTTY_ATMOSPHERE_HZ,
336 DrawCadenceTier::Interactive => GHOSTTY_INTERACTIVE_HZ,
337 };
338 return Duration::from_nanos(1_000_000_000u64 / u64::from(hz));
339 }
340 match tier {
341 DrawCadenceTier::Atmosphere => animation_interval_for_hz(display_hz, low_motion),
342 DrawCadenceTier::Interactive => draw_min_interval_for_hz(display_hz, low_motion),
343 }
344 }
345
346 /// Infer cadence tier from coarse app activity signals.
347 #[must_use]
348 pub fn cadence_tier_from_signals(
349 streaming_or_loading: bool,
350 selection_active: bool,
351 input_nonempty: bool,
352 pointer_hover_active: bool,
353 ) -> DrawCadenceTier {
354 if streaming_or_loading || selection_active || input_nonempty || pointer_hover_active {
355 DrawCadenceTier::Interactive
356 } else {
357 DrawCadenceTier::Atmosphere
358 }
359 }
360
361 #[cfg(test)]
362 mod tests {
363 use super::*;
364 use crate::tui::frame_rate_limiter::{LOW_MOTION_MIN_FRAME_INTERVAL, MIN_FRAME_INTERVAL};
365
366 #[test]
367 fn falls_back_to_default_when_probe_has_no_hz() {
368 let interval = animation_interval_for_hz(None, false);
369 assert_eq!(interval, Duration::from_millis(FALLBACK_ANIMATION_MS));
370 assert_eq!(FALLBACK_ANIMATION_MS, 120);
371 }
372
373 #[test]
374 fn low_motion_wins_over_measured_hz() {
375 let interval = animation_interval_for_hz(Some(144), true);
376 assert_eq!(interval, Duration::from_millis(2_400));
377 }
378
379 #[test]
380 fn high_hz_display_raises_cadence_but_stays_bounded() {
381 let interval = animation_interval_for_hz(Some(144), false);
382 // 144/8 = 18 → ~55ms: calmer than the old 1/5 divisor, still smooth.
383 assert!(interval >= Duration::from_millis(33));
384 assert!(interval <= Duration::from_millis(250));
385 }
386
387 #[test]
388 fn sixty_hz_keeps_historical_atmosphere_cadence() {
389 let interval = animation_interval_for_hz(Some(60), false);
390 // Standard panels stay on the 120 ms atmosphere floor.
391 assert_eq!(interval, Duration::from_millis(FALLBACK_ANIMATION_MS));
392 }
393
394 #[test]
395 fn ghostty_separates_ambient_and_interactive_cadence() {
396 let _guard = crate::test_support::lock_test_env();
397 let previous_program = std::env::var_os("TERM_PROGRAM");
398 let previous_term = std::env::var_os("TERM");
399 // SAFETY: serialized by the process-wide test environment lock.
400 unsafe {
401 std::env::set_var("TERM_PROGRAM", "Ghostty");
402 std::env::set_var("TERM", "xterm-ghostty");
403 }
404 assert_eq!(
405 content_driven_draw_interval(DrawCadenceTier::Atmosphere, Some(120), false),
406 Duration::from_nanos(1_000_000_000 / 30)
407 );
408 assert_eq!(
409 content_driven_draw_interval(DrawCadenceTier::Interactive, Some(120), false),
410 Duration::from_nanos(1_000_000_000 / 60)
411 );
412 // SAFETY: cleanup under the same lock.
413 unsafe {
414 match previous_program {
415 Some(value) => std::env::set_var("TERM_PROGRAM", value),
416 None => std::env::remove_var("TERM_PROGRAM"),
417 }
418 match previous_term {
419 Some(value) => std::env::set_var("TERM", value),
420 None => std::env::remove_var("TERM"),
421 }
422 }
423 }
424
425 #[test]
426 fn accept_hz_rejects_out_of_range() {
427 assert_eq!(accept_hz(10), None);
428 assert_eq!(accept_hz(60), Some(60));
429 assert_eq!(accept_hz(500), None);
430 }
431
432 #[test]
433 fn draw_cap_never_exceeds_historical_min_interval() {
434 let interval = draw_min_interval_for_hz(Some(240), false);
435 assert!(interval >= MIN_FRAME_INTERVAL);
436 }
437
438 #[test]
439 fn draw_cap_respects_low_motion() {
440 assert_eq!(
441 draw_min_interval_for_hz(Some(144), true),
442 LOW_MOTION_MIN_FRAME_INTERVAL
443 );
444 }
445
446 #[test]
447 fn probe_is_infallible_and_cached() {
448 let a = probe_display_refresh();
449 let b = probe_display_refresh();
450 assert_eq!(a, b);
451 // Either measured or skipped — never panics.
452 assert!(a.outcome() == "ok" || a.outcome() == "skipped" || a.outcome() == "error");
453 }
454
455 /// The real host probe, which `probe_display_refresh` no longer reaches
456 /// under test. Its result is whatever this machine reports, so assert only
457 /// the invariants that hold everywhere — but keep calling it, so the FFI
458 /// and env-override paths stay compiled and exercised rather than becoming
459 /// dead code the moment tests stopped consulting the panel.
460 #[test]
461 fn the_host_probe_itself_stays_infallible_and_in_range() {
462 let probe = probe_uncached();
463 assert!(
464 probe.outcome() == "ok" || probe.outcome() == "skipped" || probe.outcome() == "error"
465 );
466 if let Some(hz) = probe.hz {
467 assert!(
468 (MIN_HZ..=MAX_HZ).contains(&hz),
469 "{hz} outside accepted range"
470 );
471 assert!(probe.skip_reason.is_empty());
472 } else {
473 assert!(!probe.skip_reason.is_empty(), "a skip must name its reason");
474 }
475 }
476
477 #[test]
478 fn unpinned_tests_never_see_the_host_panel() {
479 let probe = probe_display_refresh();
480 assert_eq!(probe.hz, None, "a test must not inherit the developer's Hz");
481 assert_eq!(probe.skip_reason, "not_probed_under_test");
482 assert_eq!(
483 adaptive_animation_interval_ms(false),
484 FALLBACK_ANIMATION_MS,
485 "the unmeasured fallback is what CI computes"
486 );
487 }
488
489 #[test]
490 fn a_pinned_panel_drives_the_cadence_and_is_restored_on_drop() {
491 assert_eq!(probe_display_refresh().hz, None);
492 {
493 let _pin = DisplayRefreshPin::measured(144);
494 assert_eq!(probe_display_refresh().hz, Some(144));
495 assert_eq!(
496 adaptive_animation_interval_ms(false),
497 animation_interval_for_hz(Some(144), false).as_millis() as u64
498 );
499 assert!(adaptive_animation_interval_ms(false) < FALLBACK_ANIMATION_MS);
500 }
501 assert_eq!(
502 probe_display_refresh().hz,
503 None,
504 "the pin must not outlive its scope"
505 );
506 }
507
508 #[test]
509 fn a_pin_outside_the_accepted_range_reads_as_unmeasured() {
510 let _pin = DisplayRefreshPin::measured(1);
511 let probe = probe_display_refresh();
512 assert_eq!(probe.hz, None);
513 assert_eq!(probe.skip_reason, "out_of_range");
514 assert_eq!(adaptive_animation_interval_ms(false), FALLBACK_ANIMATION_MS);
515 }
516 }
517
517 lines RUST