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))) }?) }