From 5cc8e437655c6496e4659a172dbb9964a395c9bd Mon Sep 17 00:00:00 2001 From: LGUG2Z Date: Wed, 28 Oct 2026 12:54:29 -0700 Subject: [PATCH] feat(animation): clip a ghost to a rect A ghost can now be given a rect that none of it is drawn outside of: the part of it that is outside is cut off, with the part of its picture that goes there, and it is not shown at all when all of it is. A host that covers every monitor would otherwise draw a ghost that slides out of a monitor on the one next to it. What a ghost is made to show now includes which part of its picture goes where, so that a ghost that is already showing what it is asked to is still left alone. Checked against DWM with real pixels (on a host that is not layered, whose thumbnails the screen can be read back from): inside the clip the window, outside it the host, and nothing at all when it is all outside. --- komorebi/src/animation/ghost.rs | 393 ++++++++++++++++++++++++++++++-- 1 file changed, 370 insertions(+), 23 deletions(-) diff --git a/komorebi/src/animation/ghost.rs b/komorebi/src/animation/ghost.rs index b55b27a6..9c02b612 100644 --- a/komorebi/src/animation/ghost.rs +++ b/komorebi/src/animation/ghost.rs @@ -35,6 +35,7 @@ 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_RECTSOURCE; use windows::Win32::Graphics::Dwm::DWM_TNP_VISIBLE; use windows::Win32::Graphics::Gdi::AC_SRC_ALPHA; use windows::Win32::Graphics::Gdi::AC_SRC_OVER; @@ -81,6 +82,7 @@ use windows::core::PCWSTR; use crate::WindowsApi; use crate::animation::snapshot::Snapshot; use crate::animation::snapshot::SnapshotWindow; +use crate::animation::transition::clip_thumbnail; use crate::core::Rect; const GHOST_CLASS_NAME: &[u16] = &[ @@ -296,7 +298,11 @@ impl GhostHost { 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); + let props = thumbnail_properties( + destination(start_rect, origin), + full_source(snapshot.width, snapshot.height), + 255, + ); WindowsApi::dwm_update_thumbnail_properties(hthumb, &props) .inspect_err(|_| { let _ = WindowsApi::dwm_unregister_thumbnail(hthumb); @@ -471,17 +477,49 @@ fn destination(rect: Rect, origin: (i32, i32)) -> RECT { } } -fn thumbnail_properties(destination: RECT, opacity: u8) -> DWM_THUMBNAIL_PROPERTIES { +/// The whole of a picture, in pixels of it +fn full_source(width: i32, height: i32) -> RECT { + RECT { + left: 0, + top: 0, + right: width, + bottom: height, + } +} + +/// A rect, in pixels of the picture, as the part of it that is shown +fn part_of_source(part: Rect) -> RECT { + RECT { + left: part.left, + top: part.top, + right: part.left + part.right, + bottom: part.top + part.bottom, + } +} + +fn thumbnail_properties(destination: RECT, source: RECT, opacity: u8) -> DWM_THUMBNAIL_PROPERTIES { DWM_THUMBNAIL_PROPERTIES { - dwFlags: DWM_TNP_VISIBLE | DWM_TNP_RECTDESTINATION | DWM_TNP_OPACITY, + dwFlags: DWM_TNP_VISIBLE | DWM_TNP_RECTDESTINATION | DWM_TNP_RECTSOURCE | DWM_TNP_OPACITY, rcDestination: destination, - rcSource: RECT::default(), + rcSource: source, opacity, fVisible: true.into(), fSourceClientAreaOnly: false.into(), } } +/// A thumbnail that is not shown, because none of it is where it may be +fn hidden_thumbnail_properties() -> DWM_THUMBNAIL_PROPERTIES { + DWM_THUMBNAIL_PROPERTIES { + dwFlags: DWM_TNP_VISIBLE, + rcDestination: RECT::default(), + rcSource: RECT::default(), + opacity: 0, + fVisible: false.into(), + fSourceClientAreaOnly: false.into(), + } +} + /// 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 @@ -491,11 +529,21 @@ pub struct GhostWindow { hthumb: isize, /// Where the host's client area starts in screen coordinates origin: (i32, i32), - /// Where the ghost was last put, and how opaque it was made - shown: Mutex>, + /// How big the picture is, in pixels + source_size: (i32, i32), + /// What the ghost was last made to show + shown: Mutex>, disposed: bool, } +/// What a ghost shows: where it is drawn and which part of its picture goes +/// there, or nothing, and how opaque it is +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Shown { + drawn: Option<(Rect, Rect)>, + opacity: u8, +} + impl GhostWindow { /// Take a picture of the window and show it where the window is. The /// picture is taken before the window is touched. @@ -503,6 +551,7 @@ impl GhostWindow { let start = std::time::Instant::now(); let snapshot = Snapshot::capture(src_hwnd)?; let captured = start.elapsed(); + let source_size = (snapshot.width, snapshot.height); let (reply_tx, reply_rx) = bounded::>(1); ghost_owner() @@ -524,8 +573,21 @@ impl GhostWindow { Ok(Self { hthumb, origin, - // it is registered where the window is, and opaque - shown: Mutex::new(Some((start_rect, 255))), + source_size, + // it is registered where the window is, with all of its picture, + // and opaque + shown: Mutex::new(Some(Shown { + drawn: Some(( + start_rect, + Rect { + left: 0, + top: 0, + right: source_size.0, + bottom: source_size.1, + }, + )), + opacity: 255, + })), disposed: false, }) } @@ -537,13 +599,22 @@ impl GhostWindow { /// A ghost that is already there is left alone: asking DWM for what is /// already so still has it compose the frame again. pub fn update(&self, rect: Rect, opacity: u8) -> eyre::Result<()> { - if !needs_update(&mut self.shown.lock(), rect, opacity) { + self.update_clipped(rect, opacity, None) + } + + /// Like `update`, but nothing of the ghost is drawn outside `clip`: the + /// part of it that is outside is cut off, and it is not shown at all when + /// all of it is. + pub fn update_clipped(&self, rect: Rect, opacity: u8, clip: Option) -> eyre::Result<()> { + let drawn = drawn_part(rect, self.source_size, clip); + + if !needs_update(&mut self.shown.lock(), Shown { drawn, opacity }) { return Ok(()); } WindowsApi::dwm_update_thumbnail_properties( self.hthumb, - &thumbnail_properties(destination(rect, self.origin), opacity), + &properties_for(drawn, self.origin, opacity), ) } @@ -583,14 +654,47 @@ impl Drop for GhostWindow { } } -/// Whether putting a ghost at `rect` with `opacity` changes anything, given -/// where it was last put, which is remembered if it does -fn needs_update(shown: &mut Option<(Rect, u8)>, rect: Rect, opacity: u8) -> bool { - if *shown == Some((rect, opacity)) { +/// Where a ghost of a picture of `source_size` that is to be at `rect` is to be +/// drawn and which part of the picture goes there, when nothing of it is to be +/// drawn outside `clip`, if there is one. Nothing if none of it is inside. +fn drawn_part(rect: Rect, source_size: (i32, i32), clip: Option) -> Option<(Rect, Rect)> { + match clip { + Some(clip) => clip_thumbnail(rect, source_size, clip), + None => Some(( + rect, + Rect { + left: 0, + top: 0, + right: source_size.0, + bottom: source_size.1, + }, + )), + } +} + +/// What to ask DWM for to show what `drawn_part` says, on a host whose client +/// area starts at `origin` on the screen +fn properties_for( + drawn: Option<(Rect, Rect)>, + origin: (i32, i32), + opacity: u8, +) -> DWM_THUMBNAIL_PROPERTIES { + match drawn { + Some((rect, part)) => { + thumbnail_properties(destination(rect, origin), part_of_source(part), opacity) + } + None => hidden_thumbnail_properties(), + } +} + +/// Whether showing what a ghost is to show changes anything, given what it +/// was last made to show, which is remembered if it does +fn needs_update(shown: &mut Option, next: Shown) -> bool { + if *shown == Some(next) { return false; } - *shown = Some((rect, opacity)); + *shown = Some(next); true } @@ -710,23 +814,59 @@ mod tests { right: 100, bottom: 50, }; - let mut shown = Some((rect(0), 255)); + let picture = rect(0); + let at = |left, opacity| Shown { + drawn: Some((rect(left), picture)), + opacity, + }; + let mut shown = Some(at(0, 255)); // where it is already - assert!(!needs_update(&mut shown, rect(0), 255)); + assert!(!needs_update(&mut shown, at(0, 255))); // moved, or faded, or both - assert!(needs_update(&mut shown, rect(10), 255)); - assert!(!needs_update(&mut shown, rect(10), 255)); - assert!(needs_update(&mut shown, rect(10), 200)); - assert!(needs_update(&mut shown, rect(20), 100)); + assert!(needs_update(&mut shown, at(10, 255))); + assert!(!needs_update(&mut shown, at(10, 255))); + assert!(needs_update(&mut shown, at(10, 200))); + assert!(needs_update(&mut shown, at(20, 100))); // and back, which is a change from where it was last - assert!(needs_update(&mut shown, rect(10), 200)); + assert!(needs_update(&mut shown, at(10, 200))); // one that was never put anywhere is put somewhere let mut never = None; - assert!(needs_update(&mut never, rect(0), 255)); + assert!(needs_update(&mut never, at(0, 255))); + } + + #[test] + fn a_ghost_is_updated_when_the_part_of_it_that_is_drawn_changes() { + let rect = |left, width| Rect { + left, + top: 0, + right: width, + bottom: 50, + }; + let drawn = |part_left, part_width| Shown { + drawn: Some((rect(0, part_width), rect(part_left, part_width))), + opacity: 255, + }; + let nothing = Shown { + drawn: None, + opacity: 255, + }; + + let mut shown = Some(drawn(0, 100)); + + // the same place, but a different part of the picture + assert!(needs_update(&mut shown, drawn(20, 100))); + assert!(!needs_update(&mut shown, drawn(20, 100))); + + // not drawn at all, which is asked for once + assert!(needs_update(&mut shown, nothing)); + assert!(!needs_update(&mut shown, nothing)); + + // and drawn again + assert!(needs_update(&mut shown, drawn(20, 100))); } #[test] @@ -770,4 +910,211 @@ mod tests { assert_eq!(ghost_opacity(1.0, 1.0), 0); assert_eq!(ghost_opacity(0.99, 1.0), 255); } + + /// Needs a desktop to put windows on, so it is only run when asked to: + /// `cargo test -p komorebi ghost -- --ignored` + /// + /// What is asked of DWM to clip a ghost to a rect, as if the rect were the + /// edge of a monitor with another one next to it, draws none of it outside + /// the rect, and all of the rest of it where it belongs. + /// + /// (The host of the ghosts is a layered window, whose thumbnails the screen + /// cannot be read back from, so this is done on a host that is not.) + #[test] + #[ignore = "needs an interactive desktop"] + fn dwm_draws_a_clipped_thumbnail_only_inside_its_clip() { + use windows::Win32::Graphics::Gdi::BitBlt; + use windows::Win32::Graphics::Gdi::CAPTUREBLT; + use windows::Win32::Graphics::Gdi::CreateCompatibleBitmap; + use windows::Win32::Graphics::Gdi::CreateSolidBrush; + use windows::Win32::Graphics::Gdi::DeleteDC; + use windows::Win32::Graphics::Gdi::DeleteObject; + use windows::Win32::Graphics::Gdi::GetPixel; + use windows::Win32::Graphics::Gdi::ROP_CODE; + use windows::Win32::Graphics::Gdi::SRCCOPY; + use windows::Win32::Graphics::Gdi::SelectObject; + use windows::Win32::UI::WindowsAndMessaging::CreateWindowExW; + use windows::Win32::UI::WindowsAndMessaging::DestroyWindow; + use windows::Win32::UI::WindowsAndMessaging::HWND_TOPMOST; + use windows::Win32::UI::WindowsAndMessaging::SWP_NOACTIVATE; + use windows::Win32::UI::WindowsAndMessaging::SetWindowPos; + use windows::Win32::UI::WindowsAndMessaging::WS_EX_NOACTIVATE; + use windows::Win32::UI::WindowsAndMessaging::WS_EX_TOOLWINDOW; + use windows::Win32::UI::WindowsAndMessaging::WS_POPUP; + use windows::Win32::UI::WindowsAndMessaging::WS_VISIBLE; + use windows::core::w; + + // windows of two colours nothing else on the desktop is: the one that + // is thumbnailed (magenta) and the host that it is drawn on (green) + let instance = WindowsApi::module_handle_w().unwrap(); + let window = |class: windows::core::PCWSTR, colour: u32, x, y, width, height| { + let window_class = WNDCLASSW { + hInstance: instance.into(), + lpszClassName: class, + lpfnWndProc: Some(ghost_wnd_proc), + hbrBackground: unsafe { CreateSolidBrush(COLORREF(colour)) }, + ..Default::default() + }; + let _ = WindowsApi::register_class_w(&window_class); + + unsafe { + CreateWindowExW( + WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE, + class, + class, + WS_POPUP | WS_VISIBLE, + x, + y, + width, + height, + None, + None, + Some(instance.into()), + None, + ) + .unwrap() + } + }; + + let source = window( + w!("komorebi-ghost-test-source"), + 0x00FF_00FF, + 100, + 100, + 300, + 200, + ); + let host = window( + w!("komorebi-ghost-test-host"), + 0x0000_FF00, + 600, + 300, + 800, + 600, + ); + unsafe { + SetWindowPos(host, Some(HWND_TOPMOST), 600, 300, 800, 600, SWP_NOACTIVATE).unwrap(); + } + // paint them now: nothing is pumping the messages of this thread + for hwnd in [source, host] { + unsafe { + use windows::Win32::Graphics::Gdi::RDW_ERASE; + use windows::Win32::Graphics::Gdi::RDW_INVALIDATE; + use windows::Win32::Graphics::Gdi::RDW_UPDATENOW; + use windows::Win32::Graphics::Gdi::RedrawWindow; + + let _ = RedrawWindow( + Some(hwnd), + None, + None, + RDW_INVALIDATE | RDW_ERASE | RDW_UPDATENOW, + ); + } + } + std::thread::sleep(Duration::from_millis(300)); + + let hthumb = WindowsApi::dwm_register_thumbnail(host.0 as isize, source.0 as isize) + .expect("a thumbnail of the window"); + let size = unsafe { windows::Win32::Graphics::Dwm::DwmQueryThumbnailSourceSize(hthumb) } + .expect("the size of what is thumbnailed"); + let size = (size.cx, size.cy); + + // shown 400 wide, from 600 on the screen, of which what is right of 800 + // is inside the clip + let dest = Rect { + left: 600, + top: 400, + right: 400, + bottom: 300, + }; + let clip = Rect { + left: 800, + top: 0, + right: 1000, + bottom: 1500, + }; + let drawn = drawn_part(dest, size, Some(clip)); + assert!(drawn.is_some()); + WindowsApi::dwm_update_thumbnail_properties( + hthumb, + &properties_for(drawn, (600, 300), 255), + ) + .expect("show it clipped"); + std::thread::sleep(Duration::from_millis(400)); + + // the screen is read in pixels, and the windows were put where they are in + // the units of a process that the display's scaling is applied to + let scale = WindowsApi::window_rect(host.0 as isize) + .map(|rect| f64::from(rect.right) / 800.0) + .unwrap_or(1.0); + + let screen = unsafe { windows::Win32::Graphics::Gdi::GetDC(None) }; + let sample = |x: i32, y: i32| unsafe { + let (x, y) = ( + (f64::from(x) * scale).round() as i32, + (f64::from(y) * scale).round() as i32, + ); + let dc = CreateCompatibleDC(Some(screen)); + let bitmap = CreateCompatibleBitmap(screen, 1, 1); + let previous = SelectObject(dc, bitmap.into()); + let _ = BitBlt( + dc, + 0, + 0, + 1, + 1, + Some(screen), + x, + y, + ROP_CODE(SRCCOPY.0 | CAPTUREBLT.0), + ); + let pixel = GetPixel(dc, 0, 0).0 & 0xFF_FFFF; + SelectObject(dc, previous); + let _ = DeleteObject(bitmap.into()); + let _ = DeleteDC(dc); + pixel + }; + + // left of the clip, but in the destination: what the host is + let outside = sample(650, 450); + // right of the edge of the clip, in the destination: the window + let inside = sample(950, 450); + // in the host and not in the destination at all: what the host is + let elsewhere = sample(650, 350); + + // all of it moved out of the clip: hidden + let hidden = drawn_part(Rect { left: 100, ..dest }, size, Some(clip)); + assert!(hidden.is_none()); + WindowsApi::dwm_update_thumbnail_properties( + hthumb, + &properties_for(hidden, (600, 300), 255), + ) + .expect("hide it"); + std::thread::sleep(Duration::from_millis(400)); + let moved_out = sample(950, 450); + + unsafe { + ReleaseDC(None, screen); + let _ = WindowsApi::dwm_unregister_thumbnail(hthumb); + let _ = DestroyWindow(source); + let _ = DestroyWindow(host); + } + + assert_eq!( + outside, 0x00FF00, + "the host outside the clip, got {outside:#08x}" + ); + assert_eq!( + inside, 0xFF00FF, + "the window inside the clip, got {inside:#08x}" + ); + assert_eq!( + elsewhere, 0x00FF00, + "the host outside the destination, got {elsewhere:#08x}" + ); + assert_eq!( + moved_out, 0x00FF00, + "nothing drawn when all of it is outside, got {moved_out:#08x}" + ); + } }