diff --git a/Cargo.lock b/Cargo.lock index 3d5f3a35..5c25ebe5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6208,6 +6208,7 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "windows-sys 0.61.2", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5ebf05c5..ab942c55 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -78,3 +78,11 @@ objc2-core-foundation = { version = "0.3", default-features = false, features = objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSNotification", "NSOperation", "NSString"] } # Used only by the debug font-restriction override; Cargo dependency cfg cannot test debug_assertions. objc2-web-kit = { version = "0.3", default-features = false, features = ["std", "WKWebViewConfiguration", "WKPreferences"] } + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Graphics_Gdi", + "Win32_UI_Shell", + "Win32_UI_WindowsAndMessaging", +] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bee40a88..ed795e66 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -19,6 +19,8 @@ mod theme_frame; #[cfg(target_os = "macos")] mod titlebar; mod window_groups; +#[cfg(any(windows, test))] +mod window_work_area; mod windows; use parking_lot::Mutex; @@ -70,6 +72,9 @@ pub fn run() { // Single instance first: a second launch focuses this one and opens its path argument. // Debug builds have a separate identifier per worktree. let app = app.plugin(tauri_plugin_single_instance::init(windows::on_second_instance)); + // Install before window-state restores maximized geometry during window creation. + #[cfg(windows)] + let app = app.plugin(window_work_area::init()); let app = app.plugin(tauri_plugin_opener::init()).plugin(tauri_plugin_dialog::init()); #[cfg(target_os = "linux")] let app = app.plugin(linux_window_state::plugin()); diff --git a/src-tauri/src/window_work_area.rs b/src-tauri/src/window_work_area.rs new file mode 100644 index 00000000..116a748a --- /dev/null +++ b/src-tauri/src/window_work_area.rs @@ -0,0 +1,208 @@ +//! Native maximize bounds for every Windows frameless window, including state restoration. + +#[cfg(windows)] +pub use native::init; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Rect { + left: i32, + top: i32, + right: i32, + bottom: i32, +} + +/// All coordinates are physical pixels in the selected monitor's coordinate space. +/// Auto-hide needs one exposed pixel, but an already reserved edge needs no extra inset. +fn bounds(monitor: Rect, work: Rect, auto_hide: [bool; 4], fullscreen: bool) -> Rect { + if fullscreen { + return monitor; + } + let [left, top, right, bottom] = auto_hide; + Rect { + left: work.left + i32::from(left && work.left == monitor.left), + top: work.top + i32::from(top && work.top == monitor.top), + right: work.right - i32::from(right && work.right == monitor.right), + bottom: work.bottom - i32::from(bottom && work.bottom == monitor.bottom), + } +} + +fn maximize_geometry(monitor: Rect, area: Rect) -> (i32, i32, i32, i32) { + (area.left - monitor.left, area.top - monitor.top, area.right - area.left, area.bottom - area.top) +} + +#[cfg(windows)] +mod native { + use super::{Rect, bounds, maximize_geometry}; + use windows_sys::Win32::{ + Foundation::{HWND, LPARAM, LRESULT, RECT, WPARAM}, + Graphics::Gdi::{ + GetMonitorInfoW, HMONITOR, MONITOR_DEFAULTTONEAREST, MONITORINFO, MonitorFromRect, MonitorFromWindow, + }, + UI::{ + Shell::{ + ABE_BOTTOM, ABE_LEFT, ABE_RIGHT, ABE_TOP, ABM_GETAUTOHIDEBAREX, APPBARDATA, DefSubclassProc, + RemoveWindowSubclass, SHAppBarMessage, SetWindowSubclass, + }, + WindowsAndMessaging::{ + GWL_STYLE, GetWindowLongPtrW, IsZoomed, MINMAXINFO, NCCALCSIZE_PARAMS, WM_GETMINMAXINFO, WM_NCCALCSIZE, + WM_NCDESTROY, WS_CAPTION, + }, + }, + }; + + const SUBCLASS: usize = 0x410; + + pub fn init() -> tauri::plugin::TauriPlugin { + tauri::plugin::Builder::new("window-work-area") + .on_window_ready(|window| { + if window.is_decorated().unwrap_or(true) { + return; + } + let install = || -> tauri::Result<()> { + let hwnd = window.hwnd()?.0 as usize; + window.run_on_main_thread(move || { + // Subclasses must be installed and removed on the owning GUI thread. + if unsafe { SetWindowSubclass(hwnd as HWND, Some(window_proc), SUBCLASS, 0) } == 0 { + tracing::warn!( + "cannot install window work area handler: {}", + std::io::Error::last_os_error() + ); + } + }) + }; + if let Err(error) = install() { + tracing::warn!("cannot install window work area handler: {error}"); + } + }) + .build() + } + + fn rect(rect: RECT) -> Rect { + Rect { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom } + } + + fn native_rect(rect: Rect) -> RECT { + RECT { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom } + } + + unsafe fn monitor_bounds(monitor: HMONITOR, fullscreen: bool) -> Option<(Rect, Rect)> { + let mut info = MONITORINFO { cbSize: size_of::() as u32, ..Default::default() }; + if unsafe { GetMonitorInfoW(monitor, &mut info) } == 0 { + return None; + } + let mut auto_hide = [false; 4]; + if !fullscreen { + for (index, edge) in [ABE_LEFT, ABE_TOP, ABE_RIGHT, ABE_BOTTOM].into_iter().enumerate() { + let mut data = APPBARDATA { + cbSize: size_of::() as u32, + uEdge: edge, + rc: info.rcMonitor, + ..Default::default() + }; + auto_hide[index] = unsafe { SHAppBarMessage(ABM_GETAUTOHIDEBAREX, &mut data) } != 0; + } + } + let monitor = rect(info.rcMonitor); + Some((monitor, bounds(monitor, rect(info.rcWork), auto_hide, fullscreen))) + } + + unsafe extern "system" fn window_proc( + hwnd: HWND, + message: u32, + wparam: WPARAM, + lparam: LPARAM, + _id: usize, + _data: usize, + ) -> LRESULT { + // Tao keeps WS_CAPTION on frameless windows and removes it for native fullscreen. + // Read native styles here: a Tauri state query from its window procedure can deadlock. + let fullscreen = unsafe { GetWindowLongPtrW(hwnd, GWL_STYLE) } as u32 & WS_CAPTION == 0; + match message { + WM_GETMINMAXINFO if !fullscreen => { + // Keep Tao's min/max tracking constraints, then set the outer maximize bounds. + let result = unsafe { DefSubclassProc(hwnd, message, wparam, lparam) }; + let monitor = unsafe { MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST) }; + if let Some((monitor, area)) = unsafe { monitor_bounds(monitor, false) } { + let (x, y, width, height) = maximize_geometry(monitor, area); + let info = unsafe { &mut *(lparam as *mut MINMAXINFO) }; + info.ptMaxPosition.x = x; + info.ptMaxPosition.y = y; + info.ptMaxSize.x = width; + info.ptMaxSize.y = height; + } + result + } + WM_NCCALCSIZE if wparam != 0 && (fullscreen || unsafe { IsZoomed(hwnd) } != 0) => { + let params = unsafe { &mut *(lparam as *mut NCCALCSIZE_PARAMS) }; + // Use the proposed rectangle: a move/snap/DPI change can target another monitor. + let monitor = unsafe { MonitorFromRect(¶ms.rgrc[0], MONITOR_DEFAULTTONEAREST) }; + if let Some((_, area)) = unsafe { monitor_bounds(monitor, fullscreen) } { + // Replace Tao's client-only clamp and global auto-hide query. Fullscreen + // must retain the entire display, even when entered from a maximized window. + params.rgrc[0] = native_rect(area); + 0 + } else { + unsafe { DefSubclassProc(hwnd, message, wparam, lparam) } + } + } + WM_NCDESTROY => { + unsafe { RemoveWindowSubclass(hwnd, Some(window_proc), SUBCLASS) }; + unsafe { DefSubclassProc(hwnd, message, wparam, lparam) } + } + _ => unsafe { DefSubclassProc(hwnd, message, wparam, lparam) }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn r(left: i32, top: i32, right: i32, bottom: i32) -> Rect { + Rect { left, top, right, bottom } + } + + #[test] + fn taskbars_on_every_edge_and_secondary_displays() { + for (monitor, work, expected) in [ + // Reported case: maximized window must leave the bottom taskbar visible. + (r(0, 0, 1920, 1080), r(0, 0, 1920, 1040), (0, 0, 1920, 1040)), + (r(0, 0, 1920, 1080), r(48, 0, 1920, 1080), (48, 0, 1872, 1080)), + (r(0, 0, 1920, 1080), r(0, 40, 1920, 1080), (0, 40, 1920, 1040)), + (r(0, 0, 1920, 1080), r(0, 0, 1872, 1080), (0, 0, 1872, 1080)), + // Negative origin, top taskbar, 150% display beside a 100% display. + (r(-2560, -1440, 0, 0), r(-2560, -1380, 0, 0), (0, 60, 2560, 1380)), + // 250% display: physical bounds must not be scaled with the previous monitor's DPI. + (r(1920, 0, 5760, 2160), r(1920, 0, 5760, 2060), (0, 0, 3840, 2060)), + ] { + let area = bounds(monitor, work, [false; 4], false); + assert_eq!(area, work); + assert_eq!(maximize_geometry(monitor, area), expected); + } + } + + #[test] + fn auto_hide_leaves_a_physical_pixel_only_on_its_own_monitor_edge() { + let monitor = r(-1920, 0, 0, 1080); + for (edges, expected) in [ + ([true, false, false, false], r(-1919, 0, 0, 1080)), + ([false, true, false, false], r(-1920, 1, 0, 1080)), + ([false, false, true, false], r(-1920, 0, -1, 1080)), + ([false, false, false, true], r(-1920, 0, 0, 1079)), + ([false; 4], monitor), + ([true; 4], r(-1919, 1, -1, 1079)), + ] { + assert_eq!(bounds(monitor, monitor, edges, false), expected); + } + let reserved = r(-1920, 0, 0, 1040); + assert_eq!(bounds(monitor, reserved, [false, false, false, true], false), reserved); + } + + #[test] + fn fullscreen_covers_taskbars_including_when_entered_from_maximized() { + let monitor = r(1920, -100, 5760, 2060); + let work = r(1980, -100, 5760, 1960); + assert_eq!(bounds(monitor, work, [true; 4], true), monitor); + assert_eq!(bounds(monitor, work, [false; 4], false), work); + } +} diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index f905ed51..1599e060 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -841,9 +841,18 @@ fn fit_settings_window(window: &WebviewWindow, parent: &WebviewWindow) -> tauri: /// scale and its position is given in physical pixels. #[cfg(not(target_os = "macos"))] pub(super) fn place_settings_window(window: &WebviewWindow, parent: &WebviewWindow) -> tauri::Result<()> { + // Placement setters leave maximized mode. Fit the restored frame, then reapply the saved state. + // A restored fullscreen window already covers its display and must not be fitted to the work area. + if window.is_fullscreen()? { + return Ok(()); + } let Some(monitor) = parent.current_monitor()?.or(parent.primary_monitor()?) else { return Ok(()); }; + let maximized = window.is_maximized()?; + if maximized { + window.unmaximize()?; + } let scale = monitor.scale_factor(); let area = monitor.work_area(); let origin = area.position.to_logical::(scale); @@ -872,6 +881,9 @@ pub(super) fn place_settings_window(window: &WebviewWindow, parent: &WebviewWind )))?; window.set_size(tauri::LogicalSize::new(width, height))?; } + if maximized { + window.maximize()?; + } Ok(()) }