mirror of
https://github.com/LGUG2Z/komorebi.git
synced 2026-05-05 02:14:22 +02:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2e5dbfcae | ||
|
|
937b28a7d9 |
16
Cargo.lock
generated
16
Cargo.lock
generated
@@ -3168,7 +3168,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
|
||||
|
||||
[[package]]
|
||||
name = "komorebi"
|
||||
version = "0.1.41"
|
||||
version = "0.1.42"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bitflags 2.11.1",
|
||||
@@ -3219,7 +3219,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "komorebi-bar"
|
||||
version = "0.1.41"
|
||||
version = "0.1.42"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
@@ -3263,7 +3263,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "komorebi-client"
|
||||
version = "0.1.41"
|
||||
version = "0.1.42"
|
||||
dependencies = [
|
||||
"komorebi",
|
||||
"serde_json_lenient",
|
||||
@@ -3272,7 +3272,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "komorebi-gui"
|
||||
version = "0.1.41"
|
||||
version = "0.1.42"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui_extras",
|
||||
@@ -3285,7 +3285,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "komorebi-layouts"
|
||||
version = "0.1.41"
|
||||
version = "0.1.42"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"color-eyre",
|
||||
@@ -3311,7 +3311,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "komorebi-themes"
|
||||
version = "0.1.41"
|
||||
version = "0.1.42"
|
||||
dependencies = [
|
||||
"base16-egui-themes",
|
||||
"catppuccin-egui",
|
||||
@@ -3326,7 +3326,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "komorebic"
|
||||
version = "0.1.41"
|
||||
version = "0.1.42"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
@@ -3354,7 +3354,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "komorebic-no-console"
|
||||
version = "0.1.41"
|
||||
version = "0.1.42"
|
||||
|
||||
[[package]]
|
||||
name = "konst"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)))]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "komorebi-layouts"
|
||||
version = "0.1.41"
|
||||
version = "0.1.42"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "komorebi-themes"
|
||||
version = "0.1.41"
|
||||
version = "0.1.42"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -86,6 +86,7 @@ impl AnimationEngine {
|
||||
{
|
||||
// cancel animation
|
||||
ANIMATION_MANAGER.lock().cancel(animation_key.as_str());
|
||||
render_dispatcher.cleanup_on_cancel();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
|
||||
363
komorebi/src/animation/ghost.rs
Normal file
363
komorebi/src/animation/ghost.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
10
schema.json
10
schema.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "StaticConfig",
|
||||
"description": "The `komorebi.json` static configuration file reference for `v0.1.41`",
|
||||
"description": "The `komorebi.json` static configuration file reference for `v0.1.42`",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"animation": {
|
||||
@@ -778,6 +778,14 @@
|
||||
"default": 60,
|
||||
"minimum": 0
|
||||
},
|
||||
"ghost_movement": {
|
||||
"description": "Render movement animations on a GPU-composited ghost surface (recommended).\nWhen false, falls back to the legacy per-frame MoveWindow path.",
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
],
|
||||
"default": true
|
||||
},
|
||||
"style": {
|
||||
"description": "Set the animation style",
|
||||
"anyOf": [
|
||||
|
||||
Reference in New Issue
Block a user