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