diff --git a/Cargo.lock b/Cargo.lock index ea2618c1..0705d10a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4225,6 +4225,7 @@ dependencies = [ "vectorcraft-engine", "vectorcraft-ui-egui", "webbrowser", + "winit", "winresource", ] diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index 5820e2e5..06dba69a 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -22,6 +22,8 @@ rfd = { workspace = true } arboard = { workspace = true, features = ["image-data"] } image = { workspace = true } webbrowser = { workspace = true } +# The window's monitors, size and position (the same winit eframe runs on). +winit = { version = "0.30", default-features = false } [target.'cfg(windows)'.dependencies] clipboard-win = { workspace = true } diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index fa8c93b7..e82e2416 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -11,6 +11,7 @@ mod control_server; #[cfg(target_os = "macos")] mod native_menu; mod printing; +mod window; use vectorcraft_engine::Session; use vectorcraft_engine::cmd::fileio; @@ -30,6 +31,7 @@ impl eframe::App for App { } } self.0.logic(ctx); + window::track(ctx, &mut self.0.ui.window); if self.0.ui.status == "quit" { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } @@ -71,21 +73,32 @@ fn prefs_path_for(name: &str, lower: &str) -> Option { base.map(|b| b.join("ui.json")) } -fn load_prefs(app: &mut VectorcraftApp) { - if std::env::var_os("VECTORCRAFT_NO_PREFS").is_some() { +/// Runs without preferences (`VECTORCRAFT_NO_PREFS`, agents' test runs) neither read nor write them. +fn prefs_enabled() -> bool { + std::env::var_os("VECTORCRAFT_NO_PREFS").is_none() +} + +/// The saved UI preferences, read before the window opens (they hold its size and position). +fn read_prefs() -> Option { + if !prefs_enabled() { + return None; + } + let bytes = prefs_path().and_then(|p| std::fs::read(p).ok()).or_else(|| legacy_prefs_path().and_then(|p| std::fs::read(p).ok()))?; + serde_json::from_slice(&bytes).ok() +} + +fn load_prefs(app: &mut VectorcraftApp, saved: Option) { + if !prefs_enabled() { return; } - let bytes = prefs_path().and_then(|p| std::fs::read(p).ok()).or_else(|| legacy_prefs_path().and_then(|p| std::fs::read(p).ok())); - if let Some(bytes) = bytes - && let Ok(ui) = serde_json::from_slice::(&bytes) - { + if let Some(ui) = saved { app.ui = ui.sanitized(); } vectorcraft_ui_egui::prefs_dialog::restore(app); } fn save_prefs(app: &VectorcraftApp) { - if std::env::var_os("VECTORCRAFT_NO_PREFS").is_some() { + if !prefs_enabled() { return; } if let Some(p) = prefs_path() { @@ -230,11 +243,13 @@ fn main() -> eframe::Result { _ => files.push(a), } } + let saved = read_prefs(); + let saved_window = saved.as_ref().and_then(|ui| ui.window); let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_title("VectorCraft") - .with_inner_size([1440.0, 900.0]) - .with_min_inner_size([800.0, 500.0]) + .with_inner_size(window::DEFAULT_SIZE) + .with_min_inner_size(window::MIN_SIZE) .with_drag_and_drop(true) .with_decorations(!CUSTOM_TITLEBAR) .with_fullsize_content_view(true) @@ -249,7 +264,11 @@ fn main() -> eframe::Result { options, Box::new(move |cc| { let mut app = VectorcraftApp::new(Session::new(), services()); - load_prefs(&mut app); + load_prefs(&mut app, saved); + // Fit the window to its monitor, or put it back where it was (still hidden). + if let Some(w) = cc.winit_window() { + app.ui.window = Some(window::restore(w, saved_window)); + } // User Defined swatch and graphic style libraries live next to the preferences. let swatches = prefs_path().and_then(|p| Some(p.parent()?.join("Swatches").to_string_lossy().to_string())); app.session.swatch_libraries.set_user_dir(swatches); diff --git a/apps/vectorcraft/src/window.rs b/apps/vectorcraft/src/window.rs new file mode 100644 index 00000000..e9532642 --- /dev/null +++ b/apps/vectorcraft/src/window.rs @@ -0,0 +1,256 @@ +//! 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, size: PhysicalSize, 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, monitors: &[Monitor], home: Option) -> 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 { + let handles: Vec<_> = window.available_monitors().collect(); + let monitors: Vec = 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) { + 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 { + 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); + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 4c7c3153..aeb19207 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -303,6 +303,24 @@ pub struct UiState { /// becoming active, leaves it. #[serde(skip)] pub home: Option<(Option, usize)>, + /// The desktop window's size, position and maximized state, saved when the app quits and + /// restored at the next launch (the desktop host reads and writes it; none on the web). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub window: Option, +} + +/// The desktop window's geometry, kept across launches. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct WindowGeometry { + /// Top-left corner of the window frame, in physical pixels on the desktop (none where the + /// system doesn't tell windows where they are). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pos: Option<[i32; 2]>, + /// Size of the window's contents in logical pixels (points at 100% UI scaling). + pub size: [f32; 2], + /// The window was maximized; `pos` and `size` are where un-maximizing puts it. + #[serde(default)] + pub maximized: bool, } impl UiState { @@ -365,6 +383,7 @@ impl Default for UiState { eps_options: Value::Null, dxf_import: Value::Null, home: None, + window: None, } } }