The first window opens at 1440 x 900 when the screen has room for it around a taskbar or Dock, and maximized (with a size that fits once un-maximized) when it doesn't, as on 1600 x 900, 1366 x 768 or 150% scaled 1080p screens. The window's size, position and maximized state are saved with the UI preferences (UiState::window) and restored at the next launch, moved fully onto a monitor that is still connected and maximized if it no longer fits. Root cause: main.rs created the window at a fixed 1440 x 900 at the system's default position, never checked it against the monitor and never remembered it. The new apps/vectorcraft/src/window.rs places the still-hidden window from the monitors winit reports (CreationContext::winit_window) and tracks its geometry every frame. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
257 lines
12 KiB
Rust
257 lines
12 KiB
Rust
//! The main window's size and position. The first launch fits the window to its monitor, opening
|
||
//! it maximized where the default size leaves no room for a taskbar or Dock. Later launches restore
|
||
//! the size, position and maximized state saved with the UI preferences (`UiState::window`), moved
|
||
//! onto a monitor that is still connected.
|
||
|
||
use vectorcraft_ui_egui::state::WindowGeometry;
|
||
use winit::dpi::{LogicalSize, PhysicalPosition, PhysicalSize};
|
||
|
||
/// The first window's size, in points.
|
||
pub const DEFAULT_SIZE: [f32; 2] = [1440.0, 900.0];
|
||
/// The smallest window, in points.
|
||
pub const MIN_SIZE: [f32; 2] = [800.0, 500.0];
|
||
/// Room the first window leaves around itself for a taskbar, Dock or menu bar (the system doesn't
|
||
/// say how much of the screen they take): a monitor with less room opens it maximized.
|
||
const SCREEN_ROOM: [f32; 2] = [64.0, 128.0];
|
||
|
||
/// A monitor: its top-left corner and size in physical pixels, and its scale factor.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub struct Monitor {
|
||
pos: [i64; 2],
|
||
size: [i64; 2],
|
||
scale: f32,
|
||
}
|
||
|
||
impl Monitor {
|
||
pub fn new(pos: PhysicalPosition<i32>, size: PhysicalSize<u32>, scale: f64) -> Self {
|
||
let scale = scale as f32;
|
||
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
|
||
Self { pos: [pos.x.into(), pos.y.into()], size: [size.width.into(), size.height.into()], scale }
|
||
}
|
||
|
||
/// Its size in points.
|
||
fn points(&self) -> [f32; 2] {
|
||
self.size.map(|s| s as f32 / self.scale)
|
||
}
|
||
|
||
/// Its size in points less room for a taskbar, Dock or menu bar.
|
||
fn usable(&self) -> [f32; 2] {
|
||
let points = self.points();
|
||
std::array::from_fn(|i| points[i] - SCREEN_ROOM[i])
|
||
}
|
||
|
||
/// A window of `size` points on this monitor, in physical pixels.
|
||
fn pixels(&self, size: [f32; 2]) -> [i64; 2] {
|
||
size.map(|s| (s * self.scale) as i64)
|
||
}
|
||
|
||
/// How much of a window at `pos` of `size` points this monitor shows, in square pixels.
|
||
fn overlap(&self, pos: [i32; 2], size: [f32; 2]) -> i64 {
|
||
let px = self.pixels(size);
|
||
let span = |i: usize| {
|
||
let start = i64::from(pos[i]).max(self.pos[i]);
|
||
let end = i64::from(pos[i]).saturating_add(px[i]).min(self.pos[i] + self.size[i]);
|
||
(end - start).max(0)
|
||
};
|
||
span(0).saturating_mul(span(1))
|
||
}
|
||
|
||
/// The top-left corner nearest to `pos` (centred when none) that keeps a window of `size`
|
||
/// points on this monitor.
|
||
fn keep_on(&self, pos: Option<[i32; 2]>, size: [f32; 2]) -> [i32; 2] {
|
||
let px = self.pixels(size);
|
||
std::array::from_fn(|i| {
|
||
let lo = self.pos[i];
|
||
let hi = lo + self.size[i].saturating_sub(px[i]).max(0);
|
||
let want = pos.map_or(lo + (hi - lo) / 2, |p| i64::from(p[i]));
|
||
i32::try_from(want.max(lo).min(hi)).unwrap_or_default()
|
||
})
|
||
}
|
||
}
|
||
|
||
/// Where the window opens: `saved` (the last session's geometry) kept on a connected monitor, else
|
||
/// the default size on `home` (the monitor the system opens it on). Without monitors (the system
|
||
/// doesn't list them, so nothing to check a saved geometry against) the default size.
|
||
pub fn place(saved: Option<WindowGeometry>, monitors: &[Monitor], home: Option<usize>) -> WindowGeometry {
|
||
let default = WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false };
|
||
let Some(home) = home.and_then(|i| monitors.get(i)).or(monitors.first()) else {
|
||
return default;
|
||
};
|
||
let Some(saved) = saved else {
|
||
let usable = home.usable();
|
||
if (0..2).all(|i| DEFAULT_SIZE[i] <= usable[i]) {
|
||
return default;
|
||
}
|
||
// A small screen: maximized, with a size that fits once un-maximized.
|
||
let size = fit(DEFAULT_SIZE, usable);
|
||
return WindowGeometry { pos: Some(home.keep_on(None, size)), size, maximized: true };
|
||
};
|
||
// The monitor showing most of the window; none when it was on a monitor that is gone.
|
||
let shown = saved.pos.and_then(|p| {
|
||
let (area, m) = monitors.iter().map(|m| (m.overlap(p, saved.size), m)).max_by_key(|(area, _)| *area)?;
|
||
(area > 0).then_some(m)
|
||
});
|
||
let m = shown.unwrap_or(home);
|
||
// Bigger than its monitor (a smaller screen than last time): maximized, like the first window.
|
||
let room = m.points();
|
||
let too_big = (0..2).any(|i| saved.size[i] > room[i]);
|
||
let size = fit(saved.size, if too_big { m.usable() } else { room });
|
||
WindowGeometry {
|
||
// Moved fully onto its monitor, or centred on the home monitor.
|
||
pos: saved.pos.map(|p| m.keep_on(shown.map(|_| p), size)),
|
||
size,
|
||
maximized: saved.maximized || too_big,
|
||
}
|
||
}
|
||
|
||
/// `size` no bigger than `room`, and no smaller than the smallest window unless `room` is.
|
||
fn fit(size: [f32; 2], room: [f32; 2]) -> [f32; 2] {
|
||
std::array::from_fn(|i| size[i].min(room[i]).max(MIN_SIZE[i].min(room[i])).max(1.0))
|
||
}
|
||
|
||
/// Size and place the new window (eframe shows it after drawing the first frame) and return its
|
||
/// geometry.
|
||
pub fn restore(window: &winit::window::Window, saved: Option<WindowGeometry>) -> WindowGeometry {
|
||
let handles: Vec<_> = window.available_monitors().collect();
|
||
let monitors: Vec<Monitor> = handles.iter().map(|m| Monitor::new(m.position(), m.size(), m.scale_factor())).collect();
|
||
let home = window.current_monitor().or_else(|| window.primary_monitor()).and_then(|c| handles.iter().position(|m| *m == c));
|
||
let g = place(saved, &monitors, home);
|
||
if let Some([x, y]) = g.pos {
|
||
window.set_outer_position(PhysicalPosition::new(x, y));
|
||
}
|
||
// Returns the new size when it applies at once; either way the window gets it.
|
||
let _ = window.request_inner_size(LogicalSize::new(g.size[0], g.size[1]));
|
||
if g.maximized {
|
||
window.set_maximized(true);
|
||
}
|
||
g
|
||
}
|
||
|
||
/// Per frame: remember the window's geometry for the next launch. Minimized or full screen keeps
|
||
/// the last one; maximized only sets its flag, so un-maximizing next time restores the last size.
|
||
pub fn track(ctx: &egui::Context, geometry: &mut Option<WindowGeometry>) {
|
||
let zoom = ctx.zoom_factor();
|
||
ctx.input(|i| {
|
||
let v = i.viewport();
|
||
if v.minimized == Some(true) || v.fullscreen == Some(true) {
|
||
return;
|
||
}
|
||
if v.maximized == Some(true) {
|
||
if let Some(g) = geometry {
|
||
g.maximized = true;
|
||
}
|
||
return;
|
||
}
|
||
let Some(inner) = v.inner_rect else { return };
|
||
let pos = v.outer_rect.map(|r| [r.min.x, r.min.y].map(|c| (c * i.pixels_per_point).round() as i32));
|
||
*geometry = Some(WindowGeometry { pos, size: [inner.width() * zoom, inner.height() * zoom], maximized: false });
|
||
});
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn monitor(x: i32, y: i32, w: u32, h: u32, scale: f64) -> Monitor {
|
||
Monitor::new(PhysicalPosition::new(x, y), PhysicalSize::new(w, h), scale)
|
||
}
|
||
|
||
fn saved(pos: Option<[i32; 2]>, size: [f32; 2], maximized: bool) -> Option<WindowGeometry> {
|
||
Some(WindowGeometry { pos, size, maximized })
|
||
}
|
||
|
||
#[test]
|
||
fn the_first_window_fits_its_screen() {
|
||
// 1920 × 1080: the default size, where the system puts it.
|
||
let big = [monitor(0, 0, 1920, 1080, 1.0)];
|
||
assert_eq!(place(None, &big, Some(0)), WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false });
|
||
// 1600 × 900, 1366 × 768 and 1920 × 1080 at 150% (1280 × 720 points): maximized, and a
|
||
// size that fits the screen with room for the taskbar once un-maximized.
|
||
for (m, size) in [
|
||
(monitor(0, 0, 1600, 900, 1.0), [1440.0, 772.0]),
|
||
(monitor(0, 0, 1366, 768, 1.0), [1302.0, 640.0]),
|
||
(monitor(0, 0, 1920, 1080, 1.5), [1216.0, 592.0]),
|
||
] {
|
||
let g = place(None, &[m], Some(0));
|
||
assert!(g.maximized, "{m:?}");
|
||
assert_eq!(g.size, size, "{m:?}");
|
||
let [x, y] = g.pos.unwrap();
|
||
let px = m.pixels(g.size);
|
||
assert!(x >= 0 && y >= 0 && i64::from(x) + px[0] <= m.size[0] && i64::from(y) + px[1] <= m.size[1], "{m:?}: {g:?}");
|
||
}
|
||
// No monitor information: the default size, whatever was saved.
|
||
assert_eq!(place(None, &[], None), WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false });
|
||
assert_eq!(place(saved(Some([9000, 9000]), [1e9, 1e9], true), &[], None).size, DEFAULT_SIZE);
|
||
}
|
||
|
||
#[test]
|
||
fn the_saved_window_comes_back_where_it_was() {
|
||
let two = [monitor(0, 0, 2560, 1440, 1.0), monitor(2560, 0, 1920, 1080, 1.0)];
|
||
for g in [saved(Some([100, 80]), [1200.0, 800.0], false), saved(Some([2700, 50]), [1000.0, 700.0], true), saved(None, [1000.0, 700.0], false)]
|
||
{
|
||
assert_eq!(Some(place(g, &two, Some(0))), g);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_saved_window_stays_on_a_connected_monitor() {
|
||
let laptop = [monitor(0, 0, 1600, 900, 1.0)];
|
||
// Last time on a second monitor that is gone: centred on the remaining one.
|
||
let g = place(saved(Some([2700, 50]), [1000.0, 700.0], false), &laptop, Some(0));
|
||
assert_eq!(g, WindowGeometry { pos: Some([300, 100]), size: [1000.0, 700.0], maximized: false });
|
||
// Half off the monitor's right and bottom edges: moved fully onto it.
|
||
let g = place(saved(Some([1200, 600]), [1000.0, 700.0], false), &laptop, Some(0));
|
||
assert_eq!(g.pos, Some([600, 200]));
|
||
// Bigger than the screen now (it was on a larger one): it fits, and opens maximized.
|
||
let g = place(saved(Some([0, 0]), [2400.0, 1300.0], false), &laptop, Some(0));
|
||
assert_eq!(g, WindowGeometry { pos: Some([0, 0]), size: [1536.0, 772.0], maximized: true });
|
||
// A monitor left of the primary one (negative coordinates) still counts.
|
||
let left = [monitor(0, 0, 1920, 1080, 1.0), monitor(-1920, 0, 1920, 1080, 1.0)];
|
||
assert_eq!(place(saved(Some([-1800, 40]), [1000.0, 700.0], false), &left, Some(0)).pos, Some([-1800, 40]));
|
||
// Nonsense sizes from a damaged preferences file are kept within the screen.
|
||
let g = place(saved(Some([i32::MAX, i32::MIN]), [-5.0, f32::MAX], false), &laptop, Some(0));
|
||
assert_eq!(g, WindowGeometry { pos: Some([400, 64]), size: [800.0, 772.0], maximized: true });
|
||
let g = place(saved(Some([5, 5]), [f32::NEG_INFINITY, f32::INFINITY], false), &laptop, Some(0));
|
||
assert_eq!(g, WindowGeometry { pos: Some([400, 64]), size: [800.0, 772.0], maximized: true });
|
||
}
|
||
|
||
#[test]
|
||
fn the_window_geometry_is_tracked_for_the_next_launch() {
|
||
let ctx = egui::Context::default();
|
||
let mut geometry = None;
|
||
let mut frame = |info: egui::ViewportInfo| {
|
||
let mut input = egui::RawInput::default();
|
||
input.viewports.insert(egui::ViewportId::ROOT, info);
|
||
ctx.run_ui(input, |ui| track(ui.ctx(), &mut geometry)).textures_delta.clear();
|
||
geometry
|
||
};
|
||
let rect = egui::Rect::from_min_size(egui::pos2(20.0, 30.0), egui::vec2(1000.0, 600.0));
|
||
let normal = egui::ViewportInfo {
|
||
native_pixels_per_point: Some(1.5),
|
||
inner_rect: Some(rect),
|
||
outer_rect: Some(rect),
|
||
maximized: Some(false),
|
||
..Default::default()
|
||
};
|
||
// Position in physical pixels, size in points.
|
||
let g = WindowGeometry { pos: Some([30, 45]), size: [1000.0, 600.0], maximized: false };
|
||
assert_eq!(frame(normal.clone()), Some(g));
|
||
// Maximized or minimized: the size to restore stays.
|
||
let big = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 700.0));
|
||
let maximized = egui::ViewportInfo { inner_rect: Some(big), outer_rect: Some(big), maximized: Some(true), ..normal.clone() };
|
||
assert_eq!(frame(maximized), Some(WindowGeometry { maximized: true, ..g }));
|
||
let minimized = egui::ViewportInfo { inner_rect: None, outer_rect: None, minimized: Some(true), ..normal.clone() };
|
||
assert_eq!(frame(minimized), Some(WindowGeometry { maximized: true, ..g }));
|
||
assert_eq!(frame(normal), Some(g));
|
||
// Saved with the preferences; older preference files have no window.
|
||
let mut ui = vectorcraft_ui_egui::UiState { window: Some(g), ..Default::default() };
|
||
let back: vectorcraft_ui_egui::UiState = serde_json::from_slice(&serde_json::to_vec(&ui).unwrap()).unwrap();
|
||
assert_eq!(back.window, Some(g));
|
||
ui.window = None;
|
||
let json = serde_json::to_value(&ui).unwrap();
|
||
assert!(json.get("window").is_none());
|
||
let back: vectorcraft_ui_egui::UiState = serde_json::from_value(json).unwrap();
|
||
assert_eq!(back.window, None);
|
||
}
|
||
}
|