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 <noreply@anthropic.com>

* Windows resize smoke: find the main window by its title

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Windows resize smoke: probe the maximized window at the screen's edge

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bherbruckandClaude Opus 5.5 authored and GitHub committed 2026-10-15 10:27:21 -04:00
1 parent 1744fbb262
commit 073acd295b
10 files changed
+571 -24

No files matched your search

+9 -3
View File
@@ -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
View File
@@ -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:?}");
}
}