From 94cd37f6d07ee778403866973e535be7f1ddb5fe Mon Sep 17 00:00:00 2001 From: LGUG2Z Date: Wed, 28 Oct 2026 11:11:21 -0700 Subject: [PATCH] feat(animation): show ghosts as a still picture on one shared host window A ghost was a live thumbnail of its window, so it showed whatever the window painted: the stretched old frame, then the blank frame a window paints while it lays itself out again (Explorer shows its toolbar and nothing else for a few hundred milliseconds), then the new layout, replacing the picture all at once after the animation was over, and it stayed on screen about 300ms past the end of the move while it waited for that. Every ghost also had a host window of its own that was moved with SetWindowPos on every frame through a channel to a thread that polls for commands every 8ms. Take a picture of the window with PrintWindow before anything is done to it (cropped to the visible frame, with the corners rounded) and show it through a cloaked window of our own that DWM can take a thumbnail of and scale on the GPU. All ghosts are composed onto one layered, click-through host window that covers the virtual screen, and is only above other windows while a ghost is alive so that it does not get in the way of the taskbar's auto-hide: animating a ghost is one DwmUpdateThumbnailProperties call on the animating thread, which has no thread affinity. The number of registered thumbnails is counted and traced so that a leak of compositor handles can be seen. The real window is cloaked and resized at the start of the animation, on a thread of its own, and asked to paint itself at its new size straight away (taking a picture of it makes it render synchronously) while the ghost is on screen, so that it has the whole animation to lay itself out unseen. At 55% of the way through, the window is shown again under the ghost (with the one pixel size change that windows which render through a swap chain need for their compositor to rebuild its render target) and the ghost fades out over it while still moving, so the picture dissolves into the window instead of being replaced after. A window that is slow to take its new size is not waited for: it is shown once it is ready, or, if the animation ends first, waited for with the ghost over it and then faded out over a few frames. Windows that are minimized or gone are not ghosted. --- Cargo.toml | 1 + komorebi/src/animation/ghost.rs | 706 +++++++++++++++++++++-------- komorebi/src/animation/mod.rs | 1 + komorebi/src/animation/snapshot.rs | 500 ++++++++++++++++++++ komorebi/src/window.rs | 308 +++++++------ komorebi/src/windows_api.rs | 21 - 6 files changed, 1181 insertions(+), 356 deletions(-) create mode 100644 komorebi/src/animation/snapshot.rs diff --git a/Cargo.toml b/Cargo.toml index db998136..0baed429 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,7 @@ features = [ "Win32_Globalization", "Win32_Graphics_Dwm", "Win32_Graphics_Gdi", + "Win32_Storage_Xps", "Win32_Graphics_Direct2D", "Win32_Graphics_Direct2D_Common", "Win32_Graphics_Dxgi_Common", diff --git a/komorebi/src/animation/ghost.rs b/komorebi/src/animation/ghost.rs index e179fd8d..c054c8ff 100644 --- a/komorebi/src/animation/ghost.rs +++ b/komorebi/src/animation/ghost.rs @@ -1,41 +1,86 @@ +//! Ghost windows: thumbnails of a picture of a window that is being animated. +//! +//! While a movement animation runs, the real window stays cloaked and still, +//! and a thumbnail of it is moved and scaled instead. Every thumbnail is +//! composed onto one shared host window that covers the whole virtual screen, +//! so that animating a ghost costs a single `DwmUpdateThumbnailProperties` call +//! per frame: no window has to be moved, and all the ghosts of an animation +//! can be committed to the compositor together. +//! +//! The host is created and driven by a dedicated owner thread, because a +//! window has to be created by a thread that pumps its messages. Everything +//! that touches the host's z-order or the number of live thumbnails goes +//! through that thread. Updating a registered thumbnail has no thread +//! affinity and is done directly by the animating thread. + use color_eyre::eyre; use crossbeam_channel::Sender; use crossbeam_channel::bounded; use crossbeam_channel::unbounded; +use std::collections::HashMap; +use std::ffi::c_void; use std::sync::OnceLock; +use std::sync::atomic::AtomicI64; +use std::sync::atomic::Ordering; use std::time::Duration; +use windows::Win32::Foundation::COLORREF; use windows::Win32::Foundation::HWND; use windows::Win32::Foundation::LPARAM; use windows::Win32::Foundation::LRESULT; +use windows::Win32::Foundation::POINT; use windows::Win32::Foundation::RECT; +use windows::Win32::Foundation::SIZE; use windows::Win32::Foundation::WPARAM; use windows::Win32::Graphics::Dwm::DWM_THUMBNAIL_PROPERTIES; use windows::Win32::Graphics::Dwm::DWM_TNP_OPACITY; use windows::Win32::Graphics::Dwm::DWM_TNP_RECTDESTINATION; -use windows::Win32::Graphics::Dwm::DWM_TNP_SOURCECLIENTAREAONLY; use windows::Win32::Graphics::Dwm::DWM_TNP_VISIBLE; +use windows::Win32::Graphics::Gdi::AC_SRC_ALPHA; +use windows::Win32::Graphics::Gdi::AC_SRC_OVER; +use windows::Win32::Graphics::Gdi::BI_RGB; +use windows::Win32::Graphics::Gdi::BITMAPINFO; +use windows::Win32::Graphics::Gdi::BITMAPINFOHEADER; +use windows::Win32::Graphics::Gdi::BLENDFUNCTION; +use windows::Win32::Graphics::Gdi::CreateCompatibleDC; +use windows::Win32::Graphics::Gdi::CreateDIBSection; +use windows::Win32::Graphics::Gdi::DIB_RGB_COLORS; +use windows::Win32::Graphics::Gdi::DeleteDC; +use windows::Win32::Graphics::Gdi::DeleteObject; +use windows::Win32::Graphics::Gdi::GetDC; +use windows::Win32::Graphics::Gdi::ReleaseDC; +use windows::Win32::Graphics::Gdi::SelectObject; +use windows::Win32::UI::WindowsAndMessaging::CreateWindowExW; use windows::Win32::UI::WindowsAndMessaging::DefWindowProcW; -use windows::Win32::UI::WindowsAndMessaging::DestroyWindow; use windows::Win32::UI::WindowsAndMessaging::DispatchMessageW; -use windows::Win32::UI::WindowsAndMessaging::HWND_TOP; +use windows::Win32::UI::WindowsAndMessaging::GetSystemMetrics; +use windows::Win32::UI::WindowsAndMessaging::HWND_NOTOPMOST; +use windows::Win32::UI::WindowsAndMessaging::HWND_TOPMOST; use windows::Win32::UI::WindowsAndMessaging::MSG; use windows::Win32::UI::WindowsAndMessaging::PM_REMOVE; use windows::Win32::UI::WindowsAndMessaging::PeekMessageW; -use windows::Win32::UI::WindowsAndMessaging::SET_WINDOW_POS_FLAGS; -use windows::Win32::UI::WindowsAndMessaging::SHOW_WINDOW_CMD; +use windows::Win32::UI::WindowsAndMessaging::SM_CXVIRTUALSCREEN; +use windows::Win32::UI::WindowsAndMessaging::SM_CYVIRTUALSCREEN; +use windows::Win32::UI::WindowsAndMessaging::SM_XVIRTUALSCREEN; +use windows::Win32::UI::WindowsAndMessaging::SM_YVIRTUALSCREEN; use windows::Win32::UI::WindowsAndMessaging::SWP_NOACTIVATE; -use windows::Win32::UI::WindowsAndMessaging::SWP_NOREDRAW; use windows::Win32::UI::WindowsAndMessaging::SWP_NOZORDER; -use windows::Win32::UI::WindowsAndMessaging::SWP_SHOWWINDOW; use windows::Win32::UI::WindowsAndMessaging::SetWindowPos; -use windows::Win32::UI::WindowsAndMessaging::ShowWindow; use windows::Win32::UI::WindowsAndMessaging::TranslateMessage; +use windows::Win32::UI::WindowsAndMessaging::ULW_ALPHA; +use windows::Win32::UI::WindowsAndMessaging::UpdateLayeredWindow; use windows::Win32::UI::WindowsAndMessaging::WNDCLASSW; +use windows::Win32::UI::WindowsAndMessaging::WS_EX_LAYERED; +use windows::Win32::UI::WindowsAndMessaging::WS_EX_NOACTIVATE; +use windows::Win32::UI::WindowsAndMessaging::WS_EX_TOOLWINDOW; +use windows::Win32::UI::WindowsAndMessaging::WS_EX_TRANSPARENT; +use windows::Win32::UI::WindowsAndMessaging::WS_POPUP; +use windows::Win32::UI::WindowsAndMessaging::WS_VISIBLE; use windows::core::PCWSTR; use crate::WindowsApi; +use crate::animation::snapshot::Snapshot; +use crate::animation::snapshot::SnapshotWindow; use crate::core::Rect; -use crate::windows_api; const GHOST_CLASS_NAME: &[u16] = &[ b'k' as u16, @@ -55,20 +100,22 @@ const GHOST_CLASS_NAME: &[u16] = &[ 0, ]; +/// Thumbnails currently registered against the host. Returns to zero when no +/// ghost is alive; anything else is a leak of compositor handles. +static REGISTERED_THUMBNAILS: AtomicI64 = AtomicI64::new(0); + +/// How many ghost thumbnails are currently registered with DWM. +pub fn registered_thumbnails() -> i64 { + REGISTERED_THUMBNAILS.load(Ordering::SeqCst) +} + enum GhostCmd { Create { - src_hwnd: isize, + snapshot: Snapshot, start_rect: Rect, - z_above: Option, - reply: Sender>, - }, - UpdateRect { - host_hwnd: isize, - hthumb: isize, - rect: Rect, + reply: Sender>, }, Destroy { - host_hwnd: isize, hthumb: isize, }, } @@ -91,10 +138,10 @@ fn ghost_owner() -> &'static GhostOwner { } /// Eagerly initialise the ghost owner thread so the first movement animation -/// doesn't pay the spawn + class-registration cost. Idempotent. No-op for -/// users who never enable ghost movement only if it isn't called; calling -/// from a code path that's gated on `GHOST_MOVEMENT_ENABLED` keeps the lazy -/// guarantee. +/// doesn't pay the spawn + class-registration + host window creation cost. +/// Idempotent. Calling this only from a code path that is gated on +/// `GHOST_MOVEMENT_ENABLED` keeps the lazy guarantee for users who never +/// enable ghost movement. pub fn prewarm() { let _ = ghost_owner(); } @@ -118,14 +165,248 @@ fn register_ghost_class() -> eyre::Result<()> { Ok(()) } -fn run_owner_loop(cmd_rx: crossbeam_channel::Receiver) { - if let Err(error) = register_ghost_class() { - tracing::error!("ghost owner: failed to register class: {error}"); - return; +/// The bounds of the virtual screen: (x, y, width, height). +fn virtual_screen() -> (i32, i32, i32, i32) { + unsafe { + ( + GetSystemMetrics(SM_XVIRTUALSCREEN), + GetSystemMetrics(SM_YVIRTUALSCREEN), + GetSystemMetrics(SM_CXVIRTUALSCREEN), + GetSystemMetrics(SM_CYVIRTUALSCREEN), + ) + } +} + +/// The state that only the owner thread touches. +struct GhostHost { + hwnd: isize, + bounds: (i32, i32, i32, i32), + /// The thumbnails registered against the host, and the window each one + /// shows, which is kept for as long as the thumbnail is. The host is only on top of other windows while there are any. + thumbnails: HashMap, +} + +impl GhostHost { + fn create() -> eyre::Result { + register_ghost_class()?; + + let instance = WindowsApi::module_handle_w()?; + let class_name = PCWSTR(GHOST_CLASS_NAME.as_ptr()); + let bounds = virtual_screen(); + + // Layered, so that the parts of the host that no thumbnail covers are + // transparent, and transparent for input, so that it never gets in + // the way of the mouse. + let hwnd = unsafe { + CreateWindowExW( + WS_EX_LAYERED | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE | WS_EX_TRANSPARENT, + class_name, + class_name, + WS_POPUP | WS_VISIBLE, + bounds.0, + bounds.1, + bounds.2, + bounds.3, + None, + None, + Some(instance.into()), + None, + )? + }; + + unsafe { init_layered_transparent(hwnd) }; + + let host = Self { + hwnd: hwnd.0 as isize, + bounds, + thumbnails: HashMap::new(), + }; + + // Idle, the host stays out of the way of everything, the taskbar's + // auto-hide animation included. + host.set_topmost(false); + + Ok(host) } + fn set_topmost(&self, topmost: bool) { + unsafe { + let _ = SetWindowPos( + HWND(self.hwnd as *mut c_void), + Some(if topmost { + HWND_TOPMOST + } else { + HWND_NOTOPMOST + }), + self.bounds.0, + self.bounds.1, + self.bounds.2, + self.bounds.3, + SWP_NOACTIVATE, + ); + } + } + + /// Follow the virtual screen when monitors have come or gone since the + /// host was last sized. + fn fit_to_virtual_screen(&mut self) { + let bounds = virtual_screen(); + if bounds == self.bounds { + return; + } + + self.bounds = bounds; + unsafe { + let _ = SetWindowPos( + HWND(self.hwnd as *mut c_void), + None, + bounds.0, + bounds.1, + bounds.2, + bounds.3, + SWP_NOACTIVATE | SWP_NOZORDER, + ); + } + } + + fn origin(&self) -> (i32, i32) { + (self.bounds.0, self.bounds.1) + } + + fn register( + &mut self, + snapshot: &Snapshot, + start_rect: Rect, + ) -> eyre::Result<(isize, (i32, i32))> { + self.fit_to_virtual_screen(); + + let source = SnapshotWindow::create(snapshot)?; + + // On top before the first thumbnail can be seen + if self.thumbnails.is_empty() { + self.set_topmost(true); + } + + let origin = self.origin(); + let registered = + WindowsApi::dwm_register_thumbnail(self.hwnd, source.hwnd).and_then(|hthumb| { + let props = thumbnail_properties(destination(start_rect, origin), 255); + WindowsApi::dwm_update_thumbnail_properties(hthumb, &props) + .inspect_err(|_| { + let _ = WindowsApi::dwm_unregister_thumbnail(hthumb); + }) + .map(|()| hthumb) + }); + + match registered { + Ok(hthumb) => { + self.thumbnails.insert(hthumb, source); + REGISTERED_THUMBNAILS.fetch_add(1, Ordering::SeqCst); + tracing::trace!( + "ghost owner: registered thumbnail {hthumb}, {} registered", + registered_thumbnails() + ); + Ok((hthumb, origin)) + } + Err(error) => { + if self.thumbnails.is_empty() { + self.set_topmost(false); + } + Err(error) + } + } + } + + fn unregister(&mut self, hthumb: isize) { + if let Err(error) = WindowsApi::dwm_unregister_thumbnail(hthumb) { + tracing::warn!("ghost owner: failed to unregister thumbnail {hthumb}: {error}"); + } + + if self.thumbnails.remove(&hthumb).is_some() { + REGISTERED_THUMBNAILS.fetch_sub(1, Ordering::SeqCst); + } + + tracing::trace!( + "ghost owner: unregistered thumbnail {hthumb}, {} registered", + registered_thumbnails() + ); + + if self.thumbnails.is_empty() { + self.set_topmost(false); + } + } +} + +/// Give a layered window a 1x1 fully transparent backing. A layered window +/// that was never given one is not reliably composed, and thumbnails drawn on +/// it do not show up on every GPU. +unsafe fn init_layered_transparent(hwnd: HWND) { + unsafe { + let screen_dc = GetDC(None); + let mem_dc = CreateCompatibleDC(Some(screen_dc)); + + let bmi = BITMAPINFO { + bmiHeader: BITMAPINFOHEADER { + biSize: std::mem::size_of::() as u32, + biWidth: 1, + biHeight: 1, + biPlanes: 1, + biBitCount: 32, + biCompression: BI_RGB.0, + ..Default::default() + }, + ..Default::default() + }; + + let mut bits: *mut c_void = std::ptr::null_mut(); + let Ok(bitmap) = CreateDIBSection(Some(mem_dc), &bmi, DIB_RGB_COLORS, &mut bits, None, 0) + else { + let _ = DeleteDC(mem_dc); + ReleaseDC(None, screen_dc); + return; + }; + + if !bits.is_null() { + std::ptr::write_bytes(bits.cast::(), 0, 4); + } + + let previous = SelectObject(mem_dc, bitmap.into()); + + let _ = UpdateLayeredWindow( + hwnd, + None, + None, + Some(&SIZE { cx: 1, cy: 1 }), + Some(mem_dc), + Some(&POINT { x: 0, y: 0 }), + COLORREF(0), + Some(&BLENDFUNCTION { + BlendOp: AC_SRC_OVER as u8, + BlendFlags: 0, + SourceConstantAlpha: 255, + AlphaFormat: AC_SRC_ALPHA as u8, + }), + ULW_ALPHA, + ); + + SelectObject(mem_dc, previous); + let _ = DeleteObject(bitmap.into()); + let _ = DeleteDC(mem_dc); + ReleaseDC(None, screen_dc); + } +} + +fn run_owner_loop(cmd_rx: crossbeam_channel::Receiver) { + let mut host = match GhostHost::create() { + Ok(host) => Some(host), + Err(error) => { + tracing::error!("ghost owner: failed to create the host window: {error}"); + None + } + }; + loop { - // Drain any pending Win32 messages (DWM/system messages destined for our hosts). + // Drain any pending Win32 messages (DWM/system messages destined for the host). unsafe { let mut msg = MSG::default(); while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() { @@ -135,206 +416,106 @@ fn run_owner_loop(cmd_rx: crossbeam_channel::Receiver) { } match cmd_rx.recv_timeout(Duration::from_millis(8)) { - Ok(cmd) => handle_cmd(cmd), + Ok(GhostCmd::Create { + snapshot, + start_rect, + reply, + }) => { + let result = match host.as_mut() { + Some(host) => host.register(&snapshot, start_rect), + None => Err(eyre::eyre!("the ghost host window could not be created")), + }; + let _ = reply.send(result); + } + Ok(GhostCmd::Destroy { hthumb }) => { + if let Some(host) = host.as_mut() { + host.unregister(hthumb); + } + } Err(crossbeam_channel::RecvTimeoutError::Timeout) => continue, Err(crossbeam_channel::RecvTimeoutError::Disconnected) => break, } } } -fn handle_cmd(cmd: GhostCmd) { - match cmd { - GhostCmd::Create { - src_hwnd, - start_rect, - z_above, - reply, - } => { - let result = create_ghost(src_hwnd, start_rect, z_above); - let _ = reply.send(result); - } - GhostCmd::UpdateRect { - host_hwnd, - hthumb, - rect, - } => { - if let Err(error) = update_ghost(host_hwnd, hthumb, rect) { - tracing::trace!("ghost owner: update failed: {error}"); - } - } - GhostCmd::Destroy { host_hwnd, hthumb } => { - destroy_ghost(host_hwnd, hthumb); - } +/// A rect (left/top and width/height, in screen coordinates) as a thumbnail +/// destination (in the client coordinates of the host). +fn destination(rect: Rect, origin: (i32, i32)) -> RECT { + RECT { + left: rect.left - origin.0, + top: rect.top - origin.1, + right: rect.left - origin.0 + rect.right, + bottom: rect.top - origin.1 + rect.bottom, } } -fn instance_handle() -> eyre::Result { - let h_module = WindowsApi::module_handle_w()?; - Ok(h_module.0 as isize) -} - -fn create_ghost( - src_hwnd: isize, - start_rect: Rect, - z_above: Option, -) -> eyre::Result<(isize, isize)> { - let class_name = PCWSTR(GHOST_CLASS_NAME.as_ptr()); - let host_hwnd = WindowsApi::create_ghost_host_window(class_name, instance_handle()?)?; - - // Position the host at start_rect (Rect uses left/top + width/height). - let z_after = match z_above { - Some(hwnd) => HWND(windows_api::as_ptr!(hwnd)), - None => HWND_TOP, - }; - let flags = SWP_NOACTIVATE | SWP_NOREDRAW | SWP_SHOWWINDOW; - unsafe { - let _ = SetWindowPos( - HWND(windows_api::as_ptr!(host_hwnd)), - Option::from(z_after), - start_rect.left, - start_rect.top, - start_rect.right, - start_rect.bottom, - flags, - ); - } - - let hthumb = match WindowsApi::dwm_register_thumbnail(host_hwnd, src_hwnd) { - Ok(h) => h, - Err(error) => { - unsafe { - let _ = DestroyWindow(HWND(windows_api::as_ptr!(host_hwnd))); - } - return Err(error); - } - }; - - let props = thumbnail_properties(start_rect.right, start_rect.bottom); - if let Err(error) = WindowsApi::dwm_update_thumbnail_properties(hthumb, &props) { - let _ = WindowsApi::dwm_unregister_thumbnail(hthumb); - unsafe { - let _ = DestroyWindow(HWND(windows_api::as_ptr!(host_hwnd))); - } - return Err(error); - } - - // Make the host visible. Layered/transparent ext styles ensure no input. - unsafe { - let _ = ShowWindow( - HWND(windows_api::as_ptr!(host_hwnd)), - SHOW_WINDOW_CMD(8), // SW_SHOWNA - ); - } - - Ok((host_hwnd, hthumb)) -} - -fn update_ghost(host_hwnd: isize, hthumb: isize, rect: Rect) -> eyre::Result<()> { - let flags: SET_WINDOW_POS_FLAGS = SWP_NOACTIVATE | SWP_NOZORDER | SWP_NOREDRAW; - unsafe { - SetWindowPos( - HWND(windows_api::as_ptr!(host_hwnd)), - None, - rect.left, - rect.top, - rect.right, - rect.bottom, - flags, - )?; - } - - let props = thumbnail_properties(rect.right, rect.bottom); - WindowsApi::dwm_update_thumbnail_properties(hthumb, &props) -} - -fn destroy_ghost(host_hwnd: isize, hthumb: isize) { - let _ = WindowsApi::dwm_unregister_thumbnail(hthumb); - unsafe { - let _ = DestroyWindow(HWND(windows_api::as_ptr!(host_hwnd))); - } -} - -fn thumbnail_properties(width: i32, height: i32) -> DWM_THUMBNAIL_PROPERTIES { +fn thumbnail_properties(destination: RECT, opacity: u8) -> DWM_THUMBNAIL_PROPERTIES { DWM_THUMBNAIL_PROPERTIES { - dwFlags: DWM_TNP_VISIBLE - | DWM_TNP_RECTDESTINATION - | DWM_TNP_OPACITY - | DWM_TNP_SOURCECLIENTAREAONLY, - rcDestination: RECT { - left: 0, - top: 0, - right: width, - bottom: height, - }, + dwFlags: DWM_TNP_VISIBLE | DWM_TNP_RECTDESTINATION | DWM_TNP_OPACITY, + rcDestination: destination, rcSource: RECT::default(), - opacity: 255, + opacity, fVisible: true.into(), fSourceClientAreaOnly: false.into(), } } -/// A live DWM-thumbnail "ghost" of a source window, used during movement -/// animations. While a ghost is active, the source window is typically cloaked -/// by the caller. The ghost is automatically disposed on drop, but callers -/// should prefer explicit `dispose()` to surface errors. +/// A "ghost" of a window, used during movement animations: a still picture of +/// the window, taken when the animation starts, that is moved and scaled in the +/// window's place while the window itself, cloaked, is laid out again at its +/// new size. The ghost is automatically disposed on drop, but callers should +/// prefer explicit `dispose()` to surface errors. pub struct GhostWindow { - host_hwnd: isize, hthumb: isize, + /// Where the host's client area starts in screen coordinates + origin: (i32, i32), disposed: bool, } impl GhostWindow { - pub fn create(src_hwnd: isize, start_rect: Rect, z_above: Option) -> eyre::Result { - let (reply_tx, reply_rx) = bounded::>(1); + /// Take a picture of the window and show it where the window is. The + /// picture is taken before the window is touched. + pub fn create(src_hwnd: isize, start_rect: Rect) -> eyre::Result { + let snapshot = Snapshot::capture(src_hwnd)?; + + let (reply_tx, reply_rx) = bounded::>(1); ghost_owner() .cmd_tx .send(GhostCmd::Create { - src_hwnd, + snapshot, start_rect, - z_above, reply: reply_tx, }) .map_err(|e| eyre::eyre!("ghost owner channel send failed: {e}"))?; - let (host_hwnd, hthumb) = reply_rx.recv()??; + let (hthumb, origin) = reply_rx.recv()??; + Ok(Self { - host_hwnd, hthumb, + origin, disposed: false, }) } - pub fn host_hwnd(&self) -> isize { - self.host_hwnd + /// Move and scale the ghost to `rect`, and make it `opacity` opaque. + /// Updating a registered thumbnail has no thread affinity, so this is + /// applied on the calling thread and is done by the time it returns. + pub fn update(&self, rect: Rect, opacity: u8) -> eyre::Result<()> { + WindowsApi::dwm_update_thumbnail_properties( + self.hthumb, + &thumbnail_properties(destination(rect, self.origin), opacity), + ) } - pub fn update_rect(&self, rect: Rect) -> eyre::Result<()> { - ghost_owner() - .cmd_tx - .send(GhostCmd::UpdateRect { - host_hwnd: self.host_hwnd, - hthumb: self.hthumb, - rect, - }) - .map_err(|e| eyre::eyre!("ghost owner channel send failed: {e}")) - } - - /// Apply an opacity change directly via `DwmUpdateThumbnailProperties` on - /// the calling thread. Unlike rect updates (which call `SetWindowPos` and - /// therefore need the owner thread), opacity-only updates don't have - /// thread affinity, and going through the channel introduces a race where - /// the next `DwmFlush()` on the caller's thread can fire before the owner - /// has processed the SetOpacity command — which collapses what should be - /// a multi-frame fade into a single visible step. - pub fn set_opacity(&self, opacity: u8) -> eyre::Result<()> { - let props = DWM_THUMBNAIL_PROPERTIES { - dwFlags: DWM_TNP_OPACITY | DWM_TNP_VISIBLE, - rcDestination: RECT::default(), - rcSource: RECT::default(), - opacity, - fVisible: true.into(), - fSourceClientAreaOnly: false.into(), - }; - WindowsApi::dwm_update_thumbnail_properties(self.hthumb, &props) + /// Fade the ghost out over a few frames, where it is, for when there is no + /// animation left to do it in. + pub fn fade_out(&self, rect: Rect) { + for step in 1..=FADE_OUT_FRAMES { + let opacity = ghost_opacity(f64::from(step) / f64::from(FADE_OUT_FRAMES), 0.0); + let _ = self.update(rect, opacity); + unsafe { + let _ = windows::Win32::Graphics::Dwm::DwmFlush(); + } + } } pub fn dispose(mut self) -> eyre::Result<()> { @@ -349,7 +530,6 @@ impl GhostWindow { ghost_owner() .cmd_tx .send(GhostCmd::Destroy { - host_hwnd: self.host_hwnd, hthumb: self.hthumb, }) .map_err(|e| eyre::eyre!("ghost owner channel send failed: {e}")) @@ -361,3 +541,147 @@ impl Drop for GhostWindow { let _ = self.dispose_inner(); } } + +/// How many frames a ghost is faded out over once its animation is over +const FADE_OUT_FRAMES: u32 = 6; + +/// How far into an animation the window itself is shown again, under the +/// ghost, which fades out over it from there. The window has been resizing +/// since the animation started, so that is how long it has had to lay itself +/// out at its new size. +pub const REVEAL_AT: f64 = 0.55; + +/// How opaque the ghost is, from 0 to 255, `progress` of the way through the +/// animation, when the window was shown again `revealed_at` of the way +/// through: opaque until then, and then fading out smoothly to nothing at the +/// end, where the window is all that is left. +#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] +pub fn ghost_opacity(progress: f64, revealed_at: f64) -> u8 { + if progress >= 1.0 { + return 0; + } + + if progress < revealed_at { + return 255; + } + + let t = ((progress - revealed_at) / (1.0 - revealed_at).max(f64::EPSILON)).clamp(0.0, 1.0); + let smooth = t * t * (3.0 - 2.0 * t); + + ((1.0 - smooth) * 255.0).round() as u8 +} + +/// Whether a window class belongs to an application that renders through a +/// swap chain (DirectComposition) and so can be left with a stale render +/// target when it is resized while cloaked: Chromium and Electron, Firefox, +/// Windows Terminal, and the .NET Framework UI stacks. +pub fn is_swap_chain_class(class: &str) -> bool { + class.starts_with("Chrome_WidgetWin_") + || class == "MozillaWindowClass" + || class == "CASCADIA_HOSTING_WINDOW_CLASS" + || class.starts_with("WindowsForms10.") + || class.starts_with("HwndWrapper") +} + +pub fn is_swap_chain_window(hwnd: isize) -> bool { + WindowsApi::real_window_class_w(hwnd) + .map(|class| is_swap_chain_class(&class)) + .unwrap_or(false) +} + +/// Resize a window to one pixel narrower than `rect` and straight back. +/// +/// After a burst of position changes the compositor of a swap chain window +/// can see a final size that is the same as the last one it was told about +/// and so never rebuild its render target; the window is then stuck with a +/// broken internal viewport until it is restarted. Two real size changes +/// make it rebuild. +/// +/// The window is checked between the two calls: the first one pumps messages +/// on the window's thread, and in that time the window can be destroyed and +/// its handle reused for an unrelated window that must not be resized. +pub fn nudge_swap_chain_window(hwnd: isize, rect: &Rect, top: bool) { + if rect.right <= 1 { + return; + } + + let Ok(class) = WindowsApi::real_window_class_w(hwnd) else { + return; + }; + + let narrower = Rect { + right: rect.right - 1, + ..*rect + }; + + if WindowsApi::position_window(hwnd, &narrower, top, false).is_err() { + return; + } + + if !WindowsApi::is_window(hwnd) + || WindowsApi::real_window_class_w(hwnd).is_ok_and(|now| now != class) + { + return; + } + + if let Err(error) = WindowsApi::position_window(hwnd, rect, top, false) { + tracing::warn!( + "ghost movement: failed to restore hwnd {hwnd} after a swap chain nudge, \ + it is left one pixel narrower until the next layout pass: {error}" + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn swap_chain_classes() { + assert!(is_swap_chain_class("Chrome_WidgetWin_0")); + assert!(is_swap_chain_class("Chrome_WidgetWin_1")); + assert!(is_swap_chain_class("MozillaWindowClass")); + assert!(is_swap_chain_class("CASCADIA_HOSTING_WINDOW_CLASS")); + assert!(is_swap_chain_class("WindowsForms10.Window.8.app.0.1a2b3c")); + assert!(is_swap_chain_class("HwndWrapper[App.exe;;abc-123]")); + assert!(!is_swap_chain_class("Notepad")); + assert!(!is_swap_chain_class("CabinetWClass")); + assert!(!is_swap_chain_class("Chrome_RenderWidgetHostHWND")); + assert!(!is_swap_chain_class("")); + } + + #[test] + fn the_ghost_is_opaque_until_the_window_is_shown_and_gone_at_the_end() { + assert_eq!(ghost_opacity(0.0, REVEAL_AT), 255); + assert_eq!(ghost_opacity(REVEAL_AT - 0.01, REVEAL_AT), 255); + assert_eq!(ghost_opacity(REVEAL_AT, REVEAL_AT), 255); + assert_eq!(ghost_opacity(1.0, REVEAL_AT), 0); + // an animation that overran + assert_eq!(ghost_opacity(1.3, REVEAL_AT), 0); + + let fade = (0..=20) + .map(|step| { + ghost_opacity( + REVEAL_AT + (1.0 - REVEAL_AT) * f64::from(step) / 20.0, + REVEAL_AT, + ) + }) + .collect::>(); + assert!(fade.windows(2).all(|pair| pair[0] >= pair[1]), "{fade:?}"); + // it is half gone half way through the fade, and not before + assert!((120..=135).contains(&fade[10]), "{fade:?}"); + } + + #[test] + fn a_window_that_is_shown_late_has_the_ghost_fade_over_what_is_left() { + // shown 80% of the way through: opaque until then, gone at the end + assert_eq!(ghost_opacity(0.79, 0.8), 255); + assert_eq!(ghost_opacity(0.8, 0.8), 255); + assert!(ghost_opacity(0.9, 0.8) < 255); + assert_eq!(ghost_opacity(1.0, 0.8), 0); + + // shown at the very end there is nothing left to fade over + assert_eq!(ghost_opacity(1.0, 1.0), 0); + assert_eq!(ghost_opacity(0.99, 1.0), 255); + } +} diff --git a/komorebi/src/animation/mod.rs b/komorebi/src/animation/mod.rs index 0057a877..e22008ff 100644 --- a/komorebi/src/animation/mod.rs +++ b/komorebi/src/animation/mod.rs @@ -17,6 +17,7 @@ pub mod ghost; pub mod lerp; pub mod prefix; pub mod render_dispatcher; +pub mod snapshot; pub use render_dispatcher::RenderDispatcher; pub mod style; diff --git a/komorebi/src/animation/snapshot.rs b/komorebi/src/animation/snapshot.rs new file mode 100644 index 00000000..0876cfb4 --- /dev/null +++ b/komorebi/src/animation/snapshot.rs @@ -0,0 +1,500 @@ +//! A still picture of a window, for a ghost to show. +//! +//! A ghost that is a live thumbnail of the window it stands in for shows +//! whatever the window is painting, and a window that has just been resized +//! paints a blank frame, or nothing at all, before it paints its new layout. +//! A ghost has to hold on to what the window looked like when it started +//! moving, so the window is captured once, and the picture is shown through a +//! window of our own, which DWM can scale on the GPU like any other. + +use color_eyre::eyre; +use std::ffi::c_void; +use std::sync::OnceLock; +use windows::Win32::Foundation::COLORREF; +use windows::Win32::Foundation::HWND; +use windows::Win32::Foundation::POINT; +use windows::Win32::Foundation::RECT; +use windows::Win32::Foundation::SIZE; +use windows::Win32::Graphics::Dwm::DWMWA_CLOAK; +use windows::Win32::Graphics::Dwm::DwmSetWindowAttribute; +use windows::Win32::Graphics::Gdi::AC_SRC_ALPHA; +use windows::Win32::Graphics::Gdi::AC_SRC_OVER; +use windows::Win32::Graphics::Gdi::BI_RGB; +use windows::Win32::Graphics::Gdi::BITMAPINFO; +use windows::Win32::Graphics::Gdi::BITMAPINFOHEADER; +use windows::Win32::Graphics::Gdi::BLENDFUNCTION; +use windows::Win32::Graphics::Gdi::CreateCompatibleDC; +use windows::Win32::Graphics::Gdi::CreateDIBSection; +use windows::Win32::Graphics::Gdi::DIB_RGB_COLORS; +use windows::Win32::Graphics::Gdi::DeleteDC; +use windows::Win32::Graphics::Gdi::DeleteObject; +use windows::Win32::Graphics::Gdi::GetDC; +use windows::Win32::Graphics::Gdi::HBITMAP; +use windows::Win32::Graphics::Gdi::HDC; +use windows::Win32::Graphics::Gdi::ReleaseDC; +use windows::Win32::Graphics::Gdi::SelectObject; +use windows::Win32::Storage::Xps::PRINT_WINDOW_FLAGS; +use windows::Win32::Storage::Xps::PrintWindow; +use windows::Win32::UI::WindowsAndMessaging::CreateWindowExW; +use windows::Win32::UI::WindowsAndMessaging::DefWindowProcW; +use windows::Win32::UI::WindowsAndMessaging::DestroyWindow; +use windows::Win32::UI::WindowsAndMessaging::GetWindowRect; +use windows::Win32::UI::WindowsAndMessaging::SW_SHOWNA; +use windows::Win32::UI::WindowsAndMessaging::ShowWindow; +use windows::Win32::UI::WindowsAndMessaging::ULW_ALPHA; +use windows::Win32::UI::WindowsAndMessaging::UpdateLayeredWindow; +use windows::Win32::UI::WindowsAndMessaging::WNDCLASSW; +use windows::Win32::UI::WindowsAndMessaging::WS_EX_LAYERED; +use windows::Win32::UI::WindowsAndMessaging::WS_EX_NOACTIVATE; +use windows::Win32::UI::WindowsAndMessaging::WS_EX_TOOLWINDOW; +use windows::Win32::UI::WindowsAndMessaging::WS_EX_TRANSPARENT; +use windows::Win32::UI::WindowsAndMessaging::WS_POPUP; +use windows::core::BOOL; +use windows::core::PCWSTR; +use windows::core::w; + +use crate::WindowsApi; + +/// Ask for what the window looks like to the compositor, which is the only +/// way to capture a window that is drawn with DirectComposition +const PW_RENDERFULLCONTENT: u32 = 2; + +/// How round the corners of windows are, in pixels: 8 at 100% scale, a little +/// more where the display is scaled up, so a little more than 8 for them all. +const CORNER_RADIUS: i32 = 10; + +/// The picture of a window, as it was shown on screen: without the invisible +/// borders it has around it. +pub struct Snapshot { + bitmap: HBITMAP, + pub width: i32, + pub height: i32, +} + +// A GDI bitmap is not tied to the thread that made it +unsafe impl Send for Snapshot {} + +impl Drop for Snapshot { + fn drop(&mut self) { + unsafe { + let _ = DeleteObject(self.bitmap.into()); + } + } +} + +fn create_dib(dc: HDC, width: i32, height: i32) -> eyre::Result<(HBITMAP, &'static mut [u32])> { + let bmi = BITMAPINFO { + bmiHeader: BITMAPINFOHEADER { + biSize: std::mem::size_of::() as u32, + biWidth: width, + // top-down + biHeight: -height, + biPlanes: 1, + biBitCount: 32, + biCompression: BI_RGB.0, + ..Default::default() + }, + ..Default::default() + }; + + let mut bits: *mut c_void = std::ptr::null_mut(); + let bitmap = unsafe { CreateDIBSection(Some(dc), &bmi, DIB_RGB_COLORS, &mut bits, None, 0) }?; + + if bits.is_null() { + unsafe { + let _ = DeleteObject(bitmap.into()); + } + eyre::bail!("a bitmap of {width}x{height} has no pixels"); + } + + // The pixels live as long as the bitmap, which the caller owns + let pixels = + unsafe { std::slice::from_raw_parts_mut(bits.cast::(), (width * height) as usize) }; + + Ok((bitmap, pixels)) +} + +/// Copy a `width` x `height` area, starting at (`x`, `y`) in `source`, which +/// is `source_width` pixels wide, into `destination`. +fn copy_area( + source: &[u32], + source_width: i32, + x: i32, + y: i32, + destination: &mut [u32], + width: i32, + height: i32, +) { + for row in 0..height { + let from = ((y + row) * source_width + x) as usize; + let to = (row * width) as usize; + destination[to..to + width as usize].copy_from_slice(&source[from..from + width as usize]); + } +} + +/// Make every pixel opaque, except for the ones outside the rounded corners of +/// a window, which are made fully transparent. (Premultiplied, so those are +/// all zero.) +fn round_corners(pixels: &mut [u32], width: i32, height: i32, radius: i32) { + let radius = radius.min(width / 2).min(height / 2).max(0); + + for pixel in pixels.iter_mut() { + *pixel |= 0xFF00_0000; + } + + for corner_y in 0..radius { + for corner_x in 0..radius { + // the distance from the middle of the circle the corner is a + // quarter of, to the middle of this pixel + let dx = (radius - corner_x) as f32 - 0.5; + let dy = (radius - corner_y) as f32 - 0.5; + + if dx * dx + dy * dy > (radius * radius) as f32 { + for (x, y) in [ + (corner_x, corner_y), + (width - 1 - corner_x, corner_y), + (corner_x, height - 1 - corner_y), + (width - 1 - corner_x, height - 1 - corner_y), + ] { + pixels[(y * width + x) as usize] = 0; + } + } + } + } +} + +/// Ask a window to paint itself, as it is now. Taking a picture of a window +/// makes it render synchronously (see `Snapshot::capture`), which is also a +/// way to have a window that has just been resized lay itself out at its new +/// size, without waiting for it to get round to it. +pub fn paint_now(hwnd: isize) { + let _ = Snapshot::capture(hwnd); +} + +impl Snapshot { + /// Take a picture of a window as it is now. This asks the window to + /// render itself, so it can take a while for one that is busy, and it is + /// not for the thread that handles komorebi's events. + pub fn capture(hwnd: isize) -> eyre::Result { + let handle = HWND(hwnd as *mut c_void); + + let mut window = RECT::default(); + unsafe { GetWindowRect(handle, &mut window) }?; + let window_width = window.right - window.left; + let window_height = window.bottom - window.top; + + // what is on screen is the extended frame, inside the invisible borders + let frame = WindowsApi::window_rect(hwnd)?; + let x = (frame.left - window.left).clamp(0, window_width); + let y = (frame.top - window.top).clamp(0, window_height); + let width = frame.right.min(window_width - x); + let height = frame.bottom.min(window_height - y); + + if width <= 0 || height <= 0 { + eyre::bail!("hwnd {hwnd} has no visible area to take a picture of"); + } + + unsafe { + let screen = GetDC(None); + let dc = CreateCompatibleDC(Some(screen)); + + let result = (|| { + let (full, full_pixels) = create_dib(dc, window_width, window_height)?; + let previous = SelectObject(dc, full.into()); + let printed = PrintWindow(handle, dc, PRINT_WINDOW_FLAGS(PW_RENDERFULLCONTENT)); + SelectObject(dc, previous); + + let result = if printed.as_bool() { + create_dib(dc, width, height).map(|(bitmap, pixels)| { + copy_area(full_pixels, window_width, x, y, pixels, width, height); + round_corners(pixels, width, height, CORNER_RADIUS); + Self { + bitmap, + width, + height, + } + }) + } else { + Err(eyre::eyre!("hwnd {hwnd} would not render itself")) + }; + + let _ = DeleteObject(full.into()); + result + })(); + + let _ = DeleteDC(dc); + ReleaseDC(None, screen); + + result + } + } + + /// A picture of one colour, for tests + #[cfg(test)] + pub fn solid(width: i32, height: i32, color: u32) -> Self { + unsafe { + let screen = GetDC(None); + let dc = CreateCompatibleDC(Some(screen)); + let (bitmap, pixels) = create_dib(dc, width, height).unwrap(); + pixels.fill(color | 0xFF00_0000); + let _ = DeleteDC(dc); + ReleaseDC(None, screen); + + Self { + bitmap, + width, + height, + } + } + } +} + +extern "system" fn snapshot_wnd_proc( + hwnd: HWND, + msg: u32, + wparam: windows::Win32::Foundation::WPARAM, + lparam: windows::Win32::Foundation::LPARAM, +) -> windows::Win32::Foundation::LRESULT { + unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) } +} + +fn register_class() -> eyre::Result { + static REGISTERED: OnceLock<()> = OnceLock::new(); + + let class = w!("komorebi-ghost-snapshot"); + + REGISTERED.get_or_init(|| { + if let Ok(instance) = WindowsApi::module_handle_w() { + let class = WNDCLASSW { + hInstance: instance.into(), + lpszClassName: class, + lpfnWndProc: Some(snapshot_wnd_proc), + ..Default::default() + }; + // already being registered is fine + let _ = WindowsApi::register_class_w(&class); + } + }); + + Ok(class) +} + +/// A window that shows a `Snapshot` and is never seen itself: it is cloaked, +/// which keeps it composed, so that DWM can be asked for a thumbnail of it. +/// +/// It has to be made, and dropped, by a thread that handles messages. +pub struct SnapshotWindow { + pub hwnd: isize, +} + +impl SnapshotWindow { + pub fn create(snapshot: &Snapshot) -> eyre::Result { + let class = register_class()?; + let instance = WindowsApi::module_handle_w()?; + + unsafe { + let hwnd = CreateWindowExW( + WS_EX_LAYERED | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE | WS_EX_TRANSPARENT, + class, + class, + WS_POPUP, + 0, + 0, + snapshot.width, + snapshot.height, + None, + None, + Some(instance.into()), + None, + )?; + let window = Self { + hwnd: hwnd.0 as isize, + }; + + let screen = GetDC(None); + let dc = CreateCompatibleDC(Some(screen)); + let previous = SelectObject(dc, snapshot.bitmap.into()); + + let updated = UpdateLayeredWindow( + hwnd, + None, + None, + Some(&SIZE { + cx: snapshot.width, + cy: snapshot.height, + }), + Some(dc), + Some(&POINT { x: 0, y: 0 }), + COLORREF(0), + Some(&BLENDFUNCTION { + BlendOp: AC_SRC_OVER as u8, + BlendFlags: 0, + SourceConstantAlpha: 255, + AlphaFormat: AC_SRC_ALPHA as u8, + }), + ULW_ALPHA, + ); + + SelectObject(dc, previous); + let _ = DeleteDC(dc); + ReleaseDC(None, screen); + + updated?; + + // cloaked before it is shown, so it is never on screen + let cloak = BOOL::from(true); + DwmSetWindowAttribute( + hwnd, + DWMWA_CLOAK, + std::ptr::from_ref(&cloak).cast(), + std::mem::size_of::() as u32, + )?; + let _ = ShowWindow(hwnd, SW_SHOWNA); + + Ok(window) + } + } +} + +impl Drop for SnapshotWindow { + fn drop(&mut self) { + unsafe { + let _ = DestroyWindow(HWND(self.hwnd as *mut c_void)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn corners_are_cut_out_and_the_rest_is_opaque() { + let (width, height) = (40, 30); + let mut pixels = vec![0x0010_2030_u32; (width * height) as usize]; + + round_corners(&mut pixels, width, height, 10); + + let at = |x: i32, y: i32| pixels[(y * width + x) as usize]; + + // the very corners are gone + assert_eq!(at(0, 0), 0); + assert_eq!(at(width - 1, 0), 0); + assert_eq!(at(0, height - 1), 0); + assert_eq!(at(width - 1, height - 1), 0); + + // the middle and the straight edges are kept, and opaque + assert_eq!(at(20, 15), 0xFF10_2030); + assert_eq!(at(20, 0), 0xFF10_2030); + assert_eq!(at(0, 15), 0xFF10_2030); + assert_eq!(at(width - 1, height - 1 - 10), 0xFF10_2030); + + // the cut is round: a pixel just inside the circle stays + assert_eq!(at(4, 4), 0xFF10_2030); + assert_eq!(at(1, 1), 0); + } + + #[test] + fn corners_of_a_tiny_picture_do_not_run_out_of_bounds() { + let mut pixels = vec![0x00FF_FFFF_u32; 9]; + round_corners(&mut pixels, 3, 3, 10); + assert!( + pixels + .iter() + .all(|pixel| *pixel == 0xFFFF_FFFF || *pixel == 0) + ); + } + + #[test] + fn an_area_is_copied_out_of_a_picture() { + // 4x3, each pixel numbered by where it is + let source: Vec = (0..12).collect(); + let mut area = vec![0_u32; 4]; + + copy_area(&source, 4, 1, 1, &mut area, 2, 2); + + assert_eq!(area, vec![5, 6, 9, 10]); + } + + /// Needs a desktop to put windows on, so it is only run when asked to: + /// `cargo test -p komorebi snapshot -- --ignored` + #[test] + #[ignore = "needs an interactive desktop"] + fn a_snapshot_window_can_be_shown_by_a_thumbnail_without_being_seen() { + use windows::Win32::Graphics::Gdi::GetPixel; + use windows::Win32::UI::WindowsAndMessaging::HWND_TOPMOST; + use windows::Win32::UI::WindowsAndMessaging::SWP_NOACTIVATE; + use windows::Win32::UI::WindowsAndMessaging::SetWindowPos; + + fn host(x: i32, y: i32, width: i32, height: i32) -> HWND { + unsafe { + CreateWindowExW( + WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE | WS_EX_TRANSPARENT, + w!("Static"), + w!("snapshot test"), + WS_POPUP | windows::Win32::UI::WindowsAndMessaging::WS_VISIBLE, + x, + y, + width, + height, + None, + None, + None, + None, + ) + .unwrap() + } + } + + // a red window, of a size that is not the size it is shown at + let snapshot = Snapshot::solid(300, 200, 0x00FF_0000); + let source = SnapshotWindow::create(&snapshot).expect("a window for the picture"); + + let host = host(300, 300, 800, 600); + unsafe { + SetWindowPos(host, Some(HWND_TOPMOST), 300, 300, 800, 600, SWP_NOACTIVATE).unwrap(); + } + + let hthumb = WindowsApi::dwm_register_thumbnail(host.0 as isize, source.hwnd) + .expect("a thumbnail of the picture"); + + // stretched over the whole of the host, and from the other side of the + // screen where the window itself is + let props = windows::Win32::Graphics::Dwm::DWM_THUMBNAIL_PROPERTIES { + dwFlags: windows::Win32::Graphics::Dwm::DWM_TNP_VISIBLE + | windows::Win32::Graphics::Dwm::DWM_TNP_RECTDESTINATION + | windows::Win32::Graphics::Dwm::DWM_TNP_OPACITY, + rcDestination: RECT { + left: 0, + top: 0, + right: 800, + bottom: 600, + }, + rcSource: RECT::default(), + opacity: 255, + fVisible: true.into(), + fSourceClientAreaOnly: false.into(), + }; + WindowsApi::dwm_update_thumbnail_properties(hthumb, &props).expect("show the thumbnail"); + + // give DWM a moment to compose it + std::thread::sleep(std::time::Duration::from_millis(400)); + + // the part of the screen under the host, and well away from the corners + let screen = unsafe { GetDC(None) }; + let sample = |x, y| unsafe { GetPixel(screen, x, y).0 }; + // COLORREF is 0x00BBGGRR + let (middle, far_corner) = (sample(700, 600), sample(300 + 790, 300 + 590)); + unsafe { ReleaseDC(None, screen) }; + + unsafe { + let _ = WindowsApi::dwm_unregister_thumbnail(hthumb); + let _ = DestroyWindow(host); + } + + assert_eq!(middle & 0xFF, 0xFF, "red, got {middle:#08x}"); + assert_eq!((middle >> 8) & 0xFF, 0, "red, got {middle:#08x}"); + // stretched, so it reaches the corner of the host as well + assert_eq!(far_corner & 0xFF, 0xFF, "red, got {far_corner:#08x}"); + } +} diff --git a/komorebi/src/window.rs b/komorebi/src/window.rs index 8e35c8b3..6661d6ac 100644 --- a/komorebi/src/window.rs +++ b/komorebi/src/window.rs @@ -22,10 +22,12 @@ use crate::animation::ANIMATION_STYLE_PER_ANIMATION; use crate::animation::AnimationEngine; use crate::animation::GHOST_MOVEMENT_ENABLED; use crate::animation::RenderDispatcher; +use crate::animation::ghost; use crate::animation::ghost::GhostWindow; use crate::animation::lerp::Lerp; use crate::animation::prefix::AnimationPrefix; use crate::animation::prefix::new_animation_key; +use crate::animation::snapshot; use crate::border_manager; use crate::cloaked; use crate::com::SetCloak; @@ -178,10 +180,14 @@ struct MovementRenderDispatcher { /// Last lerped logical rect actually applied; used by cleanup_on_cancel to /// snap the real window to the position the user was last seeing. last_animated_rect: Mutex, - /// True when pre_render successfully repositioned the source to target_rect - /// before registering the thumbnail. In that case post_render must skip - /// the final position_window since the source is already there. - pre_painted: AtomicBool, + /// Resizing the cloaked source to its target, which is done while the + /// animation runs because it can take a while. + resize: Mutex>>>, + /// Whether the source has been shown again, under the fading ghost + revealed: AtomicBool, + /// How far through the animation the source was shown again, which is + /// when the ghost starts to fade + revealed_at: Mutex, } impl MovementRenderDispatcher { @@ -203,27 +209,17 @@ impl MovementRenderDispatcher { ghost: Mutex::new(None), cloaked: AtomicBool::new(false), last_animated_rect: Mutex::new(start_rect), - pre_painted: AtomicBool::new(false), + resize: Mutex::new(None), + revealed: AtomicBool::new(false), + revealed_at: Mutex::new(ghost::REVEAL_AT), } } fn use_ghost(&self) -> bool { + // DWM has no thumbnail to show of a window that is minimized or gone GHOST_MOVEMENT_ENABLED.load(Ordering::Relaxed) - } - - /// Chromium / Electron windows expose a top-level class beginning with - /// `Chrome_WidgetWin_`. Their renderer pipeline is suspended whenever - /// `NativeWindowOcclusionTrackerWin` reads any non-zero `DWMWA_CLOAKED` - /// state on the HWND, so the pre-paint trick (cloak → SetWindowPos → - /// capture) leaves the DComp swap chain stale and the post-uncloak frame - /// shows half-painted / black regions. For these apps we fall back to - /// capture-at-start: keep the source cloaked at start_rect for the whole - /// animation and only move it to target in post_render, where the - /// uncloak is the visibility flip that wakes Viz back up. - fn source_is_chromium_shell(&self) -> bool { - WindowsApi::real_window_class_w(self.hwnd) - .map(|class| class.starts_with("Chrome_WidgetWin_")) - .unwrap_or(false) + && WindowsApi::is_window(self.hwnd) + && !WindowsApi::is_iconic(self.hwnd) } fn finalise_managers(&self) { @@ -242,6 +238,85 @@ impl MovementRenderDispatcher { transparency_manager::send_notification(); } } + + fn uncloak(&self) { + if self.cloaked.swap(false, Ordering::SeqCst) { + SetCloak(Window { hwnd: self.hwnd }.hwnd(), 1, 0); + } + } + + /// Start resizing the cloaked source to its target rect, on a thread of + /// its own: a window that is slow to take a new size would otherwise hold + /// up the animation, and the ghost is on screen in its place meanwhile. + fn start_resize(&self) { + let hwnd = self.hwnd; + let target = self.target_rect; + let top = self.top; + + *self.resize.lock() = Some(thread::spawn(move || { + let resized = WindowsApi::position_window(hwnd, &target, top, false); + + // A window that is hidden has no reason to lay itself out again + // before it is shown, and Explorer takes its time over it when it + // is: have it do it now, while the ghost is on screen. + if resized.is_ok() { + snapshot::paint_now(hwnd); + } + + resized + })); + } + + /// Whether the resize started by `start_resize` is done, or never was + fn resize_is_done(&self) -> bool { + self.resize + .lock() + .as_ref() + .is_none_or(thread::JoinHandle::is_finished) + } + + /// Wait for the resize started by `start_resize` to be done + fn finish_resize(&self) -> eyre::Result<()> { + match self.resize.lock().take() { + Some(resize) => resize + .join() + .unwrap_or_else(|_| Err(eyre::eyre!("the resize of hwnd {} panicked", self.hwnd))), + None => Ok(()), + } + } + + /// Show the source again, at its target, under the ghost, which fades out + /// over it from here on. By now it has had part of the animation to lay + /// itself out at its new size, which is why this is not done at the end. + /// + /// Windows that render through a swap chain are given a real size change + /// on the way (see `ghost::nudge_swap_chain_window`) so that their + /// compositor rebuilds its render target at the final size. + fn reveal(&self, progress: f64) { + if self.revealed.swap(true, Ordering::SeqCst) { + return; + } + + *self.revealed_at.lock() = progress.clamp(ghost::REVEAL_AT, 1.0); + + match self.finish_resize() { + Ok(()) => { + if ghost::is_swap_chain_window(self.hwnd) { + ghost::nudge_swap_chain_window(self.hwnd, &self.target_rect, self.top); + } + } + Err(error) => { + // the window cannot be left hidden, and the next layout pass + // puts it where it belongs + tracing::warn!( + "ghost movement: failed to resize hwnd {} to its target: {error}", + self.hwnd + ); + } + } + + self.uncloak(); + } } impl RenderDispatcher for MovementRenderDispatcher { @@ -254,67 +329,34 @@ impl RenderDispatcher for MovementRenderDispatcher { stackbar_manager::send_notification(); if self.use_ghost() { - let is_chromium = self.source_is_chromium_shell(); - - // The ghost host is sized to the LOGICAL rect (visible content - // area). DWM thumbnails capture the source at its - // DWMWA_EXTENDED_FRAME_BOUNDS extents (visible content), not - // GetWindowRect outer extents that include the drop-shadow - // margin. Sizing the host to outer dims would stretch the - // visible-content texture by the shadow ratio. - // - // Place the ghost in z-order immediately above the source so - // multiple simultaneously animating windows (workspace switches, - // layout flips) keep the same relative stacking as their - // sources rather than all piling up at HWND_TOP in creation - // order. - // - // For non-Chromium sources we ALSO pre-position the source to - // target_rect *before* registering the thumbnail, so the - // captured pixels reflect target-dimensioned content. The ghost - // dest then animates start → target with the texture - // downscaling to native 1:1 at the end — crisp final frame - // instead of an upscaled blur. For Chromium we skip pre-paint - // (see `source_is_chromium_shell`). - // - // DwmSetWindowAttribute(DWMWA_CLOAK) is rejected with - // E_ACCESSDENIED for foreign HWNDs; the undocumented - // IApplicationView::SetCloak path used elsewhere does not have - // that restriction. - SetCloak(Window { hwnd: self.hwnd }.hwnd(), 1, 2); - self.cloaked.store(true, Ordering::SeqCst); - - if !is_chromium { - if let Err(error) = - WindowsApi::position_window(self.hwnd, &self.target_rect, self.top, false) - { - tracing::warn!( - "ghost movement: failed to pre-position hwnd {}: {error}", - self.hwnd - ); - } else { - // No DwmFlush here. DWM thumbnails are live: once - // registered, the thumbnail surface updates as the - // source paints, so the texture catches up to - // target-dim content within the first frame or two of - // the animation. Skipping the flush avoids a ~16ms - // pre-render stall on every non-Chromium animation. - self.pre_painted.store(true, Ordering::SeqCst); - } - } - - match GhostWindow::create(self.hwnd, self.start_rect, Some(self.hwnd)) { + // The ghost is a snapshot of the window as it is now, which is + // taken before anything is done to the window, and shown at the + // LOGICAL rect (visible content area) the window is in. It does + // not follow the window, so it shows the old frame for as long as + // the window takes to lay itself out again, which is what the + // animation is for. + match GhostWindow::create(self.hwnd, self.start_rect) { Ok(ghost) => { *self.ghost.lock() = Some(ghost); + + // DwmSetWindowAttribute(DWMWA_CLOAK) is rejected with + // E_ACCESSDENIED for foreign HWNDs; the undocumented + // IApplicationView::SetCloak path used elsewhere does not + // have that restriction. + SetCloak(Window { hwnd: self.hwnd }.hwnd(), 1, 2); + self.cloaked.store(true, Ordering::SeqCst); + + // The window is resized now rather than when the animation + // is over, so that it has the animation to paint at its new + // size in, while it cannot be seen. + self.start_resize(); } Err(error) => { tracing::warn!( "ghost movement: failed to create ghost for hwnd {}: {error}; \ - uncloaking and falling back to legacy path", + falling back to legacy path", self.hwnd ); - SetCloak(Window { hwnd: self.hwnd }.hwnd(), 1, 0); - self.cloaked.store(false, Ordering::SeqCst); } } } @@ -332,10 +374,26 @@ impl RenderDispatcher for MovementRenderDispatcher { let ghost_active = self.ghost.lock().is_some(); if ghost_active { - if let Some(ghost) = self.ghost.lock().as_ref() - && let Err(error) = ghost.update_rect(logical) + // A window that is slow to take its new size is not waited for: the + // animation goes on with the ghost as it is, and the window is + // shown when it is ready, or once the animation is over. + if progress >= ghost::REVEAL_AT + && !self.revealed.load(Ordering::SeqCst) + && self.resize_is_done() { - tracing::trace!("ghost update_rect failed: {error}"); + self.reveal(progress); + } + + let opacity = if self.revealed.load(Ordering::SeqCst) { + ghost::ghost_opacity(progress, *self.revealed_at.lock()) + } else { + 255 + }; + + if let Some(ghost) = self.ghost.lock().as_ref() + && let Err(error) = ghost.update(logical, opacity) + { + tracing::trace!("ghost update failed: {error}"); } border_manager::animate_to(self.hwnd, logical); } else { @@ -349,69 +407,28 @@ impl RenderDispatcher for MovementRenderDispatcher { } fn post_render(&self) -> eyre::Result<()> { - let used_ghost = self.ghost.lock().is_some(); - let pre_painted = self.pre_painted.load(Ordering::SeqCst); - - // Final single SetWindowPos. For the pre-paint ghost path the source - // has already been moved to target_rect in pre_render and we skip - // this. For the Chromium ghost path (no pre-paint) the source is - // still cloaked at start_rect and needs to be moved here. For the - // legacy non-ghost path this is the original final reposition. - if !pre_painted { - WindowsApi::position_window(self.hwnd, &self.target_rect, self.top, false)?; - } - - // Uncloak BEFORE crossfade so the real window's first post-resize - // frame is being composed underneath the still-visible ghost while - // we fade. This gives Chromium/Electron renderers time to produce a - // CompositorFrame at the new size — the visibility flip from - // cloaked-to-uncloaked is what nudges Viz to resume frame - // production. - if self.cloaked.swap(false, Ordering::SeqCst) { - SetCloak(Window { hwnd: self.hwnd }.hwnd(), 1, 0); - } - - if used_ghost { - // Crossfade the ghost out over several DWM frames. This masks the - // texture mismatch (start-dim bitmap stretched vs. crisp - // target-dim repaint) and gives slow-to-repaint apps time to - // present their first post-resize frame before the overlay is - // removed. Mirrors KWin's geometry-effect crossfade. - // - // Ease-in curve (1 - t^3): opacity holds high for most of the - // fade and only drops sharply at the end. The ghost stays - // prominent while the real window's first few frames land - // underneath, so the user perceives a smooth reveal rather than - // a snap. - // - // We call set_opacity directly (synchronous DwmUpdateThumbnailProperties - // on this thread) rather than via the ghost owner channel, so - // each step is guaranteed to be visible before the following - // DwmFlush waits for the next vblank. - if let Some(ghost) = self.ghost.lock().as_ref() { - const FADE_STEPS: u32 = 8; - for step in 1..=FADE_STEPS { - let t = step as f32 / FADE_STEPS as f32; - let progress = t * t * t; - #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] - let opacity_u8 = ((1.0 - progress) * 255.0).round().clamp(0.0, 255.0) as u8; - let _ = ghost.set_opacity(opacity_u8); - unsafe { - let _ = windows::Win32::Graphics::Dwm::DwmFlush(); - } - } - } - } else { - // Legacy path: still benefit from one DWM frame's wait so the - // app's first post-move paint lands. + let Some(ghost) = self.ghost.lock().take() else { + // Legacy path: the final reposition, then one DWM frame's wait so + // the app's first post-move paint lands. + let landed = WindowsApi::position_window(self.hwnd, &self.target_rect, self.top, false); + self.uncloak(); unsafe { let _ = windows::Win32::Graphics::Dwm::DwmFlush(); } + self.finalise_managers(); + return landed; + }; + + // Normally done a good while ago, but not for a window that was slow to + // take its new size, which is waited for now, with the ghost on screen + // over it, and then faded out of the way a little more quickly than + // it otherwise would be. + if !self.revealed.load(Ordering::SeqCst) { + self.reveal(1.0); + ghost.fade_out(self.target_rect); } - if let Some(ghost) = self.ghost.lock().take() { - let _ = ghost.dispose(); - } + let _ = ghost.dispose(); self.finalise_managers(); @@ -421,18 +438,21 @@ impl RenderDispatcher for MovementRenderDispatcher { fn cleanup_on_cancel(&self) { // Snap the real window to wherever the ghost was last drawn so the next // dispatcher can capture an accurate start_rect. Then uncloak and tear - // down the ghost. Mirrors post_render but uses last_animated_rect. - let target = *self.last_animated_rect.lock(); + // down the ghost. + let _ = self.finish_resize(); - if let Err(error) = WindowsApi::position_window(self.hwnd, &target, false, false) { - tracing::warn!( - "ghost movement cancel: failed to snap hwnd {} to last rect: {error}", - self.hwnd - ); - } + if WindowsApi::is_window(self.hwnd) { + let target = *self.last_animated_rect.lock(); - if self.cloaked.swap(false, Ordering::SeqCst) { - SetCloak(Window { hwnd: self.hwnd }.hwnd(), 1, 0); + if let Err(error) = WindowsApi::position_window(self.hwnd, &target, false, false) { + tracing::warn!( + "ghost movement cancel: failed to snap hwnd {} to last rect: {error}", + self.hwnd + ); + } + + self.revealed.store(true, Ordering::SeqCst); + self.uncloak(); } if let Some(ghost) = self.ghost.lock().take() { diff --git a/komorebi/src/windows_api.rs b/komorebi/src/windows_api.rs index 183b41cf..eced114c 100644 --- a/komorebi/src/windows_api.rs +++ b/komorebi/src/windows_api.rs @@ -148,7 +148,6 @@ use windows::Win32::UI::WindowsAndMessaging::WS_DISABLED; use windows::Win32::UI::WindowsAndMessaging::WS_EX_NOACTIVATE; use windows::Win32::UI::WindowsAndMessaging::WS_EX_TOOLWINDOW; use windows::Win32::UI::WindowsAndMessaging::WS_EX_TOPMOST; -use windows::Win32::UI::WindowsAndMessaging::WS_EX_TRANSPARENT; use windows::Win32::UI::WindowsAndMessaging::WS_POPUP; use windows::Win32::UI::WindowsAndMessaging::WS_SYSMENU; use windows::Win32::UI::WindowsAndMessaging::WindowFromPoint; @@ -1365,26 +1364,6 @@ impl WindowsApi { } } - pub fn create_ghost_host_window(name: PCWSTR, instance: isize) -> eyre::Result { - unsafe { - CreateWindowExW( - WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE | WS_EX_TRANSPARENT, - name, - name, - WS_POPUP, - CW_USEDEFAULT, - CW_USEDEFAULT, - CW_USEDEFAULT, - CW_USEDEFAULT, - None, - None, - Option::from(HINSTANCE(as_ptr!(instance))), - None, - )? - } - .process() - } - pub fn dwm_register_thumbnail(dest_hwnd: isize, src_hwnd: isize) -> eyre::Result { Ok(unsafe { DwmRegisterThumbnail(HWND(as_ptr!(dest_hwnd)), HWND(as_ptr!(src_hwnd))) }?) }