From e2e5dbfcaeaf6afedbb4fe126407c892a20ff1db Mon Sep 17 00:00:00 2001 From: LGUG2Z Date: Sun, 3 May 2026 16:01:11 -0700 Subject: [PATCH] feat(wm): use ghost windows for movement animations This commit tries to render move/resize animations on a DWM-thumbnail "ghost" window instead of calling MoveWindow per-frame on the real HWND. The source is cloaked via IApplicationView::SetCloak, the thumbnail is animated via DwmUpdateThumbnailProperties on a layered host owned by a single "ghost owner" thread, the border for the source follows the lerped rect via a new WM_ANIMATE_RECT message handled on the border's own WndProc thread (preserving today's per-frame border tracking), and the real SetWindowPos happens once at the end of the animation. Apps repaint exactly once per animation instead of N times, which is a substantial win for heavy renderers (browsers, IDEs, Office). For non-Chromium sources the source is also pre-positioned to target_rect before the thumbnail is registered so the captured texture is target- sized and downscales to native 1:1 at the end of the animation rather than upscaling to a stretched/blurry final frame. Chromium-shell sources skip the pre-paint step: their NativeWindowOcclusionTrackerWin reads DWMWA_CLOAKED and treats any cloak value as hidden, suspending the renderer; WM_SIZE while cloaked produces no new frame and the post-uncloak swap chain shows stale or black content. For those apps we keep the source cloaked at start_rect for the whole animation and do the SetWindowPos in post_render after uncloak, where the visibility flip is what wakes Viz back up. A short ease-in opacity crossfade in post_render masks the texture transition for the Chromium path and gives slow renderers time to present their first post-resize frame before the overlay is removed. --- komorebi/src/animation/engine.rs | 1 + komorebi/src/animation/ghost.rs | 363 ++++++++++++++++++++ komorebi/src/animation/mod.rs | 3 + komorebi/src/animation/render_dispatcher.rs | 6 + komorebi/src/border_manager/border.rs | 201 +++++++---- komorebi/src/border_manager/mod.rs | 14 + komorebi/src/static_config.rs | 18 + komorebi/src/window.rs | 253 ++++++++++++-- komorebi/src/windows_api.rs | 40 +++ schema.json | 10 +- 10 files changed, 815 insertions(+), 94 deletions(-) create mode 100644 komorebi/src/animation/ghost.rs diff --git a/komorebi/src/animation/engine.rs b/komorebi/src/animation/engine.rs index 9f6e8698..444bf36a 100644 --- a/komorebi/src/animation/engine.rs +++ b/komorebi/src/animation/engine.rs @@ -86,6 +86,7 @@ impl AnimationEngine { { // cancel animation ANIMATION_MANAGER.lock().cancel(animation_key.as_str()); + render_dispatcher.cleanup_on_cancel(); return Ok(()); } diff --git a/komorebi/src/animation/ghost.rs b/komorebi/src/animation/ghost.rs new file mode 100644 index 00000000..e179fd8d --- /dev/null +++ b/komorebi/src/animation/ghost.rs @@ -0,0 +1,363 @@ +use color_eyre::eyre; +use crossbeam_channel::Sender; +use crossbeam_channel::bounded; +use crossbeam_channel::unbounded; +use std::sync::OnceLock; +use std::time::Duration; +use windows::Win32::Foundation::HWND; +use windows::Win32::Foundation::LPARAM; +use windows::Win32::Foundation::LRESULT; +use windows::Win32::Foundation::RECT; +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::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::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::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::WNDCLASSW; +use windows::core::PCWSTR; + +use crate::WindowsApi; +use crate::core::Rect; +use crate::windows_api; + +const GHOST_CLASS_NAME: &[u16] = &[ + b'k' as u16, + b'o' as u16, + b'm' as u16, + b'o' as u16, + b'r' as u16, + b'e' as u16, + b'b' as u16, + b'i' as u16, + b'-' as u16, + b'g' as u16, + b'h' as u16, + b'o' as u16, + b's' as u16, + b't' as u16, + 0, +]; + +enum GhostCmd { + Create { + src_hwnd: isize, + start_rect: Rect, + z_above: Option, + reply: Sender>, + }, + UpdateRect { + host_hwnd: isize, + hthumb: isize, + rect: Rect, + }, + Destroy { + host_hwnd: isize, + hthumb: isize, + }, +} + +struct GhostOwner { + cmd_tx: Sender, +} + +static GHOST_OWNER: OnceLock = OnceLock::new(); + +fn ghost_owner() -> &'static GhostOwner { + GHOST_OWNER.get_or_init(|| { + let (tx, rx) = unbounded::(); + std::thread::Builder::new() + .name("komorebi-ghost-owner".into()) + .spawn(move || run_owner_loop(rx)) + .expect("failed to spawn ghost owner thread"); + GhostOwner { cmd_tx: tx } + }) +} + +/// 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. +pub fn prewarm() { + let _ = ghost_owner(); +} + +extern "system" fn ghost_wnd_proc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT { + unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) } +} + +fn register_ghost_class() -> eyre::Result<()> { + let h_module = WindowsApi::module_handle_w()?; + let class_name = PCWSTR(GHOST_CLASS_NAME.as_ptr()); + let window_class = WNDCLASSW { + hInstance: h_module.into(), + lpszClassName: class_name, + lpfnWndProc: Some(ghost_wnd_proc), + ..Default::default() + }; + // RegisterClassW returns 0 on failure with ERROR_CLASS_ALREADY_EXISTS as a + // benign error if the class is already registered. We tolerate that. + let _ = WindowsApi::register_class_w(&window_class); + 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; + } + + loop { + // Drain any pending Win32 messages (DWM/system messages destined for our hosts). + unsafe { + let mut msg = MSG::default(); + while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() { + let _ = TranslateMessage(&msg); + DispatchMessageW(&msg); + } + } + + match cmd_rx.recv_timeout(Duration::from_millis(8)) { + Ok(cmd) => handle_cmd(cmd), + 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); + } + } +} + +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 { + 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, + }, + rcSource: RECT::default(), + opacity: 255, + 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. +pub struct GhostWindow { + host_hwnd: isize, + hthumb: isize, + 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); + ghost_owner() + .cmd_tx + .send(GhostCmd::Create { + src_hwnd, + 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()??; + Ok(Self { + host_hwnd, + hthumb, + disposed: false, + }) + } + + pub fn host_hwnd(&self) -> isize { + self.host_hwnd + } + + 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) + } + + pub fn dispose(mut self) -> eyre::Result<()> { + self.dispose_inner() + } + + fn dispose_inner(&mut self) -> eyre::Result<()> { + if self.disposed { + return Ok(()); + } + self.disposed = true; + 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}")) + } +} + +impl Drop for GhostWindow { + fn drop(&mut self) { + let _ = self.dispose_inner(); + } +} diff --git a/komorebi/src/animation/mod.rs b/komorebi/src/animation/mod.rs index 4566cbf1..79fbe6e3 100644 --- a/komorebi/src/animation/mod.rs +++ b/komorebi/src/animation/mod.rs @@ -13,6 +13,7 @@ use parking_lot::Mutex; pub use engine::AnimationEngine; pub mod animation_manager; pub mod engine; +pub mod ghost; pub mod lerp; pub mod prefix; pub mod render_dispatcher; @@ -59,6 +60,7 @@ pub const DEFAULT_ANIMATION_ENABLED: bool = false; pub const DEFAULT_ANIMATION_STYLE: AnimationStyle = AnimationStyle::Linear; pub const DEFAULT_ANIMATION_DURATION: u64 = 250; pub const DEFAULT_ANIMATION_FPS: u64 = 60; +pub const DEFAULT_GHOST_MOVEMENT: bool = true; lazy_static! { pub static ref ANIMATION_MANAGER: Arc> = @@ -78,3 +80,4 @@ lazy_static! { } pub static ANIMATION_FPS: AtomicU64 = AtomicU64::new(DEFAULT_ANIMATION_FPS); +pub static GHOST_MOVEMENT_ENABLED: AtomicBool = AtomicBool::new(DEFAULT_GHOST_MOVEMENT); diff --git a/komorebi/src/animation/render_dispatcher.rs b/komorebi/src/animation/render_dispatcher.rs index 6456668f..7dd9c3de 100644 --- a/komorebi/src/animation/render_dispatcher.rs +++ b/komorebi/src/animation/render_dispatcher.rs @@ -5,4 +5,10 @@ pub trait RenderDispatcher { fn pre_render(&self) -> eyre::Result<()>; fn render(&self, delta: f64) -> eyre::Result<()>; fn post_render(&self) -> eyre::Result<()>; + + /// Called by the animation engine when an in-flight animation is cancelled + /// before it could complete. Implementors should use this to release any + /// resources allocated in `pre_render` and bring the underlying window + /// back to a consistent visible state. Default: no-op. + fn cleanup_on_cancel(&self) {} } diff --git a/komorebi/src/border_manager/border.rs b/komorebi/src/border_manager/border.rs index ccad526f..db7ee180 100644 --- a/komorebi/src/border_manager/border.rs +++ b/komorebi/src/border_manager/border.rs @@ -78,6 +78,11 @@ use windows_numerics::Matrix3x2; /// avoiding a data race between the border manager thread and the border's message loop thread. pub const WM_UPDATE_BRUSHES: u32 = WM_USER + 1; +/// Custom WM_USER message used to drive the border in lockstep with an active +/// movement animation. lparam carries a `Box` ownership transfer that the +/// receiving WndProc reclaims and applies as the new tracked rect. +pub const WM_ANIMATE_RECT: u32 = WM_USER + 2; + pub struct RenderFactory(ID2D1Factory); unsafe impl Sync for RenderFactory {} unsafe impl Send for RenderFactory {} @@ -106,6 +111,98 @@ static BRUSH_PROPERTIES: LazyLock = transform: Matrix3x2::identity(), }); +/// Apply a new tracked rect to the border on its own message-loop thread. +/// Updates `window_rect`, calls `set_position`, and re-renders if size/position +/// changed. Used by both `EVENT_OBJECT_LOCATIONCHANGE` (real window movements) +/// and `WM_ANIMATE_RECT` (animation-driven movements while the source is cloaked). +/// +/// SAFETY: caller must ensure `border_pointer` is non-null, points to a live +/// `Border`, and that we are running on the border's WndProc thread. +unsafe fn apply_tracked_rect(border_pointer: *mut Border, rect: Rect) { + unsafe { + let reference_hwnd = (*border_pointer).tracking_hwnd; + let old_rect = (*border_pointer).window_rect; + (*border_pointer).window_rect = rect; + + if let Err(error) = (*border_pointer).set_position(&rect, reference_hwnd) { + tracing::error!("failed to update border position {error}"); + } + + if (rect.is_same_size_as(&old_rect) && rect.has_same_position_as(&old_rect)) + || (*border_pointer).render_target.is_none() + { + return; + } + + // double-check destruction flag before rendering + if (*border_pointer).is_destroying.load(Ordering::Acquire) { + return; + } + + let render_target = match (*border_pointer).render_target.as_ref() { + Some(rt) => rt, + None => return, + }; + + let border_width = (*border_pointer).width; + let border_offset = (*border_pointer).offset; + + (*border_pointer).rounded_rect.rect = D2D_RECT_F { + left: (border_width / 2 - border_offset) as f32, + top: (border_width / 2 - border_offset) as f32, + right: (rect.right - border_width / 2 + border_offset) as f32, + bottom: (rect.bottom - border_width / 2 + border_offset) as f32, + }; + + let _ = render_target.Resize(&D2D_SIZE_U { + width: rect.right as u32, + height: rect.bottom as u32, + }); + + let window_kind = (*border_pointer).window_kind; + let Some(brush) = (*border_pointer).brushes.get(&window_kind) else { + return; + }; + + render_target.BeginDraw(); + render_target.Clear(None); + + let style = match (*border_pointer).style { + BorderStyle::System => { + if *WINDOWS_11 { + BorderStyle::Rounded + } else { + BorderStyle::Square + } + } + BorderStyle::Rounded => BorderStyle::Rounded, + BorderStyle::Square => BorderStyle::Square, + }; + + match style { + BorderStyle::Rounded => { + render_target.DrawRoundedRectangle( + &(*border_pointer).rounded_rect, + brush, + border_width as f32, + None, + ); + } + BorderStyle::Square => { + render_target.DrawRectangle( + &(*border_pointer).rounded_rect.rect, + brush, + border_width as f32, + None, + ); + } + _ => {} + } + + let _ = render_target.EndDraw(None, None); + } +} + pub extern "system" fn border_hwnds(hwnd: HWND, lparam: LPARAM) -> BOOL { let hwnds = unsafe { &mut *(lparam.0 as *mut Vec) }; let hwnd = hwnd.0 as isize; @@ -349,6 +446,29 @@ impl Border { }; } + /// Drive the border to follow `rect` during a movement animation. Hands + /// ownership of a boxed `Rect` to the border's message-loop thread via + /// `WM_ANIMATE_RECT`, which mirrors the redraw path normally driven by + /// `EVENT_OBJECT_LOCATIONCHANGE` on the real source window. + pub fn animate_to(&self, rect: Rect) { + let boxed = Box::new(rect); + let ptr = Box::into_raw(boxed); + let posted = unsafe { + PostMessageW( + Option::from(self.hwnd()), + WM_ANIMATE_RECT, + WPARAM(0), + LPARAM(ptr as isize), + ) + }; + if posted.is_err() { + // Reclaim the box on failure to avoid leaking. + unsafe { + drop(Box::from_raw(ptr)); + } + } + } + pub fn set_position(&self, rect: &Rect, reference_hwnd: isize) -> color_eyre::Result<()> { let mut rect = *rect; rect.add_margin(self.width); @@ -419,81 +539,24 @@ impl Border { } let reference_hwnd = (*border_pointer).tracking_hwnd; - - let old_rect = (*border_pointer).window_rect; let rect = WindowsApi::window_rect(reference_hwnd).unwrap_or_default(); + apply_tracked_rect(border_pointer, rect); + LRESULT(0) + } + WM_ANIMATE_RECT => { + // lparam carries an owned Box from the animation thread. + let rect_box = Box::from_raw(lparam.0 as *mut Rect); + let border_pointer: *mut Border = GetWindowLongPtrW(window, GWLP_USERDATA) as _; - (*border_pointer).window_rect = rect; - - if let Err(error) = (*border_pointer).set_position(&rect, reference_hwnd) { - tracing::error!("failed to update border position {error}"); + if border_pointer.is_null() { + return LRESULT(0); } - if (!rect.is_same_size_as(&old_rect) || !rect.has_same_position_as(&old_rect)) - && let Some(render_target) = (*border_pointer).render_target.as_ref() - { - // double-check destruction flag before rendering - if (*border_pointer).is_destroying.load(Ordering::Acquire) { - return LRESULT(0); - } - - let border_width = (*border_pointer).width; - let border_offset = (*border_pointer).offset; - - (*border_pointer).rounded_rect.rect = D2D_RECT_F { - left: (border_width / 2 - border_offset) as f32, - top: (border_width / 2 - border_offset) as f32, - right: (rect.right - border_width / 2 + border_offset) as f32, - bottom: (rect.bottom - border_width / 2 + border_offset) as f32, - }; - - let _ = render_target.Resize(&D2D_SIZE_U { - width: rect.right as u32, - height: rect.bottom as u32, - }); - - let window_kind = (*border_pointer).window_kind; - if let Some(brush) = (*border_pointer).brushes.get(&window_kind) { - render_target.BeginDraw(); - render_target.Clear(None); - - // Calculate border radius based on style - let style = match (*border_pointer).style { - BorderStyle::System => { - if *WINDOWS_11 { - BorderStyle::Rounded - } else { - BorderStyle::Square - } - } - BorderStyle::Rounded => BorderStyle::Rounded, - BorderStyle::Square => BorderStyle::Square, - }; - - match style { - BorderStyle::Rounded => { - render_target.DrawRoundedRectangle( - &(*border_pointer).rounded_rect, - brush, - border_width as f32, - None, - ); - } - BorderStyle::Square => { - render_target.DrawRectangle( - &(*border_pointer).rounded_rect.rect, - brush, - border_width as f32, - None, - ); - } - _ => {} - } - - let _ = render_target.EndDraw(None, None); - } + if (*border_pointer).is_destroying.load(Ordering::Acquire) { + return LRESULT(0); } + apply_tracked_rect(border_pointer, *rect_box); LRESULT(0) } WM_PAINT => { diff --git a/komorebi/src/border_manager/mod.rs b/komorebi/src/border_manager/mod.rs index a4f452f1..55af0638 100644 --- a/komorebi/src/border_manager/mod.rs +++ b/komorebi/src/border_manager/mod.rs @@ -113,6 +113,20 @@ pub fn window_border(hwnd: isize) -> Option { }) } +/// Drive the border that tracks `source_hwnd` to follow `rect`. No-op when no +/// border is registered for the source window. Used by movement animations to +/// keep the border visually in sync while the source window is cloaked. +pub fn animate_to(source_hwnd: isize, rect: crate::core::Rect) { + let border_id = match WINDOWS_BORDERS.lock().get(&source_hwnd).cloned() { + Some(id) => id, + None => return, + }; + let state = BORDER_STATE.lock(); + if let Some(border) = state.get(&border_id) { + border.animate_to(rect); + } +} + pub fn send_notification(hwnd: Option) { if event_tx().try_send(Notification::Update(hwnd)).is_err() { tracing::warn!("channel is full; dropping notification") diff --git a/komorebi/src/static_config.rs b/komorebi/src/static_config.rs index d8dee728..08debea8 100644 --- a/komorebi/src/static_config.rs +++ b/komorebi/src/static_config.rs @@ -39,6 +39,8 @@ use crate::animation::ANIMATION_FPS; use crate::animation::ANIMATION_STYLE_GLOBAL; use crate::animation::ANIMATION_STYLE_PER_ANIMATION; use crate::animation::DEFAULT_ANIMATION_FPS; +use crate::animation::DEFAULT_GHOST_MOVEMENT; +use crate::animation::GHOST_MOVEMENT_ENABLED; use crate::animation::PerAnimationPrefixConfig; use crate::asc::ApplicationSpecificConfiguration; use crate::asc::AscApplicationRulesOrSchema; @@ -695,6 +697,11 @@ pub struct AnimationsConfig { #[serde(skip_serializing_if = "Option::is_none")] #[cfg_attr(feature = "schemars", schemars(extend("default" = ANIMATION_FPS)))] pub fps: Option, + /// Render movement animations on a GPU-composited ghost surface (recommended). + /// When false, falls back to the legacy per-frame MoveWindow path. + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "schemars", schemars(extend("default" = true)))] + pub ghost_movement: Option, } pub use komorebi_themes::KomorebiTheme; @@ -1022,6 +1029,17 @@ impl StaticConfig { animations.fps.unwrap_or(DEFAULT_ANIMATION_FPS), Ordering::SeqCst, ); + + let ghost_movement_enabled = + animations.ghost_movement.unwrap_or(DEFAULT_GHOST_MOVEMENT); + GHOST_MOVEMENT_ENABLED.store(ghost_movement_enabled, Ordering::SeqCst); + if ghost_movement_enabled { + // Spawn the ghost owner thread now so the first animation + // doesn't pay the spawn + wndclass-registration cost. Lazy + // guarantee preserved: users who turn ghost_movement off + // never trigger this path, so the thread is never created. + crate::animation::ghost::prewarm(); + } } if let Some(container) = self.default_container_padding { diff --git a/komorebi/src/window.rs b/komorebi/src/window.rs index 3434203f..71304153 100644 --- a/komorebi/src/window.rs +++ b/komorebi/src/window.rs @@ -20,7 +20,9 @@ use crate::animation::ANIMATION_MANAGER; use crate::animation::ANIMATION_STYLE_GLOBAL; 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::GhostWindow; use crate::animation::lerp::Lerp; use crate::animation::prefix::AnimationPrefix; use crate::animation::prefix::new_animation_key; @@ -42,6 +44,7 @@ use crate::windows_api; use crate::windows_api::WindowsApi; use color_eyre::eyre; use crossbeam_utils::atomic::AtomicConsume; +use parking_lot::Mutex; use regex::Regex; use serde::Deserialize; use serde::Serialize; @@ -52,6 +55,7 @@ use std::convert::TryFrom; use std::fmt::Display; use std::fmt::Formatter; use std::fmt::Write as _; +use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicI32; use std::sync::atomic::Ordering; use std::thread; @@ -165,6 +169,18 @@ struct MovementRenderDispatcher { target_rect: Rect, top: bool, style: AnimationStyle, + /// Some between successful pre_render and post_render/cleanup_on_cancel when + /// ghost movement is active. None for the legacy code path. + ghost: Mutex>, + /// Tracks whether the source has been cloaked so cleanup can uncloak idempotently. + cloaked: AtomicBool, + /// 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, } impl MovementRenderDispatcher { @@ -183,37 +199,33 @@ impl MovementRenderDispatcher { target_rect, top, style, + ghost: Mutex::new(None), + cloaked: AtomicBool::new(false), + last_animated_rect: Mutex::new(start_rect), + pre_painted: AtomicBool::new(false), } } -} -impl RenderDispatcher for MovementRenderDispatcher { - fn get_animation_key(&self) -> String { - new_animation_key(MovementRenderDispatcher::PREFIX, self.hwnd.to_string()) + fn use_ghost(&self) -> bool { + GHOST_MOVEMENT_ENABLED.load(Ordering::Relaxed) } - fn pre_render(&self) -> eyre::Result<()> { - stackbar_manager::STACKBAR_TEMPORARILY_DISABLED.store(true, Ordering::SeqCst); - stackbar_manager::send_notification(); - - Ok(()) + /// 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) } - fn render(&self, progress: f64) -> eyre::Result<()> { - let new_rect = self.start_rect.lerp(self.target_rect, progress, self.style); - - // we don't check WINDOW_HANDLING_BEHAVIOUR here because animations - // are always run on a separate thread - WindowsApi::move_window(self.hwnd, &new_rect, false)?; - WindowsApi::invalidate_rect(self.hwnd, None, false); - - Ok(()) - } - - fn post_render(&self) -> eyre::Result<()> { - // we don't add the async_window_pos flag here because animations - // are always run on a separate thread - WindowsApi::position_window(self.hwnd, &self.target_rect, self.top, false)?; + fn finalise_managers(&self) { if ANIMATION_MANAGER .lock() .count_in_progress(MovementRenderDispatcher::PREFIX) @@ -228,9 +240,202 @@ impl RenderDispatcher for MovementRenderDispatcher { stackbar_manager::send_notification(); transparency_manager::send_notification(); } + } +} + +impl RenderDispatcher for MovementRenderDispatcher { + fn get_animation_key(&self) -> String { + new_animation_key(MovementRenderDispatcher::PREFIX, self.hwnd.to_string()) + } + + fn pre_render(&self) -> eyre::Result<()> { + stackbar_manager::STACKBAR_TEMPORARILY_DISABLED.store(true, Ordering::SeqCst); + 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)) { + Ok(ghost) => { + *self.ghost.lock() = Some(ghost); + } + Err(error) => { + tracing::warn!( + "ghost movement: failed to create ghost for hwnd {}: {error}; \ + uncloaking and falling back to legacy path", + self.hwnd + ); + SetCloak(Window { hwnd: self.hwnd }.hwnd(), 1, 0); + self.cloaked.store(false, Ordering::SeqCst); + } + } + } Ok(()) } + + fn render(&self, progress: f64) -> eyre::Result<()> { + let logical = self.start_rect.lerp(self.target_rect, progress, self.style); + *self.last_animated_rect.lock() = logical; + + 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) + { + tracing::trace!("ghost update_rect failed: {error}"); + } + border_manager::animate_to(self.hwnd, logical); + } else { + // Legacy path: animations always run on a separate thread, so we don't + // gate on WINDOW_HANDLING_BEHAVIOUR here. + WindowsApi::move_window(self.hwnd, &logical, false)?; + WindowsApi::invalidate_rect(self.hwnd, None, false); + } + + Ok(()) + } + + 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. + unsafe { + let _ = windows::Win32::Graphics::Dwm::DwmFlush(); + } + } + + if let Some(ghost) = self.ghost.lock().take() { + let _ = ghost.dispose(); + } + + self.finalise_managers(); + + Ok(()) + } + + 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(); + + 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 self.cloaked.swap(false, Ordering::SeqCst) { + SetCloak(Window { hwnd: self.hwnd }.hwnd(), 1, 0); + } + + if let Some(ghost) = self.ghost.lock().take() { + let _ = ghost.dispose(); + } + + self.finalise_managers(); + } } struct TransparencyRenderDispatcher { diff --git a/komorebi/src/windows_api.rs b/komorebi/src/windows_api.rs index 326c4d5b..edb4f148 100644 --- a/komorebi/src/windows_api.rs +++ b/komorebi/src/windows_api.rs @@ -24,6 +24,7 @@ use windows::Win32::Foundation::WPARAM; use windows::Win32::Graphics::Dwm::DWM_CLOAKED_APP; use windows::Win32::Graphics::Dwm::DWM_CLOAKED_INHERITED; use windows::Win32::Graphics::Dwm::DWM_CLOAKED_SHELL; +use windows::Win32::Graphics::Dwm::DWM_THUMBNAIL_PROPERTIES; use windows::Win32::Graphics::Dwm::DWMWA_BORDER_COLOR; use windows::Win32::Graphics::Dwm::DWMWA_CLOAKED; use windows::Win32::Graphics::Dwm::DWMWA_COLOR_NONE; @@ -32,7 +33,10 @@ use windows::Win32::Graphics::Dwm::DWMWA_WINDOW_CORNER_PREFERENCE; use windows::Win32::Graphics::Dwm::DWMWCP_ROUND; use windows::Win32::Graphics::Dwm::DWMWINDOWATTRIBUTE; use windows::Win32::Graphics::Dwm::DwmGetWindowAttribute; +use windows::Win32::Graphics::Dwm::DwmRegisterThumbnail; use windows::Win32::Graphics::Dwm::DwmSetWindowAttribute; +use windows::Win32::Graphics::Dwm::DwmUnregisterThumbnail; +use windows::Win32::Graphics::Dwm::DwmUpdateThumbnailProperties; use windows::Win32::Graphics::Gdi::CreateSolidBrush; use windows::Win32::Graphics::Gdi::EnumDisplayMonitors; use windows::Win32::Graphics::Gdi::GetMonitorInfoW; @@ -144,6 +148,7 @@ 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; @@ -1343,6 +1348,41 @@ 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))) }?) + } + + pub fn dwm_update_thumbnail_properties( + hthumb: isize, + props: &DWM_THUMBNAIL_PROPERTIES, + ) -> eyre::Result<()> { + unsafe { DwmUpdateThumbnailProperties(hthumb, props) }.map_err(Into::into) + } + + pub fn dwm_unregister_thumbnail(hthumb: isize) -> eyre::Result<()> { + unsafe { DwmUnregisterThumbnail(hthumb) }.map_err(Into::into) + } + pub fn create_hidden_window(name: PCWSTR, instance: isize) -> eyre::Result { unsafe { CreateWindowExW( diff --git a/schema.json b/schema.json index 5e582ab6..d61dd84e 100644 --- a/schema.json +++ b/schema.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "StaticConfig", - "description": "The `komorebi.json` static configuration file reference for `v0.1.41`", + "description": "The `komorebi.json` static configuration file reference for `v0.1.42`", "type": "object", "properties": { "animation": { @@ -778,6 +778,14 @@ "default": 60, "minimum": 0 }, + "ghost_movement": { + "description": "Render movement animations on a GPU-composited ghost surface (recommended).\nWhen false, falls back to the legacy per-frame MoveWindow path.", + "type": [ + "boolean", + "null" + ], + "default": true + }, "style": { "description": "Set the animation style", "anyOf": [