diff --git a/rio-window/src/platform_impl/input_rate.rs b/rio-window/src/platform_impl/input_rate.rs new file mode 100644 index 00000000..e96776f0 --- /dev/null +++ b/rio-window/src/platform_impl/input_rate.rs @@ -0,0 +1,83 @@ +//! Rate-gated "keep presenting after input" tracker. +//! +//! Replaces the single-timestamp `last_input_timestamp` pattern with +//! a rolling-window input-rate detector, matching Zed's +//! `InputRateTracker` (`zed/crates/gpui/src/window.rs:987-1027`). +//! +//! The single-timestamp version sustained a 1-second post-input +//! presentation window after *any* input. Combined with very cheap +//! per-frame work (e.g. Rio's new-v4 grid renderer), that forced +//! ~60+ vsync redraws after every keystroke, saturating the Metal +//! drawable pool on macOS and wasting power everywhere. +//! +//! This tracker only extends the sustain window when input arrives +//! at a *high rate* (≥ 60 events/sec in a 100 ms rolling window) — +//! the condition that actually causes ProMotion / VRR displays to +//! downclock. Single keystrokes or isolated mouse events don't +//! trigger the sustain. +//! +//! All platform backends (macOS, Wayland, X11, Windows) share this +//! struct. Single-threaded platforms wrap it in `RefCell`; Windows' +//! DwmFlush worker wraps it in `Mutex`. + +use std::time::{Duration, Instant}; + +#[derive(Debug, Clone)] +pub(crate) struct InputRateTracker { + timestamps: Vec, + window: Duration, + inputs_per_second: u32, + sustain_until: Instant, + sustain_duration: Duration, +} + +impl Default for InputRateTracker { + fn default() -> Self { + Self { + timestamps: Vec::new(), + window: Duration::from_millis(100), + inputs_per_second: 60, + sustain_until: Instant::now(), + sustain_duration: Duration::from_secs(1), + } + } +} + +impl InputRateTracker { + #[inline] + pub(crate) fn new() -> Self { + Self::default() + } + + /// Record an input event. Extends the post-input sustain window + /// only when input is arriving at ≥ `inputs_per_second` over the + /// rolling window — a single keystroke / lone mouse move does + /// not set the sustain. + pub(crate) fn record_input(&mut self) { + let now = Instant::now(); + self.timestamps.push(now); + self.prune_old_timestamps(now); + + // `min_events` = inputs_per_second × window_ms / 1000. For + // defaults (60/s, 100 ms) that's 6 events in the last 100 ms. + let min_events = + self.inputs_per_second as u128 * self.window.as_millis() / 1000; + if self.timestamps.len() as u128 >= min_events { + self.sustain_until = now + self.sustain_duration; + } + } + + /// `true` while the sustain window set by a recent high-rate + /// input burst is still active. Drives the "fire a redraw each + /// vsync even if the app isn't dirty" logic that keeps VRR + /// displays from downclocking. + #[inline] + pub(crate) fn is_high_rate(&self) -> bool { + Instant::now() < self.sustain_until + } + + fn prune_old_timestamps(&mut self, now: Instant) { + self.timestamps + .retain(|&t| now.duration_since(t) <= self.window); + } +} diff --git a/rio-window/src/platform_impl/linux/wayland/state.rs b/rio-window/src/platform_impl/linux/wayland/state.rs index ef37b638..524b50c8 100644 --- a/rio-window/src/platform_impl/linux/wayland/state.rs +++ b/rio-window/src/platform_impl/linux/wayland/state.rs @@ -116,19 +116,24 @@ pub struct WinitState { /// send `AboutToWait` and normally wakeup the user. pub dispatched_events: bool, - /// Timestamp of the most recent input event. - pub last_input_timestamp: std::cell::Cell, + /// Rate-gated post-input sustain tracker. Only sustained high-rate + /// input (≥ 60 events/sec over 100 ms) extends the 1-second + /// presentation window — single keystrokes don't. See + /// `platform_impl::input_rate`. + pub input_rate_tracker: std::cell::RefCell< + crate::platform_impl::input_rate::InputRateTracker, + >, } impl WinitState { #[inline] pub fn mark_input_received(&self) { - self.last_input_timestamp.set(std::time::Instant::now()); + self.input_rate_tracker.borrow_mut().record_input(); } #[inline] pub fn should_present_after_input(&self) -> bool { - self.last_input_timestamp.get().elapsed() < std::time::Duration::from_secs(1) + self.input_rate_tracker.borrow().is_high_rate() } } @@ -210,7 +215,9 @@ impl WinitState { loop_handle, // Make it true by default. dispatched_events: true, - last_input_timestamp: std::cell::Cell::new(std::time::Instant::now()), + input_rate_tracker: std::cell::RefCell::new( + crate::platform_impl::input_rate::InputRateTracker::new(), + ), }) } diff --git a/rio-window/src/platform_impl/linux/x11/mod.rs b/rio-window/src/platform_impl/linux/x11/mod.rs index b49520aa..80b023f5 100644 --- a/rio-window/src/platform_impl/linux/x11/mod.rs +++ b/rio-window/src/platform_impl/linux/x11/mod.rs @@ -145,8 +145,12 @@ pub struct ActiveEventLoop { redraw_sender: WakeSender, activation_sender: WakeSender, device_events: Cell, - /// Timestamp of the most recent input event. - last_input_timestamp: Cell, + /// Rate-gated post-input sustain tracker. Only sustained high-rate + /// input (≥ 60 events/sec over 100 ms) extends the 1-second + /// presentation window — single keystrokes don't. See + /// `platform_impl::input_rate`. + input_rate_tracker: + RefCell, /// Shared with `EventLoopState` and every window. Set by /// `request_redraw`, checked by `has_pending`, cleared by /// the vsync tick after fanning out. @@ -351,7 +355,9 @@ impl EventLoop { waker: waker.clone(), }, device_events: Default::default(), - last_input_timestamp: Cell::new(std::time::Instant::now()), + input_rate_tracker: RefCell::new( + crate::platform_impl::input_rate::InputRateTracker::new(), + ), redraw_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)), waker: waker.clone(), }; @@ -749,12 +755,12 @@ impl AsRawFd for EventLoop { impl ActiveEventLoop { #[inline] pub(crate) fn mark_input_received(&self) { - self.last_input_timestamp.set(std::time::Instant::now()); + self.input_rate_tracker.borrow_mut().record_input(); } #[inline] pub(crate) fn should_present_after_input(&self) -> bool { - self.last_input_timestamp.get().elapsed() < std::time::Duration::from_secs(1) + self.input_rate_tracker.borrow().is_high_rate() } /// Returns the `XConnection` of this events loop. diff --git a/rio-window/src/platform_impl/macos/window_delegate.rs b/rio-window/src/platform_impl/macos/window_delegate.rs index 6814de9d..9c4bf007 100644 --- a/rio-window/src/platform_impl/macos/window_delegate.rs +++ b/rio-window/src/platform_impl/macos/window_delegate.rs @@ -135,8 +135,10 @@ pub(crate) struct State { display_link: RefCell>, // Track when rendering is needed (dirty state) needs_redraw: Cell, - // Track last input timestamp for 1-second presentation window - last_input_timestamp: Cell, + // Rate-gated post-input sustain window. Replaces the raw + // `last_input_timestamp` — single keystrokes no longer force + // 1 s of vsync redraws. See `platform_impl::input_rate`. + input_rate_tracker: RefCell, // Position of traffic light buttons (close, minimize, maximize) // Specified as (x, y) coordinates from top-left corner traffic_light_position: Cell>, @@ -765,7 +767,9 @@ impl WindowDelegate { background_color: unsafe { NSColor::blackColor().into() }, display_link: RefCell::new(None), needs_redraw: Cell::new(false), - last_input_timestamp: Cell::new(std::time::Instant::now()), + input_rate_tracker: RefCell::new( + crate::platform_impl::input_rate::InputRateTracker::new(), + ), traffic_light_position: Cell::new( attrs.platform_specific.traffic_light_position, ), @@ -978,25 +982,25 @@ impl WindowDelegate { #[inline] pub fn pre_present_notify(&self) {} - /// Mark that input was received. + /// Record an input event for rate-gated post-input presentation. /// - /// This updates the timestamp used for the 1-second presentation window - /// to prevent display downclocking. + /// Only sustained high-rate input (≥ 60 events/sec over 100 ms) + /// extends the post-input vsync-redraw window — single keystrokes + /// don't. Matches Zed's `InputRateTracker` + /// (`zed/crates/gpui/src/window.rs:987`). #[inline] pub(crate) fn mark_input_received(&self) { - self.ivars() - .last_input_timestamp - .set(std::time::Instant::now()); + self.ivars().input_rate_tracker.borrow_mut().record_input(); } - /// Check if we should keep presenting frames after input. - /// - /// After ANY input, keeps presenting frames for 1 second to prevent - /// the display from downclocking the refresh rate. + /// `true` while we're still inside a high-rate-input sustain + /// window. Used by the CVDisplayLink callback to decide whether + /// to fire a redraw even when the window isn't dirty, preventing + /// ProMotion / VRR displays from downclocking while the user is + /// actively typing / scrolling. #[inline] pub fn should_present_after_input(&self) -> bool { - self.ivars().last_input_timestamp.get().elapsed() - < std::time::Duration::from_secs(1) + self.ivars().input_rate_tracker.borrow().is_high_rate() } pub fn outer_position(&self) -> Result, NotSupportedError> { diff --git a/rio-window/src/platform_impl/mod.rs b/rio-window/src/platform_impl/mod.rs index c03f95fb..883c8c56 100644 --- a/rio-window/src/platform_impl/mod.rs +++ b/rio-window/src/platform_impl/mod.rs @@ -3,6 +3,10 @@ use crate::monitor::{ }; use crate::window::Fullscreen as RootFullscreen; +// Shared by every backend — "sustain presenting after high-rate +// input" gate. See module docs for the rationale. +pub(crate) mod input_rate; + #[cfg(windows_platform)] #[path = "windows/mod.rs"] mod platform; diff --git a/rio-window/src/platform_impl/windows/event_loop/vsync.rs b/rio-window/src/platform_impl/windows/event_loop/vsync.rs index 04fbcfbe..ba396f9c 100644 --- a/rio-window/src/platform_impl/windows/event_loop/vsync.rs +++ b/rio-window/src/platform_impl/windows/event_loop/vsync.rs @@ -9,8 +9,11 @@ //! `RedrawRequested` path is unchanged. //! //! `should_present_after_input` keeps the loop firing for one -//! second after any input even if the app never sets the dirty -//! flag — same gate macOS / Wayland / X11 use. +//! second *after a high-rate input burst* (≥ 60 events/sec over +//! a 100 ms window) even if the app never sets the dirty flag — +//! same gate macOS / Wayland / X11 use via `InputRateTracker`. +//! Single keystrokes / lone mouse events no longer force a +//! 1-second redraw storm. //! //! When DWM is disabled, the monitor is unplugged, or under some //! RDP modes, `DwmFlush` returns immediately. The 1 ms threshold @@ -24,6 +27,8 @@ use std::sync::{Arc, Mutex, RwLock}; use std::thread::JoinHandle; use std::time::{Duration, Instant}; +use crate::platform_impl::input_rate::InputRateTracker; + use windows_sys::Win32::Foundation::{HWND, S_OK}; use windows_sys::Win32::Graphics::Dwm::{ DwmFlush, DwmGetCompositionTimingInfo, DWM_TIMING_INFO, @@ -34,27 +39,28 @@ use windows_sys::Win32::UI::WindowsAndMessaging::IsWindowVisible; const VSYNC_INTERVAL_THRESHOLD: Duration = Duration::from_millis(1); const DEFAULT_VSYNC_INTERVAL: Duration = Duration::from_micros(16_666); // ~60Hz -const POST_INPUT_PRESENT_WINDOW: Duration = Duration::from_secs(1); /// State shared between the event loop, window-callback thread, /// and the DwmFlush worker thread. Holds the per-window dirty-flag -/// registry plus the most recent input timestamp. +/// registry plus the rate-gated post-input sustain tracker. pub(crate) struct VSyncSharedState { /// HWND (as `usize` for `Hash`/`Eq`) → per-window dirty flag. /// `Window::request_redraw` sets the flag; the worker reads /// and clears it on each tick. windows: RwLock>>, - /// Updated by every input handler; checked by the worker to - /// decide whether to fan out a redraw even for windows whose - /// dirty flag is clear. - last_input_timestamp: Mutex, + /// Rate-gated post-input sustain. Only sustained high-rate + /// input (≥ 60 events/sec over 100 ms) keeps the worker fanning + /// out redraws after the app stops marking windows dirty. + /// `Mutex` because the worker thread and the window-message + /// thread both touch this. See `platform_impl::input_rate`. + input_rate_tracker: Mutex, } impl VSyncSharedState { pub(crate) fn new() -> Arc { Arc::new(Self { windows: RwLock::new(HashMap::new()), - last_input_timestamp: Mutex::new(Instant::now()), + input_rate_tracker: Mutex::new(InputRateTracker::new()), }) } @@ -81,12 +87,12 @@ impl VSyncSharedState { #[inline] pub(crate) fn mark_input_received(&self) { - *self.last_input_timestamp.lock().unwrap() = Instant::now(); + self.input_rate_tracker.lock().unwrap().record_input(); } #[inline] pub(crate) fn should_present_after_input(&self) -> bool { - self.last_input_timestamp.lock().unwrap().elapsed() < POST_INPUT_PRESENT_WINDOW + self.input_rate_tracker.lock().unwrap().is_high_rate() } }