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