Compare commits

...

3 Commits

Author SHA1 Message Date
dependabot[bot]
17483d126a chore(deps): bump egui_extras from 0.33.3 to 0.34.2
Bumps [egui_extras](https://github.com/emilk/egui) from 0.33.3 to 0.34.2.
- [Release notes](https://github.com/emilk/egui/releases)
- [Changelog](https://github.com/emilk/egui/blob/0.34.2/CHANGELOG.md)
- [Commits](https://github.com/emilk/egui/compare/0.33.3...0.34.2)

---
updated-dependencies:
- dependency-name: egui_extras
  dependency-version: 0.34.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-04 18:56:13 +00:00
LGUG2Z
e2e5dbfcae 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.
2026-05-03 16:09:28 -07:00
LGUG2Z
937b28a7d9 chore(dev): begin 0.1.42-dev 2026-05-03 16:06:46 -07:00
21 changed files with 1037 additions and 136 deletions

242
Cargo.lock generated
View File

@@ -24,13 +24,22 @@ version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf203f9d3bd8f29f98833d1fbef628df18f759248a547e7e01cfbf63cda36a99"
[[package]]
name = "accesskit"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5351dcebb14b579ccab05f288596b2ae097005be7ee50a7c3d4ca9d0d5a66f6a"
dependencies = [
"uuid",
]
[[package]]
name = "accesskit_atspi_common"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "890d241cf51fc784f0ac5ac34dfc847421f8d39da6c7c91a0fcc987db62a8267"
dependencies = [
"accesskit",
"accesskit 0.21.1",
"accesskit_consumer",
"atspi-common",
"serde",
@@ -44,7 +53,7 @@ version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db81010a6895d8707f9072e6ce98070579b43b717193d2614014abd5cb17dd43"
dependencies = [
"accesskit",
"accesskit 0.21.1",
"hashbrown 0.15.5",
]
@@ -54,7 +63,7 @@ version = "0.22.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0089e5c0ac0ca281e13ea374773898d9354cc28d15af9f0f7394d44a495b575"
dependencies = [
"accesskit",
"accesskit 0.21.1",
"accesskit_consumer",
"hashbrown 0.15.5",
"objc2 0.5.2",
@@ -68,7 +77,7 @@ version = "0.17.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "301e55b39cfc15d9c48943ce5f572204a551646700d0e8efa424585f94fec528"
dependencies = [
"accesskit",
"accesskit 0.21.1",
"accesskit_atspi_common",
"async-channel",
"async-executor",
@@ -86,7 +95,7 @@ version = "0.29.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2d63dd5041e49c363d83f5419a896ecb074d309c414036f616dc0b04faca971"
dependencies = [
"accesskit",
"accesskit 0.21.1",
"accesskit_consumer",
"hashbrown 0.15.5",
"static_assertions",
@@ -100,7 +109,7 @@ version = "0.29.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8cfabe59d0eaca7412bfb1f70198dd31e3b0496fee7e15b066f9c36a1a140a0"
dependencies = [
"accesskit",
"accesskit 0.21.1",
"accesskit_macos",
"accesskit_unix",
"accesskit_windows",
@@ -642,7 +651,7 @@ name = "base16-egui-themes"
version = "0.1.0"
source = "git+https://github.com/LGUG2Z/base16-egui-themes?rev=3f157904c641f0dc80f043449fe0214fc4182425#3f157904c641f0dc80f043449fe0214fc4182425"
dependencies = [
"egui",
"egui 0.33.3",
"schemars 1.2.1",
"serde",
"strum",
@@ -921,7 +930,7 @@ name = "catppuccin-egui"
version = "5.6.0"
source = "git+https://github.com/LGUG2Z/catppuccin-egui?rev=b2f95cbf441d1dd99f3c955ef10dcb84ce23c20a#b2f95cbf441d1dd99f3c955ef10dcb84ce23c20a"
dependencies = [
"egui",
"egui 0.33.3",
]
[[package]]
@@ -1078,6 +1087,15 @@ dependencies = [
"unicode-width 0.2.2",
]
[[package]]
name = "color"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a18ef4657441fb193b65f34dc39b3781f0dfec23d3bd94d0eeb4e88cde421edb"
dependencies = [
"bytemuck",
]
[[package]]
name = "color-eyre"
version = "0.6.5"
@@ -1592,7 +1610,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71ddb8ac7643d1dba1bb02110e804406dd459a838efcb14011ced10556711a8e"
dependencies = [
"bytemuck",
"emath",
"emath 0.33.3",
]
[[package]]
name = "ecolor"
version = "0.34.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55f6cc0cb3b84a21232c468db972ebcddd34decbf1ff02cdebffd807c13bbd81"
dependencies = [
"emath 0.34.2",
]
[[package]]
@@ -1628,7 +1655,7 @@ dependencies = [
"ahash",
"bytemuck",
"document-features",
"egui",
"egui 0.33.3",
"egui-wgpu",
"egui-winit",
"egui_glow",
@@ -1660,11 +1687,29 @@ version = "0.33.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a9b567d356674e9a5121ed3fedfb0a7c31e059fe71f6972b691bcd0bfc284e3"
dependencies = [
"accesskit",
"accesskit 0.21.1",
"ahash",
"bitflags 2.11.1",
"emath",
"epaint",
"emath 0.33.3",
"epaint 0.33.3",
"log",
"nohash-hasher",
"profiling",
"smallvec",
"unicode-segmentation",
]
[[package]]
name = "egui"
version = "0.34.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cbe28ac1a9c0761319aafb9ad37737720cc49d99c13a4a6b990768fa01ffe67"
dependencies = [
"accesskit 0.24.0",
"ahash",
"bitflags 2.11.1",
"emath 0.34.2",
"epaint 0.34.2",
"log",
"nohash-hasher",
"profiling",
@@ -1677,7 +1722,7 @@ name = "egui-phosphor"
version = "0.10.0"
source = "git+https://github.com/amPerl/egui-phosphor?rev=d13688738478ecd12b426e3e74c59d6577a85b59#d13688738478ecd12b426e3e74c59d6577a85b59"
dependencies = [
"egui",
"egui 0.33.3",
]
[[package]]
@@ -1689,8 +1734,8 @@ dependencies = [
"ahash",
"bytemuck",
"document-features",
"egui",
"epaint",
"egui 0.33.3",
"epaint 0.33.3",
"log",
"profiling",
"thiserror 2.0.18",
@@ -1709,7 +1754,7 @@ dependencies = [
"accesskit_winit",
"arboard",
"bytemuck",
"egui",
"egui 0.33.3",
"log",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
@@ -1724,12 +1769,12 @@ dependencies = [
[[package]]
name = "egui_extras"
version = "0.33.3"
version = "0.34.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d01d34e845f01c62e3fded726961092e70417d66570c499b9817ab24674ca4ed"
checksum = "8c609fc87f6c70ffd3afd679cbb294985096d2fc0be33e762ad5614bde4925bc"
dependencies = [
"ahash",
"egui",
"egui 0.34.2",
"enum-map",
"log",
"mime_guess2",
@@ -1743,7 +1788,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6420863ea1d90e750f75075231a260030ad8a9f30a7cef82cdc966492dc4c4eb"
dependencies = [
"bytemuck",
"egui",
"egui 0.33.3",
"glow",
"log",
"memoffset",
@@ -1768,6 +1813,12 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "emath"
version = "0.34.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a74fbbf7501c430b89df62d102b6bfa02162faaf3e155512c677c9d20f5708d1"
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -1833,8 +1884,8 @@ dependencies = [
"ab_glyph",
"ahash",
"bytemuck",
"ecolor",
"emath",
"ecolor 0.33.3",
"emath 0.33.3",
"epaint_default_fonts",
"log",
"nohash-hasher",
@@ -1842,6 +1893,26 @@ dependencies = [
"profiling",
]
[[package]]
name = "epaint"
version = "0.34.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92b452e348c2758115288802ca25f86ee286ce2cfae6643711ce116662311310"
dependencies = [
"ahash",
"ecolor 0.34.2",
"emath 0.34.2",
"font-types",
"log",
"nohash-hasher",
"parking_lot",
"profiling",
"self_cell",
"skrifa",
"smallvec",
"vello_cpu",
]
[[package]]
name = "epaint_default_fonts"
version = "0.33.3"
@@ -1890,6 +1961,15 @@ version = "3.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
[[package]]
name = "euclid"
version = "0.22.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"
dependencies = [
"num-traits",
]
[[package]]
name = "event-listener"
version = "5.4.1"
@@ -1973,6 +2053,15 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "fearless_simd"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fb2907d1f08b2b316b9223ced5b0e89d87028ba8deae9764741dba8ff7f3903"
dependencies = [
"bytemuck",
]
[[package]]
name = "fiat-crypto"
version = "0.2.9"
@@ -2074,6 +2163,15 @@ dependencies = [
"winapi",
]
[[package]]
name = "font-types"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7"
dependencies = [
"bytemuck",
]
[[package]]
name = "foreign-types"
version = "0.3.2"
@@ -3168,7 +3266,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "komorebi"
version = "0.1.41"
version = "0.1.42"
dependencies = [
"base64",
"bitflags 2.11.1",
@@ -3219,7 +3317,7 @@ dependencies = [
[[package]]
name = "komorebi-bar"
version = "0.1.41"
version = "0.1.42"
dependencies = [
"chrono",
"chrono-tz",
@@ -3263,7 +3361,7 @@ dependencies = [
[[package]]
name = "komorebi-client"
version = "0.1.41"
version = "0.1.42"
dependencies = [
"komorebi",
"serde_json_lenient",
@@ -3272,7 +3370,7 @@ dependencies = [
[[package]]
name = "komorebi-gui"
version = "0.1.41"
version = "0.1.42"
dependencies = [
"eframe",
"egui_extras",
@@ -3285,7 +3383,7 @@ dependencies = [
[[package]]
name = "komorebi-layouts"
version = "0.1.41"
version = "0.1.42"
dependencies = [
"clap",
"color-eyre",
@@ -3311,7 +3409,7 @@ dependencies = [
[[package]]
name = "komorebi-themes"
version = "0.1.41"
version = "0.1.42"
dependencies = [
"base16-egui-themes",
"catppuccin-egui",
@@ -3326,7 +3424,7 @@ dependencies = [
[[package]]
name = "komorebic"
version = "0.1.41"
version = "0.1.42"
dependencies = [
"chrono",
"clap",
@@ -3354,7 +3452,7 @@ dependencies = [
[[package]]
name = "komorebic-no-console"
version = "0.1.41"
version = "0.1.42"
[[package]]
name = "konst"
@@ -3391,6 +3489,17 @@ dependencies = [
"libc",
]
[[package]]
name = "kurbo"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7564e90fe3c0d5771e1f0bc95322b21baaeaa0d9213fa6a0b61c99f8b17b3bfb"
dependencies = [
"arrayvec",
"euclid",
"smallvec",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -3483,6 +3592,12 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "linebender_resource_handle"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
[[package]]
name = "linked-hash-map"
version = "0.5.6"
@@ -4807,6 +4922,19 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "peniko"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2b6aadb221872732e87d465213e9be5af2849b0e8cc5300a8ba98fffa2e00a"
dependencies = [
"bytemuck",
"color",
"kurbo",
"linebender_resource_handle",
"smallvec",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -5348,6 +5476,16 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "read-fonts"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5"
dependencies = [
"bytemuck",
"font-types",
]
[[package]]
name = "redox_syscall"
version = "0.4.1"
@@ -5730,6 +5868,12 @@ dependencies = [
"libc",
]
[[package]]
name = "self_cell"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89"
[[package]]
name = "semver"
version = "1.0.28"
@@ -6022,6 +6166,16 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
[[package]]
name = "skrifa"
version = "0.40.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac"
dependencies = [
"bytemuck",
"read-fonts",
]
[[package]]
name = "slab"
version = "0.4.12"
@@ -6962,6 +7116,32 @@ version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "vello_common"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd1a4c633ce09e7d713df1a6e036644a125e15e0c169cfb5180ddf5836ca04b"
dependencies = [
"bytemuck",
"fearless_simd",
"hashbrown 0.16.1",
"log",
"peniko",
"skrifa",
"smallvec",
]
[[package]]
name = "vello_cpu"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0162bfe48aabf6a9fdcd401b628c7d9f260c2cbabb343c70a65feba6f7849edc"
dependencies = [
"bytemuck",
"hashbrown 0.16.1",
"vello_common",
]
[[package]]
name = "version_check"
version = "0.9.5"

View File

@@ -21,7 +21,7 @@ crossbeam-channel = "0.5"
crossbeam-utils = "0.8"
color-eyre = "0.6"
eframe = "0.33"
egui_extras = "0.33"
egui_extras = "0.34"
dirs = "6"
dunce = "1"
hotwatch = "0.5"

View File

@@ -1,6 +1,6 @@
[package]
name = "komorebi-bar"
version = "0.1.41"
version = "0.1.42"
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

View File

@@ -15,7 +15,7 @@ use std::path::PathBuf;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
/// The `komorebi.bar.json` configuration file reference for `v0.1.41`
/// The `komorebi.bar.json` configuration file reference for `v0.1.42`
pub struct KomobarConfig {
/// Bar height
#[cfg_attr(feature = "schemars", schemars(extend("default" = 50)))]

View File

@@ -1,6 +1,6 @@
[package]
name = "komorebi-client"
version = "0.1.41"
version = "0.1.42"
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

View File

@@ -1,6 +1,6 @@
[package]
name = "komorebi-gui"
version = "0.1.41"
version = "0.1.42"
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

View File

@@ -1,6 +1,6 @@
[package]
name = "komorebi-layouts"
version = "0.1.41"
version = "0.1.42"
edition = "2024"
[dependencies]

View File

@@ -1,6 +1,6 @@
[package]
name = "komorebi-themes"
version = "0.1.41"
version = "0.1.42"
edition = "2024"
[dependencies]

View File

@@ -1,6 +1,6 @@
[package]
name = "komorebi"
version = "0.1.41"
version = "0.1.42"
description = "A tiling window manager for Windows"
repository = "https://github.com/LGUG2Z/komorebi"
edition = "2024"

View File

@@ -86,6 +86,7 @@ impl AnimationEngine {
{
// cancel animation
ANIMATION_MANAGER.lock().cancel(animation_key.as_str());
render_dispatcher.cleanup_on_cancel();
return Ok(());
}

View File

@@ -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<isize>,
reply: Sender<eyre::Result<(isize, isize)>>,
},
UpdateRect {
host_hwnd: isize,
hthumb: isize,
rect: Rect,
},
Destroy {
host_hwnd: isize,
hthumb: isize,
},
}
struct GhostOwner {
cmd_tx: Sender<GhostCmd>,
}
static GHOST_OWNER: OnceLock<GhostOwner> = OnceLock::new();
fn ghost_owner() -> &'static GhostOwner {
GHOST_OWNER.get_or_init(|| {
let (tx, rx) = unbounded::<GhostCmd>();
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<GhostCmd>) {
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<isize> {
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<isize>,
) -> 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<isize>) -> eyre::Result<Self> {
let (reply_tx, reply_rx) = bounded::<eyre::Result<(isize, isize)>>(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();
}
}

View File

@@ -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<Mutex<AnimationManager>> =
@@ -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);

View File

@@ -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) {}
}

View File

@@ -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<Rect>` 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<D2D1_BRUSH_PROPERTIES> =
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<isize>) };
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<Rect> 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 => {

View File

@@ -113,6 +113,20 @@ pub fn window_border(hwnd: isize) -> Option<BorderInfo> {
})
}
/// 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<isize>) {
if event_tx().try_send(Notification::Update(hwnd)).is_err() {
tracing::warn!("channel is full; dropping notification")

View File

@@ -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;
@@ -475,7 +477,7 @@ pub enum AppSpecificConfigurationPath {
#[serde_with::serde_as]
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
/// The `komorebi.json` static configuration file reference for `v0.1.41`
/// The `komorebi.json` static configuration file reference for `v0.1.42`
pub struct StaticConfig {
/// DEPRECATED from v0.1.22: no longer required
#[deprecated(note = "No longer required")]
@@ -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<u64>,
/// 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<bool>,
}
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 {

View File

@@ -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<Option<GhostWindow>>,
/// 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<Rect>,
/// 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 {

View File

@@ -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<isize> {
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<isize> {
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<isize> {
unsafe {
CreateWindowExW(

View File

@@ -1,6 +1,6 @@
[package]
name = "komorebic-no-console"
version = "0.1.41"
version = "0.1.42"
description = "The command-line interface (without a console) for Komorebi, a tiling window manager for Windows"
repository = "https://github.com/LGUG2Z/komorebi"
edition = "2024"

View File

@@ -1,6 +1,6 @@
[package]
name = "komorebic"
version = "0.1.41"
version = "0.1.42"
description = "The command-line interface for Komorebi, a tiling window manager for Windows"
repository = "https://github.com/LGUG2Z/komorebi"
edition = "2024"

View File

@@ -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": [