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 <[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]>
This commit is contained in:
1 parent
1744fbb262
commit
073acd295b
10 files changed
+571
-24
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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<T, E>`** 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
|
||||
|
||||
@@ -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<T, E>`** 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
|
||||
|
||||
Generated
+1
@@ -3459,6 +3459,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"pollster 0.4.0",
|
||||
"quick-xml",
|
||||
"raw-window-handle",
|
||||
"rfd",
|
||||
"ron",
|
||||
"rustls",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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::<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]);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
+156
-12
@@ -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<ResizeDirection> {
|
||||
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<Event>) -> 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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
Reference in new issue
Block a user