diff --git a/docs/src/pages/changelog.mdx b/docs/src/pages/changelog.mdx index 059c687e..f231a848 100644 --- a/docs/src/pages/changelog.mdx +++ b/docs/src/pages/changelog.mdx @@ -7,6 +7,7 @@ language: 'en' ## 0.3.11 +- Windows: vsync-driven render loop with 1-second post-input sustain (matches macOS / Linux / zed behaviour). All inside `rio-window` — frontends still just respond to `RedrawRequested`. New worker thread (`platform_impl/windows/event_loop/vsync.rs`) calls `DwmFlush()` per composition cycle; when DWM is disabled, the monitor is asleep, or the call returns under the 1 ms threshold (RDP / occluded), it falls back to `thread::sleep` at the queried `DwmGetCompositionTimingInfo.qpcRefreshPeriod` interval, with a `rateRefresh` numerator/denominator fallback for spuriously low values and a 16.6 ms (60 Hz) default if both queries fail. Same heuristic zed uses in `gpui_windows/src/vsync.rs`. Each tick `PostMessageW`'s a registered custom message (`Winit::VsyncTick`) to the existing `thread_msg_target`; the handler in `thread_event_target_callback` enumerates thread windows via `EnumThreadWindows`, filters with `IsWindowVisible`, and posts `RedrawWindow(hwnd, RDW_INTERNALPAINT)` per visible window so the existing `WM_PAINT` path emits `RedrawRequested` unchanged. The fan-out is gated on a new `EventLoopRunner.should_present_after_input()` (1 s window after the last input event) — same field/method names as the macOS / Wayland / X11 implementations. `mark_input_received` is hooked into `public_window_callback_inner` for `WM_KEY{DOWN,UP}`, `WM_SYSKEY{DOWN,UP}`, `WM_MOUSE{MOVE,WHEEL,HWHEEL}`, all four button down/up pairs, `WM_TOUCH`, and `WM_POINTER{DOWN,UPDATE,UP}`. `EventLoop::drop` now joins the worker thread (via a `stub()`-and-`mem::replace` swap) before destroying `thread_msg_target` so the worker can never `PostMessageW` to a freed HWND. New windows-sys feature `Win32_System_Performance` for `QueryPerformanceFrequency`. `build.rs` no longer panics when cross-compiling between host and target OS (`#[cfg(target_os = ...)]` branches now also check `CARGO_CFG_TARGET_OS`), so `cargo check --target x86_64-pc-windows-gnu` actually works from a macOS workstation. - Linux: vsync-driven render loop with 1-second post-input sustain (matches macOS / zed behaviour). Two changes inside `rio-window` (no rioterm churn — frontends still just respond to `RedrawRequested`). **Wayland** (`platform_impl/linux/wayland/event_loop/mod.rs`): the `wl_callback::Done` handler now unconditionally re-arms the next `wl_surface.frame()` and drives an auto-loop, modelled on zed's `gpui_linux/src/linux/wayland/window.rs:572-587`. Compositors stop delivering Done for occluded windows so the loop pauses naturally without an explicit visibility check. **X11** (`platform_impl/linux/x11/mod.rs`): a new `calloop::timer::Timer` source reschedules at the primary monitor's xrandr-derived refresh rate (`monitor::mode_refresh_rate_millihertz`, fallback 60 Hz); each tick sets `EventLoopState.vsync_pending` and the main pump fans the flag out to every visible window via the existing `redraw_sender` channel. Mirrors zed's `gpui_linux/src/linux/x11/client.rs:1934-1971`. Both paths gate the synthetic `RedrawRequested` on a new `should_present_after_input()` (1 s window after the last input event) — same field/method names as the existing macOS implementation in `platform_impl/macos/window_delegate.rs:139,997`. `mark_input_received` is hooked into the Wayland `KeyboardHandler` and `PointerHandler` and into the X11 KeyPress/KeyRelease + XInput2 ButtonPress/Release/Motion dispatch sites in `event_processor.rs`. Net effect: ProMotion / 144 Hz / 240 Hz monitors now stay at peak refresh during typing/scroll on Wayland and X11; idle terminals don't burn extra CPU because the gate falls back to the existing dirty-driven path outside the 1 s window. - Full kitty graphics protocol Unicode-placeholder support (`kitten icat --unicode-placeholder` and any tmux/yazi/etc. that uses U+10EEEE placement cells). Wire-protocol bugs fixed: (1) the APC parser stored `U=1` into a private `cmd.virtual_placement` field that was never propagated to `PlacementRequest`, so `place_graphic` always fell through to the direct-overlay branch; (2) `place_virtual_graphic` was auto-writing U+10EEEE cells to the grid, racing kitty's own writes (per the spec the application emits the cells, the terminal only stores metadata); (3) icat uses `a=T,U=1` (combined transmit-and-display) which goes through `kitty_transmit_and_display`, not `place_graphic` — that handler now also routes virtual placements correctly and pushes pixel data to `pending_images` (the regular overlay path did this implicitly via `place_kitty_overlay`); (4) the diacritics table had 299 entries instead of the canonical 297 (two stray entries `\u{06EA}` and `\u{06ED}` from earlier work), shifting every index past the divergence point so `image_id_high` decoded as the wrong byte (e.g. `0x5C` stored, `0x5E` decoded). Replaced with the 297 entries from `kitty/gen/rowcolumn-diacritics.txt`. Shader bug fixed: `image.metal` interpreted `source_rect.zw` as **size** but the new code passes it as end-coords; switched to `mix(.xy, .zw, corner)` (only worked before because every overlay used the full-image default `[0,0,1,1]`). Renderer side, modeled cell-by-cell on ghostty's `graphics_unicode.zig`: new `Row.kitty_virtual_placeholder` per-row dirty flag set in `Crosswords::input` and checked by the scan loop, so we only walk rows that contain a placeholder (`page.zig:1953-1958`); new `IncompletePlacement` + `can_append` / `append` / `complete` implements kitty's diacritic continuation rules — a cell with missing row/col/high-byte diacritics inherits from the previous cell on the row and the col field can be omitted to mean "auto-increment from prev" (`graphics_unicode.zig:407-535`); the renderer now walks each visible row left-to-right, builds runs of consecutive cells that belong to the same `(image_id, placement_id, image_row)` with sequential image columns, and pushes ONE `GraphicOverlay` per run instead of one per placement (matches ghostty's `PlacementIterator`); per-run aspect-preserving fit, centering, and source-rect clipping factored into `kitty_virtual::compute_run_geometry` — handles partial visibility (placement scrolled half off-screen renders only the visible image slice), runs that fall entirely in the centering padding (returns `None`), and runs that straddle the padding boundary (clips both the screen rect and the source rect so the rendered slice exactly covers the fitted-image area). Placeholder cells render with `style.background_color = None` so the per-cell bg quad doesn't cover the BelowText image. `GraphicOverlay` gained a `source_rect: [f32; 4]` field (default `[0,0,1,1]` via `FULL_SOURCE_RECT`) wired through to the existing `ImageInstance.source_rect`. Tests cover: the 297-entry diacritic table, `IncompletePlacement::from_cell` across all input shapes (Indexed fg, Spec fg + 3rd diacritic, underline=placement_id, missing diacritics, Named fg → 0), all `can_append` cases (row inherit, col inherit, sequential col, col jump, row mismatch, image-id mismatch, image-id-high inherit), a 3-cell run with only the first cell carrying diacritics, parser-level `U=1` propagation, the metadata-only contract of `place_virtual_graphic`, an end-to-end pass that feeds icat's exact wire format and asserts both the resulting grid cells AND the per-row dirty flag, and `compute_run_geometry` across exact-fit / image-taller-than-grid (horizontal centering + left-padding cull) / image-wider-than-grid (vertical centering + top-row cull) / partial-visibility-scrolled-off-top / origin-offset / zero-sized-image cases. - macOS Metal renderer: triple-buffered pipeline modeled on zed's `gpui_macos::InstanceBufferPool`. `CAMetalLayer.maximumDrawableCount` is now 3 (was 2). The six per-frame buffers (text vertices, quad instances, kitty/sixel image instances, bg-image instance, bg-fill instance, Globals uniform) collapsed into a single pooled `metal::Buffer` per frame: text/quad/image data bump-allocates from the pool buffer with 256-byte aligned offsets, and `Globals` (transform + `input_colorspace`, ~80 B) goes through `set_vertex_bytes` / `set_fragment_bytes` so no buffer is needed for it at all. The pool starts at 2 MiB, doubles on overflow up to a 256 MiB cap (matches zed). On overflow we end-encoding, drop the never-committed command buffer, grow the pool, and retry the frame from scratch — old smaller buffers in flight get rejected by `release` after `grow` and dropped naturally, no "all 3 slots must grow together" coordination. `command_buffer.add_completed_handler` releases the buffer back to the `Arc>` on the GPU completion thread. Net effect: CPU can stay up to 3 frames ahead of the GPU without racing the previously single `StorageModeShared` buffers (the old code was relying on luck — every frame shared one allocation), and ProMotion 120 Hz is now reachable without dropped frames during heavy scenes. `MetalRenderer::resize` is gone (no more uniform buffer to refresh on resize); `Renderer::render_metal` now owns command-buffer / encoder / drawable / commit, so `Sugarloaf::render_metal` is just a one-liner that hands `bg_color` and the `MetalContext` over. diff --git a/rio-window/Cargo.toml b/rio-window/Cargo.toml index f74f4536..ffac3702 100644 --- a/rio-window/Cargo.toml +++ b/rio-window/Cargo.toml @@ -132,6 +132,7 @@ features = [ "Win32_System_Com", "Win32_System_LibraryLoader", "Win32_System_Ole", + "Win32_System_Performance", "Win32_Security", "Win32_System_SystemInformation", "Win32_System_SystemServices", diff --git a/rio-window/build.rs b/rio-window/build.rs index 74c003c4..0061fef1 100644 --- a/rio-window/build.rs +++ b/rio-window/build.rs @@ -40,11 +40,19 @@ fn main() { println!("cargo:rustc-check-cfg=cfg(unreleased_changelogs)"); + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + #[cfg(target_os = "macos")] - generate_dispatch_bindings(); + if target_os == "macos" { + generate_dispatch_bindings(); + } #[cfg(target_os = "windows")] - load_app_icon(); + if target_os == "windows" { + load_app_icon(); + } + + let _ = target_os; } #[cfg(target_os = "macos")] diff --git a/rio-window/src/platform_impl/windows/event_loop.rs b/rio-window/src/platform_impl/windows/event_loop.rs index c830a316..0923649a 100644 --- a/rio-window/src/platform_impl/windows/event_loop.rs +++ b/rio-window/src/platform_impl/windows/event_loop.rs @@ -1,6 +1,7 @@ #![allow(non_snake_case)] mod runner; +mod vsync; use std::cell::Cell; use std::collections::VecDeque; @@ -48,26 +49,27 @@ use windows_sys::Win32::UI::Input::{ MOUSE_MOVE_RELATIVE, RAWINPUT, RIM_TYPEKEYBOARD, RIM_TYPEMOUSE, }; use windows_sys::Win32::UI::WindowsAndMessaging::{ - CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, GetClientRect, - GetCursorPos, GetMenu, LoadCursorW, MsgWaitForMultipleObjectsEx, PeekMessageW, - PostMessageW, RegisterClassExW, RegisterWindowMessageA, SetCursor, SetWindowPos, - TranslateMessage, CREATESTRUCTW, GIDC_ARRIVAL, GIDC_REMOVAL, GWL_STYLE, GWL_USERDATA, - HTCAPTION, HTCLIENT, MINMAXINFO, MNC_CLOSE, MSG, MWMO_INPUTAVAILABLE, - NCCALCSIZE_PARAMS, PM_REMOVE, PT_PEN, PT_TOUCH, QS_ALLINPUT, RI_MOUSE_HWHEEL, - RI_MOUSE_WHEEL, SC_MINIMIZE, SC_RESTORE, SIZE_MAXIMIZED, SWP_NOACTIVATE, SWP_NOMOVE, - SWP_NOSIZE, SWP_NOZORDER, WHEEL_DELTA, WINDOWPOS, WMSZ_BOTTOM, WMSZ_BOTTOMLEFT, - WMSZ_BOTTOMRIGHT, WMSZ_LEFT, WMSZ_RIGHT, WMSZ_TOP, WMSZ_TOPLEFT, WMSZ_TOPRIGHT, - WM_CAPTURECHANGED, WM_CLOSE, WM_CREATE, WM_DESTROY, WM_DPICHANGED, WM_ENTERSIZEMOVE, - WM_EXITSIZEMOVE, WM_GETMINMAXINFO, WM_IME_COMPOSITION, WM_IME_ENDCOMPOSITION, - WM_IME_SETCONTEXT, WM_IME_STARTCOMPOSITION, WM_INPUT, WM_INPUT_DEVICE_CHANGE, - WM_KEYDOWN, WM_KEYUP, WM_KILLFOCUS, WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MBUTTONDOWN, - WM_MBUTTONUP, WM_MENUCHAR, WM_MOUSEHWHEEL, WM_MOUSEMOVE, WM_MOUSEWHEEL, - WM_NCACTIVATE, WM_NCCALCSIZE, WM_NCCREATE, WM_NCDESTROY, WM_NCLBUTTONDOWN, WM_PAINT, - WM_POINTERDOWN, WM_POINTERUP, WM_POINTERUPDATE, WM_RBUTTONDOWN, WM_RBUTTONUP, - WM_SETCURSOR, WM_SETFOCUS, WM_SETTINGCHANGE, WM_SIZE, WM_SIZING, WM_SYSCOMMAND, - WM_SYSKEYDOWN, WM_SYSKEYUP, WM_TOUCH, WM_WINDOWPOSCHANGED, WM_WINDOWPOSCHANGING, - WM_XBUTTONDOWN, WM_XBUTTONUP, WNDCLASSEXW, WS_EX_LAYERED, WS_EX_NOACTIVATE, - WS_EX_TOOLWINDOW, WS_EX_TRANSPARENT, WS_OVERLAPPED, WS_POPUP, WS_VISIBLE, + CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, EnumThreadWindows, + GetClientRect, GetCursorPos, GetMenu, IsWindowVisible, LoadCursorW, + MsgWaitForMultipleObjectsEx, PeekMessageW, PostMessageW, RegisterClassExW, + RegisterWindowMessageA, SetCursor, SetWindowPos, TranslateMessage, CREATESTRUCTW, + GIDC_ARRIVAL, GIDC_REMOVAL, GWL_STYLE, GWL_USERDATA, HTCAPTION, HTCLIENT, MINMAXINFO, + MNC_CLOSE, MSG, MWMO_INPUTAVAILABLE, NCCALCSIZE_PARAMS, PM_REMOVE, PT_PEN, PT_TOUCH, + QS_ALLINPUT, RI_MOUSE_HWHEEL, RI_MOUSE_WHEEL, SC_MINIMIZE, SC_RESTORE, + SIZE_MAXIMIZED, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, WHEEL_DELTA, + WINDOWPOS, WMSZ_BOTTOM, WMSZ_BOTTOMLEFT, WMSZ_BOTTOMRIGHT, WMSZ_LEFT, WMSZ_RIGHT, + WMSZ_TOP, WMSZ_TOPLEFT, WMSZ_TOPRIGHT, WM_CAPTURECHANGED, WM_CLOSE, WM_CREATE, + WM_DESTROY, WM_DPICHANGED, WM_ENTERSIZEMOVE, WM_EXITSIZEMOVE, WM_GETMINMAXINFO, + WM_IME_COMPOSITION, WM_IME_ENDCOMPOSITION, WM_IME_SETCONTEXT, + WM_IME_STARTCOMPOSITION, WM_INPUT, WM_INPUT_DEVICE_CHANGE, WM_KEYDOWN, WM_KEYUP, + WM_KILLFOCUS, WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MBUTTONDOWN, WM_MBUTTONUP, + WM_MENUCHAR, WM_MOUSEHWHEEL, WM_MOUSEMOVE, WM_MOUSEWHEEL, WM_NCACTIVATE, + WM_NCCALCSIZE, WM_NCCREATE, WM_NCDESTROY, WM_NCLBUTTONDOWN, WM_PAINT, WM_POINTERDOWN, + WM_POINTERUP, WM_POINTERUPDATE, WM_RBUTTONDOWN, WM_RBUTTONUP, WM_SETCURSOR, + WM_SETFOCUS, WM_SETTINGCHANGE, WM_SIZE, WM_SIZING, WM_SYSCOMMAND, WM_SYSKEYDOWN, + WM_SYSKEYUP, WM_TOUCH, WM_WINDOWPOSCHANGED, WM_WINDOWPOSCHANGING, WM_XBUTTONDOWN, + WM_XBUTTONUP, WNDCLASSEXW, WS_EX_LAYERED, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, + WS_EX_TRANSPARENT, WS_OVERLAPPED, WS_POPUP, WS_VISIBLE, }; use crate::dpi::{PhysicalPosition, PhysicalSize}; @@ -170,6 +172,10 @@ pub struct EventLoop { // It is created lazily in case if we have `ControlFlow::WaitUntil`. // Keep it as a field to avoid recreating it on every `ControlFlow::WaitUntil`. high_resolution_timer: Option, + // DwmFlush-driven vsync source. Posts `VSYNC_TICK_MSG_ID` to + // `thread_msg_target` per composition cycle. Dropped before + // `thread_msg_target` is destroyed (see `Drop for EventLoop`). + _vsync_thread: vsync::VSyncThread, } pub(crate) struct PlatformSpecificEventLoopAttributes { @@ -224,6 +230,8 @@ impl EventLoop { Default::default(), ); + let vsync_thread = vsync::VSyncThread::spawn(thread_msg_target); + Ok(EventLoop { user_event_sender, user_event_receiver, @@ -237,6 +245,7 @@ impl EventLoop { }, msg_hook: attributes.msg_hook.take(), high_resolution_timer: None, + _vsync_thread: vsync_thread, }) } @@ -479,6 +488,12 @@ impl EventLoop { impl Drop for EventLoop { fn drop(&mut self) { + // Stop and join the vsync worker before destroying the + // message target it posts to, otherwise the worker can + // briefly call `PostMessageW` against a freed HWND. + // `vsync::VSyncThread::drop` joins the worker. + let stub_thread = vsync::VSyncThread::stub(); + let _ = std::mem::replace(&mut self._vsync_thread, stub_thread); unsafe { DestroyWindow(self.window_target.p.thread_msg_target); } @@ -1186,6 +1201,20 @@ unsafe fn public_window_callback_inner( ) -> LRESULT { let mut result = ProcResult::DefWindowProc(wparam); + // Mark any input message before further processing so the + // DwmFlush worker's `should_present_after_input` window stays + // open. Mirrors macOS / Wayland / X11. + match msg { + WM_KEYDOWN | WM_SYSKEYDOWN | WM_KEYUP | WM_SYSKEYUP | WM_MOUSEMOVE + | WM_MOUSEWHEEL | WM_MOUSEHWHEEL | WM_LBUTTONDOWN | WM_LBUTTONUP + | WM_RBUTTONDOWN | WM_RBUTTONUP | WM_MBUTTONDOWN | WM_MBUTTONUP + | WM_XBUTTONDOWN | WM_XBUTTONUP | WM_TOUCH | WM_POINTERDOWN + | WM_POINTERUPDATE | WM_POINTERUP => { + userdata.event_loop_runner.mark_input_received(); + } + _ => (), + } + // Send new modifiers before sending key events. let mods_changed_callback = || match msg { WM_KEYDOWN | WM_SYSKEYDOWN | WM_KEYUP | WM_SYSKEYUP => { @@ -2626,6 +2655,20 @@ unsafe fn public_window_callback_inner( } } +/// `EnumThreadWindows` callback used by the vsync tick handler: +/// for each window owned by the event-loop thread, post +/// `RDW_INTERNALPAINT` if it is visible. Skips the hidden +/// `thread_msg_target` (it has `WS_EX_TOOLWINDOW` and is never +/// shown). Returning `1` (TRUE) continues enumeration. +unsafe extern "system" fn redraw_visible_window(window: HWND, _: LPARAM) -> i32 { + if unsafe { IsWindowVisible(window) } != 0 { + unsafe { + RedrawWindow(window, ptr::null(), ptr::null_mut(), RDW_INTERNALPAINT); + } + } + 1 +} + unsafe extern "system" fn thread_event_target_callback( window: HWND, msg: u32, @@ -2711,6 +2754,24 @@ unsafe extern "system" fn thread_event_target_callback( function(); 0 } + // Per-vsync tick from `vsync::VSyncThread`. Fan out a + // `RDW_INTERNALPAINT` to every visible window owned by this + // thread when input arrived in the last second. The + // resulting `WM_PAINT` flows through the existing handler + // and emits `RedrawRequested` — frontends are unaware of + // the vsync source. Mirrors the Wayland/X11 pattern in + // `rio-window/src/platform_impl/linux/{wayland,x11}` and + // zed's `begin_vsync_thread` in + // `crates/gpui_windows/src/platform.rs:293`. + _ if msg == vsync::VSYNC_TICK_MSG_ID.get() => { + if userdata.event_loop_runner.should_present_after_input() { + let thread_id = unsafe { GetCurrentThreadId() }; + unsafe { + EnumThreadWindows(thread_id, Some(redraw_visible_window), 0); + } + } + 0 + } _ => unsafe { DefWindowProcW(window, msg, wparam, lparam) }, }; diff --git a/rio-window/src/platform_impl/windows/event_loop/runner.rs b/rio-window/src/platform_impl/windows/event_loop/runner.rs index 7bbae2c9..73d34acf 100644 --- a/rio-window/src/platform_impl/windows/event_loop/runner.rs +++ b/rio-window/src/platform_impl/windows/event_loop/runner.rs @@ -37,6 +37,13 @@ pub(crate) struct EventLoopRunner { event_buffer: RefCell>>, panic_error: Cell>, + + /// Timestamp of the most recent input event. Mirrors macOS's + /// `last_input_timestamp` and the Wayland/X11 implementations. + /// The DwmFlush vsync worker uses `should_present_after_input` + /// (1 s window) to decide whether to fan out a per-vsync + /// `RDW_INTERNALPAINT` to visible windows. + last_input_timestamp: Cell, } pub type PanicError = Box; @@ -72,9 +79,25 @@ impl EventLoopRunner { last_events_cleared: Cell::new(Instant::now()), event_handler: Cell::new(None), event_buffer: RefCell::new(VecDeque::new()), + last_input_timestamp: Cell::new(Instant::now()), } } + /// Set `last_input_timestamp` to `now`. Called from each Win32 + /// input handler. Mirrors macOS `mark_input_received` in + /// `window_delegate.rs:986` and the Wayland/X11 equivalents. + #[inline] + pub(crate) fn mark_input_received(&self) { + self.last_input_timestamp.set(Instant::now()); + } + + /// True for 1 second after the most recent input event. Mirrors + /// macOS `should_present_after_input` in `window_delegate.rs:997`. + #[inline] + pub(crate) fn should_present_after_input(&self) -> bool { + self.last_input_timestamp.get().elapsed() < std::time::Duration::from_secs(1) + } + /// Associate the application's event handler with the runner /// /// # Safety @@ -116,6 +139,7 @@ impl EventLoopRunner { last_events_cleared: _, event_handler, event_buffer: _, + last_input_timestamp: _, } = self; interrupt_msg_dispatch.set(false); runner_state.set(RunnerState::Uninitialized); diff --git a/rio-window/src/platform_impl/windows/event_loop/vsync.rs b/rio-window/src/platform_impl/windows/event_loop/vsync.rs new file mode 100644 index 00000000..743c4d15 --- /dev/null +++ b/rio-window/src/platform_impl/windows/event_loop/vsync.rs @@ -0,0 +1,211 @@ +//! DwmFlush-driven vsync worker that posts a tick message to the +//! event loop's `thread_msg_target` per composition cycle. +//! +//! Modelled on zed's `crates/gpui_windows/src/vsync.rs` + +//! `begin_vsync_thread` in `crates/gpui_windows/src/platform.rs`. +//! The handler in `event_loop::thread_event_target_callback` decides +//! per tick whether to fan out a `RedrawRequested` to visible +//! windows, gated by `EventLoopRunner::should_present_after_input` +//! (1 s window, matches macOS). +//! +//! When DWM is disabled, when the monitor is unplugged, or under +//! some RDP modes, `DwmFlush` returns immediately. We detect that +//! via a 1 ms threshold and fall back to `thread::sleep` at the +//! queried refresh interval. + +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::Arc; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +use windows_sys::Win32::Foundation::{HWND, S_OK}; +use windows_sys::Win32::Graphics::Dwm::{ + DwmFlush, DwmGetCompositionTimingInfo, DWM_TIMING_INFO, +}; +use windows_sys::Win32::System::Performance::QueryPerformanceFrequency; +use windows_sys::Win32::UI::WindowsAndMessaging::{PostMessageW, RegisterWindowMessageA}; + +const VSYNC_INTERVAL_THRESHOLD: Duration = Duration::from_millis(1); +const DEFAULT_VSYNC_INTERVAL: Duration = Duration::from_micros(16_666); // ~60Hz + +/// Custom window message posted from the worker thread to +/// `thread_msg_target` once per vsync. The event-loop side +/// dispatches it in `thread_event_target_callback`. +pub(super) static VSYNC_TICK_MSG_ID: LazyVsyncMsgId = + LazyVsyncMsgId::new("Winit::VsyncTick\0"); + +/// Lazy `RegisterWindowMessageA` wrapper. Mirrors the +/// `LazyMessageId` pattern in `event_loop.rs` but kept here to +/// avoid widening the visibility of that type. +pub(super) struct LazyVsyncMsgId { + id: AtomicU32, + name: &'static str, +} + +impl LazyVsyncMsgId { + pub(super) const fn new(name: &'static str) -> Self { + Self { + id: AtomicU32::new(0), + name, + } + } + + pub(super) fn get(&self) -> u32 { + let id = self.id.load(Ordering::Relaxed); + if id != 0 { + return id; + } + assert!(self.name.ends_with('\0')); + let new_id = unsafe { RegisterWindowMessageA(self.name.as_ptr()) }; + assert_ne!( + new_id, 0, + "RegisterWindowMessageA failed for '{}'", + self.name + ); + self.id.store(new_id, Ordering::Relaxed); + new_id + } +} + +/// Send-able HWND wrapper. HWND is `*mut c_void` (`!Send`), and +/// edition-2021 disjoint-capture would otherwise capture the inner +/// pointer field directly into the worker closure. Stashing the +/// raw bits as `usize` sidesteps that and keeps the cast local. +#[derive(Clone, Copy)] +struct SendHwnd(usize); + +impl SendHwnd { + fn new(hwnd: HWND) -> Self { + Self(hwnd as usize) + } + + fn raw(self) -> HWND { + self.0 as HWND + } +} + +// SAFETY: HWND is treated as opaque by the worker — it is only +// used as the destination of `PostMessageW`, which is documented +// to be thread-safe. +unsafe impl Send for SendHwnd {} + +/// Owns the worker thread. Drop signals stop and joins. +pub(super) struct VSyncThread { + stop: Arc, + handle: Option>, +} + +impl VSyncThread { + /// A no-op `VSyncThread` used to swap out the real worker + /// during `EventLoop::drop` so that joining can happen before + /// the target window is destroyed. + pub(super) fn stub() -> Self { + Self { + stop: Arc::new(AtomicBool::new(true)), + handle: None, + } + } + + pub(super) fn spawn(thread_msg_target: HWND) -> Self { + let stop = Arc::new(AtomicBool::new(false)); + let stop_worker = stop.clone(); + let target = SendHwnd::new(thread_msg_target); + let tick_msg = VSYNC_TICK_MSG_ID.get(); + + let handle = std::thread::Builder::new() + .name("rio-window::vsync".to_owned()) + .spawn(move || { + let provider = VSyncProvider::new(); + while !stop_worker.load(Ordering::Acquire) { + provider.wait_for_vsync(); + if stop_worker.load(Ordering::Acquire) { + break; + } + // SAFETY: PostMessageW with a valid registered + // message id is sound; failure means the target + // window has been destroyed, so we exit. + let posted = unsafe { PostMessageW(target.raw(), tick_msg, 0, 0) }; + if posted == 0 { + break; + } + } + }) + .expect("failed to spawn rio-window vsync thread"); + + Self { + stop, + handle: Some(handle), + } + } +} + +impl Drop for VSyncThread { + fn drop(&mut self) { + self.stop.store(true, Ordering::Release); + if let Some(handle) = self.handle.take() { + // The worker exits at the start of the next iteration + // after the current DwmFlush returns (typically <16 ms). + let _ = handle.join(); + } + } +} + +struct VSyncProvider { + interval: Duration, +} + +impl VSyncProvider { + fn new() -> Self { + let interval = query_dwm_interval().unwrap_or(DEFAULT_VSYNC_INTERVAL); + Self { interval } + } + + fn wait_for_vsync(&self) { + let start = Instant::now(); + let hr = unsafe { DwmFlush() }; + let elapsed = start.elapsed(); + // DwmFlush returns immediately when DWM is disabled, when + // the monitor is asleep / unplugged, or under some RDP + // modes. The 1 ms threshold catches that and we sleep the + // queried refresh interval as a fallback. Same heuristic + // zed uses in vsync.rs:51. + if hr != S_OK || elapsed < VSYNC_INTERVAL_THRESHOLD { + std::thread::sleep(self.interval); + } + } +} + +fn query_dwm_interval() -> Option { + let mut frequency: i64 = 0; + if unsafe { QueryPerformanceFrequency(&mut frequency) } == 0 || frequency <= 0 { + return None; + } + let qpc_per_second = frequency as u64; + + let mut info: DWM_TIMING_INFO = unsafe { std::mem::zeroed() }; + info.cbSize = std::mem::size_of::() as u32; + if unsafe { DwmGetCompositionTimingInfo(std::ptr::null_mut(), &mut info) } != S_OK { + return None; + } + + let interval = ticks_to_duration(info.qpcRefreshPeriod, qpc_per_second); + if interval >= VSYNC_INTERVAL_THRESHOLD { + return Some(interval); + } + // qpcRefreshPeriod is sometimes spuriously low (a value of 60 + // ticks → 29 microseconds was observed in zed). Fall back to + // the rateRefresh ratio when that happens. + if info.rateRefresh.uiNumerator == 0 { + return None; + } + Some(ticks_to_duration( + info.rateRefresh.uiDenominator as u64, + info.rateRefresh.uiNumerator as u64, + )) +} + +#[inline] +fn ticks_to_duration(counts: u64, ticks_per_second: u64) -> Duration { + let ticks_per_microsecond = (ticks_per_second / 1_000_000).max(1); + Duration::from_micros(counts / ticks_per_microsecond) +}