Compare commits

...

2 Commits

Author SHA1 Message Date
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
20 changed files with 833 additions and 112 deletions

16
Cargo.lock generated
View File

@@ -3168,7 +3168,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]] [[package]]
name = "komorebi" name = "komorebi"
version = "0.1.41" version = "0.1.42"
dependencies = [ dependencies = [
"base64", "base64",
"bitflags 2.11.1", "bitflags 2.11.1",
@@ -3219,7 +3219,7 @@ dependencies = [
[[package]] [[package]]
name = "komorebi-bar" name = "komorebi-bar"
version = "0.1.41" version = "0.1.42"
dependencies = [ dependencies = [
"chrono", "chrono",
"chrono-tz", "chrono-tz",
@@ -3263,7 +3263,7 @@ dependencies = [
[[package]] [[package]]
name = "komorebi-client" name = "komorebi-client"
version = "0.1.41" version = "0.1.42"
dependencies = [ dependencies = [
"komorebi", "komorebi",
"serde_json_lenient", "serde_json_lenient",
@@ -3272,7 +3272,7 @@ dependencies = [
[[package]] [[package]]
name = "komorebi-gui" name = "komorebi-gui"
version = "0.1.41" version = "0.1.42"
dependencies = [ dependencies = [
"eframe", "eframe",
"egui_extras", "egui_extras",
@@ -3285,7 +3285,7 @@ dependencies = [
[[package]] [[package]]
name = "komorebi-layouts" name = "komorebi-layouts"
version = "0.1.41" version = "0.1.42"
dependencies = [ dependencies = [
"clap", "clap",
"color-eyre", "color-eyre",
@@ -3311,7 +3311,7 @@ dependencies = [
[[package]] [[package]]
name = "komorebi-themes" name = "komorebi-themes"
version = "0.1.41" version = "0.1.42"
dependencies = [ dependencies = [
"base16-egui-themes", "base16-egui-themes",
"catppuccin-egui", "catppuccin-egui",
@@ -3326,7 +3326,7 @@ dependencies = [
[[package]] [[package]]
name = "komorebic" name = "komorebic"
version = "0.1.41" version = "0.1.42"
dependencies = [ dependencies = [
"chrono", "chrono",
"clap", "clap",
@@ -3354,7 +3354,7 @@ dependencies = [
[[package]] [[package]]
name = "komorebic-no-console" name = "komorebic-no-console"
version = "0.1.41" version = "0.1.42"
[[package]] [[package]]
name = "konst" name = "konst"

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "komorebi-bar" name = "komorebi-bar"
version = "0.1.41" version = "0.1.42"
edition = "2024" edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # 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)] #[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[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 { pub struct KomobarConfig {
/// Bar height /// Bar height
#[cfg_attr(feature = "schemars", schemars(extend("default" = 50)))] #[cfg_attr(feature = "schemars", schemars(extend("default" = 50)))]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -86,6 +86,7 @@ impl AnimationEngine {
{ {
// cancel animation // cancel animation
ANIMATION_MANAGER.lock().cancel(animation_key.as_str()); ANIMATION_MANAGER.lock().cancel(animation_key.as_str());
render_dispatcher.cleanup_on_cancel();
return Ok(()); 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 use engine::AnimationEngine;
pub mod animation_manager; pub mod animation_manager;
pub mod engine; pub mod engine;
pub mod ghost;
pub mod lerp; pub mod lerp;
pub mod prefix; pub mod prefix;
pub mod render_dispatcher; 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_STYLE: AnimationStyle = AnimationStyle::Linear;
pub const DEFAULT_ANIMATION_DURATION: u64 = 250; pub const DEFAULT_ANIMATION_DURATION: u64 = 250;
pub const DEFAULT_ANIMATION_FPS: u64 = 60; pub const DEFAULT_ANIMATION_FPS: u64 = 60;
pub const DEFAULT_GHOST_MOVEMENT: bool = true;
lazy_static! { lazy_static! {
pub static ref ANIMATION_MANAGER: Arc<Mutex<AnimationManager>> = 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 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 pre_render(&self) -> eyre::Result<()>;
fn render(&self, delta: f64) -> eyre::Result<()>; fn render(&self, delta: f64) -> eyre::Result<()>;
fn post_render(&self) -> 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. /// 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; 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); pub struct RenderFactory(ID2D1Factory);
unsafe impl Sync for RenderFactory {} unsafe impl Sync for RenderFactory {}
unsafe impl Send for RenderFactory {} unsafe impl Send for RenderFactory {}
@@ -106,6 +111,98 @@ static BRUSH_PROPERTIES: LazyLock<D2D1_BRUSH_PROPERTIES> =
transform: Matrix3x2::identity(), 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 { pub extern "system" fn border_hwnds(hwnd: HWND, lparam: LPARAM) -> BOOL {
let hwnds = unsafe { &mut *(lparam.0 as *mut Vec<isize>) }; let hwnds = unsafe { &mut *(lparam.0 as *mut Vec<isize>) };
let hwnd = hwnd.0 as 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<()> { pub fn set_position(&self, rect: &Rect, reference_hwnd: isize) -> color_eyre::Result<()> {
let mut rect = *rect; let mut rect = *rect;
rect.add_margin(self.width); rect.add_margin(self.width);
@@ -419,81 +539,24 @@ impl Border {
} }
let reference_hwnd = (*border_pointer).tracking_hwnd; let reference_hwnd = (*border_pointer).tracking_hwnd;
let old_rect = (*border_pointer).window_rect;
let rect = WindowsApi::window_rect(reference_hwnd).unwrap_or_default(); 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 border_pointer.is_null() {
return LRESULT(0);
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)) if (*border_pointer).is_destroying.load(Ordering::Acquire) {
&& let Some(render_target) = (*border_pointer).render_target.as_ref() return LRESULT(0);
{
// 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);
}
} }
apply_tracked_rect(border_pointer, *rect_box);
LRESULT(0) LRESULT(0)
} }
WM_PAINT => { 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>) { pub fn send_notification(hwnd: Option<isize>) {
if event_tx().try_send(Notification::Update(hwnd)).is_err() { if event_tx().try_send(Notification::Update(hwnd)).is_err() {
tracing::warn!("channel is full; dropping notification") 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_GLOBAL;
use crate::animation::ANIMATION_STYLE_PER_ANIMATION; use crate::animation::ANIMATION_STYLE_PER_ANIMATION;
use crate::animation::DEFAULT_ANIMATION_FPS; use crate::animation::DEFAULT_ANIMATION_FPS;
use crate::animation::DEFAULT_GHOST_MOVEMENT;
use crate::animation::GHOST_MOVEMENT_ENABLED;
use crate::animation::PerAnimationPrefixConfig; use crate::animation::PerAnimationPrefixConfig;
use crate::asc::ApplicationSpecificConfiguration; use crate::asc::ApplicationSpecificConfiguration;
use crate::asc::AscApplicationRulesOrSchema; use crate::asc::AscApplicationRulesOrSchema;
@@ -475,7 +477,7 @@ pub enum AppSpecificConfigurationPath {
#[serde_with::serde_as] #[serde_with::serde_as]
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[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 { pub struct StaticConfig {
/// DEPRECATED from v0.1.22: no longer required /// DEPRECATED from v0.1.22: no longer required
#[deprecated(note = "No longer required")] #[deprecated(note = "No longer required")]
@@ -695,6 +697,11 @@ pub struct AnimationsConfig {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "schemars", schemars(extend("default" = ANIMATION_FPS)))] #[cfg_attr(feature = "schemars", schemars(extend("default" = ANIMATION_FPS)))]
pub fps: Option<u64>, 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; pub use komorebi_themes::KomorebiTheme;
@@ -1022,6 +1029,17 @@ impl StaticConfig {
animations.fps.unwrap_or(DEFAULT_ANIMATION_FPS), animations.fps.unwrap_or(DEFAULT_ANIMATION_FPS),
Ordering::SeqCst, 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 { 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_GLOBAL;
use crate::animation::ANIMATION_STYLE_PER_ANIMATION; use crate::animation::ANIMATION_STYLE_PER_ANIMATION;
use crate::animation::AnimationEngine; use crate::animation::AnimationEngine;
use crate::animation::GHOST_MOVEMENT_ENABLED;
use crate::animation::RenderDispatcher; use crate::animation::RenderDispatcher;
use crate::animation::ghost::GhostWindow;
use crate::animation::lerp::Lerp; use crate::animation::lerp::Lerp;
use crate::animation::prefix::AnimationPrefix; use crate::animation::prefix::AnimationPrefix;
use crate::animation::prefix::new_animation_key; use crate::animation::prefix::new_animation_key;
@@ -42,6 +44,7 @@ use crate::windows_api;
use crate::windows_api::WindowsApi; use crate::windows_api::WindowsApi;
use color_eyre::eyre; use color_eyre::eyre;
use crossbeam_utils::atomic::AtomicConsume; use crossbeam_utils::atomic::AtomicConsume;
use parking_lot::Mutex;
use regex::Regex; use regex::Regex;
use serde::Deserialize; use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
@@ -52,6 +55,7 @@ use std::convert::TryFrom;
use std::fmt::Display; use std::fmt::Display;
use std::fmt::Formatter; use std::fmt::Formatter;
use std::fmt::Write as _; use std::fmt::Write as _;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicI32; use std::sync::atomic::AtomicI32;
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
use std::thread; use std::thread;
@@ -165,6 +169,18 @@ struct MovementRenderDispatcher {
target_rect: Rect, target_rect: Rect,
top: bool, top: bool,
style: AnimationStyle, 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 { impl MovementRenderDispatcher {
@@ -183,37 +199,33 @@ impl MovementRenderDispatcher {
target_rect, target_rect,
top, top,
style, 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 use_ghost(&self) -> bool {
fn get_animation_key(&self) -> String { GHOST_MOVEMENT_ENABLED.load(Ordering::Relaxed)
new_animation_key(MovementRenderDispatcher::PREFIX, self.hwnd.to_string())
} }
fn pre_render(&self) -> eyre::Result<()> { /// Chromium / Electron windows expose a top-level class beginning with
stackbar_manager::STACKBAR_TEMPORARILY_DISABLED.store(true, Ordering::SeqCst); /// `Chrome_WidgetWin_`. Their renderer pipeline is suspended whenever
stackbar_manager::send_notification(); /// `NativeWindowOcclusionTrackerWin` reads any non-zero `DWMWA_CLOAKED`
/// state on the HWND, so the pre-paint trick (cloak → SetWindowPos →
Ok(()) /// 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<()> { fn finalise_managers(&self) {
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)?;
if ANIMATION_MANAGER if ANIMATION_MANAGER
.lock() .lock()
.count_in_progress(MovementRenderDispatcher::PREFIX) .count_in_progress(MovementRenderDispatcher::PREFIX)
@@ -228,9 +240,202 @@ impl RenderDispatcher for MovementRenderDispatcher {
stackbar_manager::send_notification(); stackbar_manager::send_notification();
transparency_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(()) 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 { 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_APP;
use windows::Win32::Graphics::Dwm::DWM_CLOAKED_INHERITED; use windows::Win32::Graphics::Dwm::DWM_CLOAKED_INHERITED;
use windows::Win32::Graphics::Dwm::DWM_CLOAKED_SHELL; 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_BORDER_COLOR;
use windows::Win32::Graphics::Dwm::DWMWA_CLOAKED; use windows::Win32::Graphics::Dwm::DWMWA_CLOAKED;
use windows::Win32::Graphics::Dwm::DWMWA_COLOR_NONE; 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::DWMWCP_ROUND;
use windows::Win32::Graphics::Dwm::DWMWINDOWATTRIBUTE; use windows::Win32::Graphics::Dwm::DWMWINDOWATTRIBUTE;
use windows::Win32::Graphics::Dwm::DwmGetWindowAttribute; use windows::Win32::Graphics::Dwm::DwmGetWindowAttribute;
use windows::Win32::Graphics::Dwm::DwmRegisterThumbnail;
use windows::Win32::Graphics::Dwm::DwmSetWindowAttribute; 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::CreateSolidBrush;
use windows::Win32::Graphics::Gdi::EnumDisplayMonitors; use windows::Win32::Graphics::Gdi::EnumDisplayMonitors;
use windows::Win32::Graphics::Gdi::GetMonitorInfoW; 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_NOACTIVATE;
use windows::Win32::UI::WindowsAndMessaging::WS_EX_TOOLWINDOW; use windows::Win32::UI::WindowsAndMessaging::WS_EX_TOOLWINDOW;
use windows::Win32::UI::WindowsAndMessaging::WS_EX_TOPMOST; 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_POPUP;
use windows::Win32::UI::WindowsAndMessaging::WS_SYSMENU; use windows::Win32::UI::WindowsAndMessaging::WS_SYSMENU;
use windows::Win32::UI::WindowsAndMessaging::WindowFromPoint; 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> { pub fn create_hidden_window(name: PCWSTR, instance: isize) -> eyre::Result<isize> {
unsafe { unsafe {
CreateWindowExW( CreateWindowExW(

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "StaticConfig", "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", "type": "object",
"properties": { "properties": {
"animation": { "animation": {
@@ -778,6 +778,14 @@
"default": 60, "default": 60,
"minimum": 0 "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": { "style": {
"description": "Set the animation style", "description": "Set the animation style",
"anyOf": [ "anyOf": [