From 073acd295bf9e7584933853a9b9dbf74b6aa82b0 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 15 Oct 2026 10:27:21 -0400 Subject: [PATCH] Windows: resize border from the first frame, DPI-correct window moves (#1396) * Windows: resize border from the first frame, DPI-correct window moves (#1381) - The egui resize zones were laid out after the start page's early return, so a launch without a design had no resize edges at all. They now run on every page. - Windows: the main window's procedure answers WM_NCHITTEST with the resize edges and corners, Windows' own border width at the window's DPI (8 px at 100 %), so the OS shows its resize cursors and runs its own resize (snap included) from the first message; no egui zones there. - Windows: WM_GETDPISCALEDSIZE returns the size winit then applies (same logical size), so the rect the OS suggests while the title bar is dragged onto a monitor with another scale is the rect the window gets. - Zone sizes and hit test shared in titlebar.rs and unit-tested; Windows CI checks the hit test right after launch and a real edge drag. Co-Authored-By: Claude Opus 5.5 * Windows resize smoke: find the main window by its title Co-Authored-By: Claude Opus 5.5 * Windows resize smoke: probe the maximized window at the screen's edge Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .github/workflows/ci.yml | 5 +- AGENTS.md | 8 +- CLAUDE.md | 8 +- Cargo.lock | 1 + apps/solidcraft/Cargo.toml | 9 +- apps/solidcraft/src/main.rs | 11 ++ apps/solidcraft/src/native_frame.rs | 175 ++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 12 +- crates/ui-egui/src/titlebar.rs | 168 +++++++++++++++++++++-- tools/windows-resize-smoke.ps1 | 198 ++++++++++++++++++++++++++++ 10 files changed, 571 insertions(+), 24 deletions(-) create mode 100644 apps/solidcraft/src/native_frame.rs create mode 100644 tools/windows-resize-smoke.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 135e5bd5..3c2b1607 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -365,12 +365,15 @@ jobs: with: key: windows-startup - name: Backend order and probing on Windows - run: cargo test -p solidcraft --profile ci --locked -- startup::tests native_crash::tests + run: cargo test -p solidcraft --profile ci --locked -- startup::tests native_crash::tests native_frame::tests - name: Build the app run: cargo build -p solidcraft --profile ci --locked - name: Start the app under different settings shell: pwsh run: tools/windows-startup-smoke.ps1 -Exe target/ci/solidcraft.exe + - name: Resize border from the first frame + shell: pwsh + run: tools/windows-resize-smoke.ps1 -Exe target/ci/solidcraft.exe # Preserve the existing check name for branch protection, including docs-only PRs. ci: diff --git a/AGENTS.md b/AGENTS.md index dd5e3ea9..662c0f53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,11 +27,13 @@ new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pa ## Never crash People trust SolidCraft with their designs; a crash loses their work. **This outranks feature work.** - **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, - `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The two exceptions, in the app crate + `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The three exceptions, in the app crate (which denies it instead), have no safe API: two functions in `apps/solidcraft/src/open_documents.rs` (macOS hands Finder files only to an Objective-C - method) and the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that - records crashes inside native code such as graphics drivers). + method), the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that + records crashes inside native code such as graphics drivers) and the Windows-only + `apps/solidcraft/src/native_frame.rs` (the window procedure that answers the resize-border hit + test of the window without OS decorations). - **Errors are `Result`** through the crate's error type and `?`. An unfinished feature returns an error ("not supported yet: …"); it never panics. - **Input-derived numbers are hostile** (command parameters, design files, control-channel diff --git a/CLAUDE.md b/CLAUDE.md index 005b8219..60b87023 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,11 +27,13 @@ new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pa ## Never crash People trust SolidCraft with their designs; a crash loses their work. **This outranks feature work.** - **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, - `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The two exceptions, in the app crate + `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The three exceptions, in the app crate (which denies it instead), have no safe API: two functions in `apps/solidcraft/src/open_documents.rs` (macOS hands Finder files only to an Objective-C - method) and the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that - records crashes inside native code such as graphics drivers). + method), the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that + records crashes inside native code such as graphics drivers) and the Windows-only + `apps/solidcraft/src/native_frame.rs` (the window procedure that answers the resize-border hit + test of the window without OS decorations). - **Errors are `Result`** through the crate's error type and `?`. An unfinished feature returns an error ("not supported yet: …"); it never panics. - **Input-derived numbers are hostile** (command parameters, design files, control-channel diff --git a/Cargo.lock b/Cargo.lock index ed8150d9..d5df2981 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3459,6 +3459,7 @@ dependencies = [ "objc2-foundation 0.3.2", "pollster 0.4.0", "quick-xml", + "raw-window-handle", "rfd", "ron", "rustls", diff --git a/apps/solidcraft/Cargo.toml b/apps/solidcraft/Cargo.toml index ab7835c1..d870ae03 100644 --- a/apps/solidcraft/Cargo.toml +++ b/apps/solidcraft/Cargo.toml @@ -8,8 +8,8 @@ publish = false default-run = "solidcraft" # The workspace lints, except unsafe_code: denied, not forbidden, so that src/open_documents.rs -# (macOS file-open events) and src/native_crash.rs (Windows exception filter) can allow it: neither -# has a safe API. Keep the rest in sync with [workspace.lints]. +# (macOS file-open events), src/native_crash.rs (Windows exception filter) and src/native_frame.rs +# (Windows resize border) can allow it: none has a safe API. Keep the rest in sync with [workspace.lints]. [lints.rust] unsafe_code = "deny" @@ -42,7 +42,10 @@ quick-xml = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] hidapi = { version = "2.6.7", default-features = false, features = ["windows-native"] } +# The main window's handle for src/native_frame.rs (already in the tree through eframe and winit). +raw-window-handle = "0.6" # Native crash capture (src/native_crash.rs): the exception filter, the faulting module, a minidump. +# The window's resize border and DPI size (src/native_frame.rs): the window procedure, hit test, DPI. windows-sys = { version = "0.61", features = [ "Win32_Foundation", "Win32_Security", @@ -53,6 +56,8 @@ windows-sys = { version = "0.61", features = [ "Win32_System_LibraryLoader", "Win32_System_Memory", "Win32_System_Threading", + "Win32_UI_HiDpi", + "Win32_UI_WindowsAndMessaging", ] } [target.'cfg(target_os = "macos")'.dependencies] diff --git a/apps/solidcraft/src/main.rs b/apps/solidcraft/src/main.rs index a687dade..21fa6e24 100644 --- a/apps/solidcraft/src/main.rs +++ b/apps/solidcraft/src/main.rs @@ -21,6 +21,8 @@ mod app_icon; mod control_server; #[cfg(target_os = "windows")] mod native_crash; +#[cfg(target_os = "windows")] +mod native_frame; #[cfg(target_os = "macos")] mod open_documents; #[cfg(any(target_os = "windows", target_os = "linux"))] @@ -465,6 +467,15 @@ fn main() -> eframe::Result { let mut app = SolveApp::new(Session::default(), services(cc)); app.apply_off_thread = Some(solidcraft_ui_egui::dialogs::APPLY_AT_ONCE); app.custom_titlebar = CUSTOM_TITLEBAR; + // Windows: the OS hit test offers the resize border, at the window's DPI, from the + // first message (#1381). + #[cfg(target_os = "windows")] + { + app.native_resize_border = CUSTOM_TITLEBAR && native_frame::install(cc); + if CUSTOM_TITLEBAR && !app.native_resize_border { + eprintln!("SolidCraft window: no native resize border; egui edge zones instead"); + } + } app.integrated_titlebar = cfg!(target_os = "macos"); app.cpu_meshing = safe_mode; if let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))).filter(|_| !safe_mode) { diff --git a/apps/solidcraft/src/native_frame.rs b/apps/solidcraft/src/native_frame.rs new file mode 100644 index 00000000..9bac6d4e --- /dev/null +++ b/apps/solidcraft/src/native_frame.rs @@ -0,0 +1,175 @@ +//! Windows: the resize border of the window without OS decorations (#1381). +//! +//! The window keeps its native thick frame (`WS_THICKFRAME`, so Aero Snap, Win+arrows and the +//! OS's own move and size loops work) but winit draws no frame: its `WM_NCCALCSIZE` makes the +//! whole window client area, and every point then hit-tests as `HTCLIENT`. This module puts the +//! resize border back by answering `WM_NCHITTEST` itself: within Windows' resize-border width +//! for the window's current DPI from each edge (`solidcraft_ui_egui::titlebar::resize_hit`; +//! 8 px at 100 %, corners win), it returns `HTLEFT`, `HTTOPRIGHT`, … So from the first message +//! the OS shows its resize cursors and runs its own resize, whatever egui has laid out, whatever +//! the scale; nothing waits for a first frame or a first size change. +//! +//! It also answers `WM_GETDPISCALEDSIZE` with the size the window will really have at the new +//! DPI (the same logical size, as winit computes it), so the rect Windows suggests while the +//! title bar is dragged onto a monitor with another scale is the rect winit then applies: the +//! window rescales once at the crossing and stays under the pointer, instead of the OS's move +//! loop and winit disagreeing about a frame the window doesn't have. +//! +//! Replacing a window procedure has no safe API, hence the `unsafe_code` allowance on this +//! module (the app crate denies it; see `native_crash.rs` and `open_documents.rs` for the others). +//! The procedure is installed once, on the thread that owns the window, for the main window only, +//! and calls the previous one (winit's) for everything it doesn't answer. +#![allow(unsafe_code)] + +use std::sync::atomic::{AtomicIsize, Ordering}; + +use raw_window_handle::{HasWindowHandle, RawWindowHandle}; +use solidcraft_ui_egui::titlebar; +use windows_sys::Win32::Foundation::{HWND, LPARAM, LRESULT, RECT, SIZE, WPARAM}; +use windows_sys::Win32::UI::HiDpi::{GetDpiForWindow, GetSystemMetricsForDpi}; +use windows_sys::Win32::UI::WindowsAndMessaging::{ + CallWindowProcW, GWL_STYLE, GWLP_WNDPROC, GetClientRect, GetWindowLongPtrW, GetWindowRect, HTBOTTOM, HTBOTTOMLEFT, HTBOTTOMRIGHT, HTLEFT, + HTRIGHT, HTTOP, HTTOPLEFT, HTTOPRIGHT, IsIconic, IsZoomed, SM_CXPADDEDBORDER, SM_CXSIZEFRAME, SetWindowLongPtrW, WM_GETDPISCALEDSIZE, + WM_NCDESTROY, WM_NCHITTEST, WNDPROC, WS_THICKFRAME, +}; + +/// The window procedure ours replaced (winit's), as an address; 0 before [`install`]. +static PREVIOUS: AtomicIsize = AtomicIsize::new(0); + +/// Answer the main window's hit test and DPI-size questions from now on. Call it once, on the +/// thread that created the window (eframe's app creator). `false` if it isn't a Win32 window. +pub fn install(window: &impl HasWindowHandle) -> bool { + let Ok(handle) = window.window_handle() else { return false }; + let RawWindowHandle::Win32(w) = handle.as_raw() else { return false }; + let hwnd = w.hwnd.get() as HWND; + if PREVIOUS.load(Ordering::SeqCst) != 0 { + return false; + } + // SAFETY: `hwnd` is the live window eframe just created on this thread; `frame_proc` has the + // WNDPROC signature and stays valid for the life of the process. SetWindowLongPtrW returns + // the previous procedure, which `frame_proc` calls for every other message. + let previous = unsafe { SetWindowLongPtrW(hwnd, GWLP_WNDPROC, frame_proc as *const () as isize) }; + if previous == 0 { + return false; + } + PREVIOUS.store(previous, Ordering::SeqCst); + true +} + +/// The procedure ours replaced. +fn previous() -> WNDPROC { + let p = PREVIOUS.load(Ordering::SeqCst); + // SAFETY: a nonzero value is the WNDPROC that SetWindowLongPtrW returned in `install`. + (p != 0).then(|| unsafe { std::mem::transmute:: LRESULT>(p) }) +} + +unsafe extern "system" fn frame_proc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT { + let prev = previous(); + // SAFETY: the arguments are the message as Windows delivered it to this window. + let call_previous = || unsafe { CallWindowProcW(prev, hwnd, msg, wparam, lparam) }; + match msg { + WM_NCHITTEST => { + let default = call_previous(); + // Screen coordinates, signed (monitors left of or above the primary one). + let (x, y) = (i32::from(lparam as u16 as i16), i32::from((lparam >> 16) as u16 as i16)); + // The border, else what winit and the system say (`HTCLIENT` everywhere else). + border_hit(hwnd, x, y).map_or(default, |hit| hit as LRESULT) + } + WM_GETDPISCALEDSIZE => { + let new_dpi = wparam as u32; + // SAFETY: for this message lParam points to a SIZE the system reads back. + match (resizable(hwnd), client_size(hwnd), unsafe { (lparam as *mut SIZE).as_mut() }) { + (true, Some(size), Some(out)) => { + // SAFETY: a window handle the system passed in. + let old_dpi = unsafe { GetDpiForWindow(hwnd) }; + match titlebar::dpi_scaled_size(size, old_dpi, new_dpi) { + Some((cx, cy)) => { + *out = SIZE { cx, cy }; + 1 + } + None => call_previous(), + } + } + _ => call_previous(), + } + } + WM_NCDESTROY => { + // Hand the window back to winit's procedure for its last message. + if prev.is_some() { + // SAFETY: restores the procedure this window had before `install`. + unsafe { SetWindowLongPtrW(hwnd, GWLP_WNDPROC, PREVIOUS.load(Ordering::SeqCst)) }; + } + call_previous() + } + _ => call_previous(), + } +} + +/// The window can be resized by its border now: it has the thick frame (not fullscreen, which +/// drops it) and is neither maximized nor minimized. +fn resizable(hwnd: HWND) -> bool { + // SAFETY: plain queries on a window handle the system passed in. + unsafe { + let style = GetWindowLongPtrW(hwnd, GWL_STYLE) as u32; + style & WS_THICKFRAME != 0 && IsZoomed(hwnd) == 0 && IsIconic(hwnd) == 0 + } +} + +/// The client area's size in pixels (the whole window: winit gives it no frame). +fn client_size(hwnd: HWND) -> Option<(i32, i32)> { + let mut r = RECT { left: 0, top: 0, right: 0, bottom: 0 }; + // SAFETY: `r` is a valid RECT to fill. + (unsafe { GetClientRect(hwnd, &mut r) } != 0).then_some((r.right - r.left, r.bottom - r.top)) +} + +/// Windows' resize border at the window's DPI: the sizing frame plus its padding, as Windows +/// itself sizes it, or 8 px scaled when the system has no answer. +fn border_px(hwnd: HWND) -> f32 { + // SAFETY: plain queries. + let (dpi, metric) = unsafe { + let dpi = GetDpiForWindow(hwnd); + (dpi, GetSystemMetricsForDpi(SM_CXSIZEFRAME, dpi) + GetSystemMetricsForDpi(SM_CXPADDEDBORDER, dpi)) + }; + let scale = if dpi > 0 { dpi as f32 / 96.0 } else { 1.0 }; + if metric > 0 { metric as f32 } else { titlebar::resize_border_px(scale) } +} + +/// The `HT*` resize code at screen point (`x`, `y`), if it is on the border. +fn border_hit(hwnd: HWND, x: i32, y: i32) -> Option { + if !resizable(hwnd) { + return None; + } + let mut r = RECT { left: 0, top: 0, right: 0, bottom: 0 }; + // SAFETY: `r` is a valid RECT to fill. + if unsafe { GetWindowRect(hwnd, &mut r) } == 0 { + return None; + } + let w = egui::Rect::from_min_max(egui::pos2(r.left as f32, r.top as f32), egui::pos2(r.right as f32, r.bottom as f32)); + titlebar::resize_hit(w, egui::pos2(x as f32, y as f32), border_px(hwnd)).map(hit_code) +} + +fn hit_code(d: egui::ResizeDirection) -> u32 { + use egui::ResizeDirection::*; + match d { + North => HTTOP, + South => HTBOTTOM, + West => HTLEFT, + East => HTRIGHT, + NorthWest => HTTOPLEFT, + NorthEast => HTTOPRIGHT, + SouthWest => HTBOTTOMLEFT, + SouthEast => HTBOTTOMRIGHT, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_direction_has_its_own_hit_code() { + use egui::ResizeDirection::*; + let codes: Vec = [North, South, West, East, NorthWest, NorthEast, SouthWest, SouthEast].into_iter().map(hit_code).collect(); + assert_eq!(codes, vec![HTTOP, HTBOTTOM, HTLEFT, HTRIGHT, HTTOPLEFT, HTTOPRIGHT, HTBOTTOMLEFT, HTBOTTOMRIGHT]); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6cea2e4d..2f126f04 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -322,6 +322,9 @@ pub struct SolveApp { pub autosave_minutes: f64, /// The application bar is the window's title bar (Windows, Linux: no OS decorations). pub custom_titlebar: bool, + /// The OS hit test already offers the resize border ([`titlebar`]; the Windows desktop app), + /// so no egui resize zones are laid out. + pub native_resize_border: bool, /// macOS: the traffic lights sit over the application bar's left end. pub integrated_titlebar: bool, /// Build bodies' display meshes on the CPU even where the GPU could (safe mode). Read by @@ -408,6 +411,7 @@ impl SolveApp { big_operation: false, autosave_minutes: 5.0, custom_titlebar: false, + native_resize_border: false, integrated_titlebar: false, cpu_meshing: false, frame_ms: 0.0, @@ -1214,6 +1218,11 @@ impl SolveApp { issue_report::CadInput::block(self, ui, &mut report_input); self.cad_ui(ui, &mut report_input); drop(report_input); + // After everything else, on every page (the start page too, which returns early from + // `cad_ui`): the window's edges resize it (#1381). + if self.custom_titlebar && !self.native_resize_border { + titlebar::resize_zones(ui); + } if self.file_picker.is_none() { issue_report::show(self, ui.ctx()); } @@ -1279,9 +1288,6 @@ impl SolveApp { prefs::show_reset_notice(self, ui.ctx()); context_menu::show(self, ui.ctx()); delete::show(self, ui.ctx()); - if self.custom_titlebar { - titlebar::resize_zones(ui); - } shortcut_box::show(self, ui.ctx()); prefs::show(self, ui.ctx()); toolbar_custom::window(self, ui.ctx()); diff --git a/crates/ui-egui/src/titlebar.rs b/crates/ui-egui/src/titlebar.rs index b8807a2f..7d2bdebd 100644 --- a/crates/ui-egui/src/titlebar.rs +++ b/crates/ui-egui/src/titlebar.rs @@ -3,13 +3,19 @@ //! double-click there maximizes or restores it, caption buttons at the far right minimize, //! maximize/restore and close, and thin invisible zones along the window's edges resize it. //! Dragging uses the OS's own move loop, so Windows snapping (to the screen edges and corners) -//! works as with a native title bar. macOS keeps its traffic lights over the bar +//! works as with a native title bar. +//! +//! The resize zones are as wide as Windows' own resize border at the window's scale +//! ([`resize_border_px`]), corners win where they overlap the edges ([`resize_hit`]). On Windows +//! the desktop app answers the system's hit test with them (`apps/solidcraft/src/native_frame.rs`), +//! so the OS shows its resize cursors and resizes from the first frame; elsewhere egui zones above +//! everything else start an OS resize ([`resize_zones`]). macOS keeps its traffic lights over the bar //! ([`SolveApp::integrated_titlebar`]); the web build has neither. //! //! Adapted from VectorCraft's `crates/ui-egui/src/titlebar.rs` (MIT OR Apache-2.0). use crate::icons::glyphs::paint_glyph; -use egui::{Color32, CursorIcon, Id, LayerId, Order, PointerButton, Rect, ResizeDirection, Sense, Stroke, Ui, ViewportCommand, pos2, vec2}; +use egui::{Color32, CursorIcon, Id, LayerId, Order, PointerButton, Pos2, Rect, ResizeDirection, Sense, Stroke, Ui, ViewportCommand, pos2, vec2}; use crate::SolveApp; use crate::theme::Tokens; @@ -20,9 +26,8 @@ pub const BUTTON_WIDTH: f32 = 46.0; pub const WIDTH: f32 = 3.0 * BUTTON_WIDTH; /// Room for the macOS traffic lights at the bar's left end. pub const TRAFFIC_LIGHTS: f32 = 76.0; -/// Resize zone thickness along an edge, and the side of a corner zone. -const EDGE: f32 = 5.0; -const CORNER: f32 = 12.0; +/// Windows' resize border at 100 % (96 DPI): `SM_CXSIZEFRAME` + `SM_CXPADDEDBORDER`, 4 + 4 px. +pub const RESIZE_BORDER_AT_96_DPI: f32 = 8.0; /// The Close button's hover colour (as on Windows). const CLOSE_HOVER: Color32 = Color32::from_rgb(196, 43, 28); @@ -128,11 +133,23 @@ pub fn caption_buttons(app: &mut SolveApp, ui: &mut Ui, bar: Rect) { let _ = window_action(app, &ui.ctx().clone(), action); } -/// The edge and corner zones of a window with content rect `w`: (zone, direction, cursor). -/// Corners come last so they win where they overlap the edges. -fn zones(w: Rect) -> [(Rect, ResizeDirection, CursorIcon); 8] { +/// The resize border in physical pixels at `scale` (1.0 = 96 DPI): Windows' default, scaled and +/// rounded, never less than a pixel. +pub fn resize_border_px(scale: f32) -> f32 { + let s = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 }; + (RESIZE_BORDER_AT_96_DPI * s).round().max(1.0) +} + +/// The side of a corner zone for an edge `edge` wide: twice the edge, so a corner is easy to hit. +pub fn corner_size(edge: f32) -> f32 { + 2.0 * edge +} + +/// The edge and corner zones of a window with rect `w`, edges `edge` wide: (zone, direction, +/// cursor). Corners come last so they win where they overlap the edges. +pub fn zones(w: Rect, edge: f32) -> [(Rect, ResizeDirection, CursorIcon); 8] { use ResizeDirection::*; - let (e, c) = (EDGE, CORNER); + let (e, c) = (edge, corner_size(edge)); [ (Rect::from_min_max(w.left_top(), pos2(w.right(), w.top() + e)), North, CursorIcon::ResizeVertical), (Rect::from_min_max(pos2(w.left(), w.bottom() - e), w.right_bottom()), South, CursorIcon::ResizeVertical), @@ -145,6 +162,28 @@ fn zones(w: Rect) -> [(Rect, ResizeDirection, CursorIcon); 8] { ] } +/// The resize direction at `p` in a window with rect `w` and edges `edge` wide, `None` away +/// from the edges and outside the window. A point is in the window when `min <= p < max` (pixel +/// rects: the right and bottom pixels are at `max - 1`). Corners win over edges. +pub fn resize_hit(w: Rect, p: Pos2, edge: f32) -> Option { + if !(w.is_positive() && edge > 0.0 && p.x >= w.left() && p.x < w.right() && p.y >= w.top() && p.y < w.bottom()) { + return None; + } + zones(w, edge).into_iter().rev().find(|(r, ..)| p.x >= r.left() && p.x < r.right() && p.y >= r.top() && p.y < r.bottom()).map(|(_, d, _)| d) +} + +/// The window's new physical size when it moves from `old_dpi` to `new_dpi` (Windows' +/// `WM_GETDPISCALEDSIZE`): the same logical size, rounded as winit rounds the size it then +/// applies, so the rect the OS suggests mid-move is exactly the rect the window gets. +pub fn dpi_scaled_size(size: (i32, i32), old_dpi: u32, new_dpi: u32) -> Option<(i32, i32)> { + if old_dpi == 0 || new_dpi == 0 || size.0 <= 0 || size.1 <= 0 { + return None; + } + let (old, new) = (f64::from(old_dpi) / 96.0, f64::from(new_dpi) / 96.0); + let scale = |v: i32| (f64::from(v) / old * new).round().clamp(1.0, f64::from(i32::MAX)) as i32; + Some((scale(size.0), scale(size.1))) +} + /// Invisible resize zones along the window's edges, above everything else; a press in one /// starts an OS resize in that direction. None while the window is maximized or fullscreen. pub fn resize_zones(ui: &mut Ui) { @@ -153,9 +192,11 @@ pub fn resize_zones(ui: &mut Ui) { return; } let w = ctx.content_rect(); + let ppp = ctx.pixels_per_point(); + let edge = resize_border_px(ctx.input(|i| i.viewport().native_pixels_per_point).unwrap_or(ppp)) / ppp; let layer = LayerId::new(Order::Foreground, Id::new("titlebar-resize")); let zui = ui.new_child(egui::UiBuilder::new().layer_id(layer).max_rect(w)); - for (i, (r, dir, cursor)) in zones(w).into_iter().enumerate() { + for (i, (r, dir, cursor)) in zones(w, edge).into_iter().enumerate() { let resp = zui.interact(r, Id::new(("titlebar-resize", i)), Sense::drag()).on_hover_cursor(cursor); if resp.drag_started_by(PointerButton::Primary) { ctx.send_viewport_cmd(ViewportCommand::BeginResize(dir)); @@ -177,6 +218,8 @@ mod tests { width: f32, time: f64, maximized: bool, + /// Lay out the whole window (`SolveApp::ui`) rather than only the bar and the zones. + full: bool, } impl Win { @@ -184,7 +227,7 @@ mod tests { let ctx = egui::Context::default(); let mut app = SolveApp::new(Session::default(), Default::default()); app.custom_titlebar = true; - Self { ctx, app, width, time: 0.0, maximized: false } + Self { ctx, app, width, time: 0.0, maximized: false, full: false } } fn frame(&mut self, events: Vec) -> egui::FullOutput { @@ -198,8 +241,12 @@ mod tests { if let Some(v) = input.viewports.get_mut(&ViewportId::ROOT) { v.maximized = Some(self.maximized); } - let app = &mut self.app; + let (app, full) = (&mut self.app, self.full); let mut out = self.ctx.run_ui(input, |ui| { + if full { + app.ui(ui); + return; + } crate::toolbar::app_bar(app, ui); if app.custom_titlebar { resize_zones(ui); @@ -326,4 +373,101 @@ mod tests { let cmds = w.gesture(&[pos2(1.0, 300.0), pos2(8.0, 300.0)]); assert!(!cmds.iter().any(|c| matches!(c, ViewportCommand::BeginResize(_))), "{cmds:?}"); } + + #[test] + fn resize_border_is_windows_default_for_the_scale() { + for (dpi, px) in [(96.0, 8.0), (120.0, 10.0), (144.0, 12.0), (168.0, 14.0), (192.0, 16.0), (288.0, 24.0)] { + assert_eq!(resize_border_px(dpi / 96.0), px, "at {dpi} DPI"); + } + // Never 0 px, whatever the scale says. + for bad in [0.0, -1.0, f32::NAN, f32::INFINITY] { + assert_eq!(resize_border_px(bad), 8.0, "{bad}"); + } + assert_eq!(resize_border_px(0.01), 1.0); + assert_eq!(corner_size(resize_border_px(1.5)), 24.0); + } + + #[test] + fn hit_test_edges_corners_and_inside() { + use ResizeDirection::*; + // A window at (100, 50), 1000 x 700 px, at 150 %: 12 px edges, 24 px corners. + let w = Rect::from_min_size(pos2(100.0, 50.0), vec2(1000.0, 700.0)); + let e = resize_border_px(1.5); + let (l, t, r, b) = (100.0, 50.0, 1099.0, 749.0); // the outermost pixels + for (p, want) in [ + ((l, 400.0), Some(West)), + ((l + 11.0, 400.0), Some(West)), + ((l + 12.0, 400.0), None), + ((r, 400.0), Some(East)), + ((r - 11.0, 400.0), Some(East)), + ((r - 12.0, 400.0), None), + ((600.0, t), Some(North)), + ((600.0, t + 12.0), None), + ((600.0, b), Some(South)), + ((600.0, b - 11.0), Some(South)), + ((l, t), Some(NorthWest)), + ((r, t), Some(NorthEast)), + ((l, b), Some(SouthWest)), + ((r, b), Some(SouthEast)), + // Corners win over the edges, along both edges for the corner's length. + ((l + 23.0, t), Some(NorthWest)), + ((l, t + 23.0), Some(NorthWest)), + ((l + 24.0, t), Some(North)), + ((l, t + 24.0), Some(West)), + ((r - 23.0, b), Some(SouthEast)), + ((r, b - 23.0), Some(SouthEast)), + ((r - 23.0, t), Some(NorthEast)), + ((l, b - 23.0), Some(SouthWest)), + ((600.0, 400.0), None), + // Outside the window: not ours. + ((l - 1.0, 400.0), None), + ((r + 1.0, 400.0), None), + ((600.0, t - 1.0), None), + ((600.0, b + 1.0), None), + ] { + assert_eq!(resize_hit(w, pos2(p.0, p.1), e), want, "at {p:?}"); + } + assert_eq!(resize_hit(w, pos2(l, 400.0), 0.0), None); + assert_eq!(resize_hit(Rect::NOTHING, pos2(l, 400.0), e), None); + // Every DPI has a zone on every edge. + for dpi in [96.0, 120.0, 144.0, 192.0] { + let e = resize_border_px(dpi / 96.0); + for (p, d) in [((l, 400.0), West), ((r, 400.0), East), ((600.0, t), North), ((600.0, b), South)] { + assert_eq!(resize_hit(w, pos2(p.0, p.1), e), Some(d), "{d:?} at {dpi} DPI"); + } + } + } + + #[test] + fn dpi_change_keeps_the_logical_size() { + assert_eq!(dpi_scaled_size((1600, 1000), 96, 144), Some((2400, 1500))); + assert_eq!(dpi_scaled_size((2400, 1500), 144, 96), Some((1600, 1000))); + assert_eq!(dpi_scaled_size((1001, 777), 120, 192), Some((1602, 1243))); + assert_eq!(dpi_scaled_size((1001, 777), 96, 96), Some((1001, 777))); + assert_eq!(dpi_scaled_size((1001, 777), 0, 96), None); + assert_eq!(dpi_scaled_size((0, 777), 96, 144), None); + } + + #[test] + fn edges_resize_on_the_start_page() { + // At launch without a design the start page shows; its layout returned before the + // resize zones were laid out, so the edges did nothing until a design opened (#1381). + let mut w = Win::new(1200.0); + w.full = true; + let rev = w.app.session.revision; + w.app.home.show(rev); + w.frame(vec![]); + assert!(w.app.home.open); + let p = pos2(1199.0, 350.0); + let cmds = w.gesture(&[p, p + vec2(6.0, 0.0)]); + assert_eq!(cmds, vec![ViewportCommand::BeginResize(ResizeDirection::East)]); + let p = pos2(1.0, 699.0); + let cmds = w.gesture(&[p, p + vec2(-6.0, 6.0)]); + assert_eq!(cmds, vec![ViewportCommand::BeginResize(ResizeDirection::SouthWest)]); + // Where the OS hit test offers the border, egui lays out no zones of its own. + w.app.native_resize_border = true; + let p = pos2(1199.0, 350.0); + let cmds = w.gesture(&[p, p + vec2(6.0, 0.0)]); + assert!(!cmds.iter().any(|c| matches!(c, ViewportCommand::BeginResize(_))), "{cmds:?}"); + } } diff --git a/tools/windows-resize-smoke.ps1 b/tools/windows-resize-smoke.ps1 new file mode 100644 index 00000000..8722a345 --- /dev/null +++ b/tools/windows-resize-smoke.ps1 @@ -0,0 +1,198 @@ +# Start the Windows app and check its resize border (#1381): as soon as the window exists (before +# the first frame), Windows' hit test reports a resize edge or corner within the resize-border +# width of every side and the client area inside it; a maximized window has none; and a real +# press-and-drag on the bottom edge (SetCursorPos + mouse input) resizes the window. +# +# What this can't check on a CI runner (one monitor at 100 %): moving the window between monitors +# with different scales, and the resize border's width at other scales (unit tests cover the sizes). +# +# Usage: tools/windows-resize-smoke.ps1 -Exe target\ci\solidcraft.exe +param([Parameter(Mandatory = $true)][string]$Exe) +$ErrorActionPreference = 'Stop' + +Add-Type @' +using System; +using System.Runtime.InteropServices; +public static class Win { + [StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; } + [StructLayout(LayoutKind.Sequential)] public struct POINT { public int X, Y; } + [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r); + [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr after, int x, int y, int cx, int cy, uint flags); + [DllImport("user32.dll")] public static extern IntPtr SendMessageW(IntPtr h, uint msg, IntPtr w, IntPtr l); + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd); + [DllImport("user32.dll")] public static extern bool IsZoomed(IntPtr h); + [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h); + [DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y); + [DllImport("user32.dll")] public static extern bool GetCursorPos(out POINT p); + [DllImport("user32.dll")] public static extern void mouse_event(uint flags, int dx, int dy, uint data, UIntPtr extra); + [DllImport("user32.dll")] public static extern uint GetDpiForWindow(IntPtr h); + [DllImport("user32.dll")] public static extern int GetSystemMetrics(int i); + [DllImport("user32.dll")] public static extern int GetSystemMetricsForDpi(int i, uint dpi); + [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowTextW(IntPtr h, System.Text.StringBuilder s, int n); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetClassNameW(IntPtr h, System.Text.StringBuilder s, int n); + [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid); + public delegate bool EnumProc(IntPtr h, IntPtr l); + [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc f, IntPtr l); + public static string Text(IntPtr h) { var s = new System.Text.StringBuilder(256); GetWindowTextW(h, s, 256); return s.ToString(); } + public static string Class(IntPtr h) { var s = new System.Text.StringBuilder(256); GetClassNameW(h, s, 256); return s.ToString(); } + // The process's visible top-level window titled SolidCraft (the app's main window; the + // process has other top-level windows, such as the IME's). + public static IntPtr MainWindow(uint pid) { + IntPtr found = IntPtr.Zero; + EnumWindows((h, l) => { + uint p; GetWindowThreadProcessId(h, out p); + if (p == pid && IsWindowVisible(h) && Text(h).StartsWith("SolidCraft")) { found = h; return false; } + return true; + }, IntPtr.Zero); + return found; + } + public static int HitTest(IntPtr h, int x, int y) { + long l = ((long)(ushort)(short)y << 16) | (long)(ushort)(short)x; + return (int)SendMessageW(h, 0x0084 /* WM_NCHITTEST */, IntPtr.Zero, new IntPtr(l)); + } +} +'@ + +$HT = @{ 1 = 'client'; 10 = 'left'; 11 = 'right'; 12 = 'top'; 13 = 'top-left'; 14 = 'top-right'; 15 = 'bottom'; 16 = 'bottom-left'; 17 = 'bottom-right' } +$failures = New-Object System.Collections.Generic.List[string] +function Check([bool]$ok, [string]$what) { + if ($ok) { Write-Host "ok: $what" } else { Write-Host "FAIL: $what"; $failures.Add($what) } +} +function Rect($h) { $r = New-Object Win+RECT; [void][Win]::GetWindowRect($h, [ref]$r); $r } + +# Every side and corner, `in` px inside the window's outer pixel, and points just past the border. +function CheckBorder($h, [string]$when) { + $r = Rect $h + $dpi = [Win]::GetDpiForWindow($h) + # Windows' own resize border at this DPI (SM_CXSIZEFRAME + SM_CXPADDEDBORDER), as the app uses it. + $b = [Win]::GetSystemMetricsForDpi(32, $dpi) + [Win]::GetSystemMetricsForDpi(92, $dpi) + Check ($b -ge 1) "$when : a resize border of $b px" + $cx = [int](($r.Left + $r.Right) / 2); $cy = [int](($r.Top + $r.Bottom) / 2) + $l = $r.Left; $t = $r.Top; $rt = $r.Right - 1; $bt = $r.Bottom - 1 + Write-Host "$when : window $($r.Left),$($r.Top) - $($r.Right),$($r.Bottom) at $dpi DPI, border $b px" + $cases = @( + @($l, $cy, 10), @(($l + $b - 1), $cy, 10), @(($l + $b), $cy, 1), + @($rt, $cy, 11), @(($rt - $b + 1), $cy, 11), @(($rt - $b), $cy, 1), + @($cx, $t, 12), @($cx, ($t + $b - 1), 12), @($cx, ($t + $b), 1), + @($cx, $bt, 15), @($cx, ($bt - $b + 1), 15), @($cx, ($bt - $b), 1), + @($l, $t, 13), @($rt, $t, 14), @($l, $bt, 16), @($rt, $bt, 17), + @(($l + 2 * $b - 1), $t, 13), @($rt, ($bt - 2 * $b + 1), 17), + @($cx, $cy, 1) + ) + foreach ($c in $cases) { + $got = [Win]::HitTest($h, $c[0], $c[1]) + Check ($got -eq $c[2]) "$when hit test at ($($c[0]), $($c[1])): $($HT[$got]) ($got), want $($HT[$c[2]])" + } +} + +$port = 47901 +$root = Join-Path $env:RUNNER_TEMP 'resize-smoke' +Remove-Item -Recurse -Force $root -ErrorAction SilentlyContinue +$settings = Join-Path ([Environment]::GetFolderPath('ApplicationData')) 'SolidCraft\data' +Remove-Item -Recurse -Force $settings -ErrorAction SilentlyContinue +$local = Join-Path $root 'local' +$crashes = Join-Path $root 'crashes' +New-Item -ItemType Directory -Force $settings, $local, $crashes | Out-Null +$token = -join ((1..64) | ForEach-Object { '{0:x}' -f (Get-Random -Maximum 16) }) + +$psi = New-Object System.Diagnostics.ProcessStartInfo +$psi.FileName = (Resolve-Path $Exe).Path +foreach ($a in @('--sample', '--control', "$port")) { [void]$psi.ArgumentList.Add($a) } +$psi.UseShellExecute = $false +$psi.RedirectStandardError = $true +$psi.RedirectStandardOutput = $true +$psi.Environment['LOCALAPPDATA'] = $local +$psi.Environment['SOLIDCRAFT_CRASH_DIR'] = $crashes +$psi.Environment['SOLIDCRAFT_CONTROL_TOKEN'] = $token +$p = [System.Diagnostics.Process]::Start($psi) +$stderr = $p.StandardError.ReadToEndAsync() +$stdout = $p.StandardOutput.ReadToEndAsync() + +try { + # The window as soon as it shows (eframe creates it hidden and shows it after the first frame). + $h = [IntPtr]::Zero + $deadline = (Get-Date).AddSeconds(180) + while ($h -eq [IntPtr]::Zero -and (Get-Date) -lt $deadline -and -not $p.HasExited) { + $h = [Win]::MainWindow([uint32]$p.Id) + if ($h -eq [IntPtr]::Zero) { Start-Sleep -Milliseconds 20 } + } + if ($h -eq [IntPtr]::Zero) { throw "no window (exited: $($p.HasExited))" } + Write-Host "window: '$([Win]::Text($h))' class '$([Win]::Class($h))'" + CheckBorder $h 'first sight' + + $client = $null + while (-not $client -and (Get-Date) -lt $deadline -and -not $p.HasExited) { + try { $client = New-Object System.Net.Sockets.TcpClient('127.0.0.1', $port) } catch { Start-Sleep -Milliseconds 500 } + } + if (-not $client) { throw "no control channel (exited: $($p.HasExited))" } + $client.ReceiveTimeout = 120000 + $stream = $client.GetStream() + $writer = New-Object System.IO.StreamWriter($stream) + $writer.AutoFlush = $true + $reader = New-Object System.IO.StreamReader($stream) + $writer.WriteLine((@{ id = 1; token = $token; method = 'ui.screenshot'; params = @{ path = (Join-Path $root 'first.png') } } | ConvertTo-Json -Compress)) + Write-Host "first frame: $($reader.ReadLine())" + + # A known place and size that fits the runner's screen (physical px; the minimum is 960 x 600). + $sw = [Win]::GetSystemMetrics(0); $sh = [Win]::GetSystemMetrics(1) + Write-Host "screen: $sw x $sh" + [void][Win]::SetWindowPos($h, [IntPtr]::Zero, 20, 20, [Math]::Min(1000, $sw - 40), [Math]::Min(620, $sh - 140), 0x0014) # NOZORDER | NOACTIVATE + Start-Sleep -Milliseconds 500 + CheckBorder $h 'restored' + + # A real drag on the bottom edge, if this session takes mouse input. + $before = Rect $h + [void][Win]::SetForegroundWindow($h) + $x = [int](($before.Left + $before.Right) / 2); $y = $before.Bottom - 2 + [void][Win]::SetCursorPos($x, $y) + $pt = New-Object Win+POINT; [void][Win]::GetCursorPos([ref]$pt) + if ($pt.X -ne $x -or $pt.Y -ne $y) { + Write-Host "skipped: the drag (this session doesn't move the pointer: at $($pt.X),$($pt.Y))" + } else { + Start-Sleep -Milliseconds 300 + [Win]::mouse_event(0x0002, 0, 0, 0, [UIntPtr]::Zero) # LEFTDOWN + $grow = [Math]::Min(80, $sh - $y - 10) + for ($i = 1; $i -le 8; $i++) { + Start-Sleep -Milliseconds 40 + [void][Win]::SetCursorPos($x, $y + [int]($grow * $i / 8)) + [Win]::mouse_event(0x0001, 0, 0, 0, [UIntPtr]::Zero) # MOVE (relative 0: the position above) + } + Start-Sleep -Milliseconds 100 + [Win]::mouse_event(0x0004, 0, 0, 0, [UIntPtr]::Zero) # LEFTUP + Start-Sleep -Milliseconds 500 + $after = Rect $h + $dh = ($after.Bottom - $after.Top) - ($before.Bottom - $before.Top) + Check ($dh -ge $grow - 4 -and $after.Top -eq $before.Top) "dragging the bottom edge down $grow px resizes the window (height +$dh, top $($before.Top) -> $($after.Top))" + } + + # Maximized: no resize border, the whole window is client area. + [void][Win]::ShowWindow($h, 3) # SW_MAXIMIZE + Start-Sleep -Milliseconds 500 + if ([Win]::IsZoomed($h)) { + # The maximized window's rect overhangs the screen by the hidden frame (Windows' own + # frame answers there); the screen's left edge is where the pointer can be. + $r = Rect $h + $x = [Math]::Max($r.Left, 0) + 1 + $got = [Win]::HitTest($h, $x, [int](($r.Top + $r.Bottom) / 2)) + Check ($got -eq 1) "maximized ($($r.Left),$($r.Top) - $($r.Right),$($r.Bottom)), the screen's left edge is client area ($($HT[$got]))" + } else { + Check $false 'the window maximizes' + } + [void][Win]::ShowWindow($h, 9) # SW_RESTORE + Start-Sleep -Milliseconds 500 + CheckBorder $h 'restored again' + + $writer.WriteLine((@{ id = 2; method = 'app.quit'; params = @{ force = $true } } | ConvertTo-Json -Compress)) + Write-Host "quit: $($reader.ReadLine())" + $client.Close() + if (-not $p.WaitForExit(60000)) { throw 'did not exit after app.quit' } + if ($p.ExitCode -ne 0) { throw ('exit code 0x{0:x}' -f $p.ExitCode) } +} catch { + $failures.Add($_.Exception.Message) +} finally { + if (-not $p.HasExited) { $p.Kill(); $p.WaitForExit() } +} +Write-Host $stderr.Result +if ($failures.Count -gt 0) { Write-Error ("resize border: " + ($failures -join '; ')) } +Write-Host 'PASS: resize border'