* 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 <[email protected]> * Windows resize smoke: find the main window by its title Co-Authored-By: Claude Opus 5.5 <[email protected]> * Windows resize smoke: probe the maximized window at the screen's edge Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
176 lines
8.3 KiB
Rust
176 lines
8.3 KiB
Rust
//! 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::<isize, unsafe extern "system" fn(HWND, u32, WPARAM, LPARAM) -> 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<u32> {
|
|
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<u32> = [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]);
|
|
}
|
|
}
|