From bf7715785c7f2f1b86cb45fa1aa95a7f512fe188 Mon Sep 17 00:00:00 2001 From: Joseph Anglada Date: Wed, 4 Nov 2026 16:19:03 -0400 Subject: [PATCH] Desktop: a window restored onto no monitor opens on screen (#4742) Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + apps/wordcraft/src/main.rs | 41 +++++++++++++- crates/ui-egui/src/window_geometry.rs | 79 ++++++++++++++++++++++++--- docs/hardware-parity.md | 1 + 4 files changed, 111 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0cae22c49a..3cce5cdaae 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -227,6 +227,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-10-29: Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880). Labels → New Document with one label per recipient includes every selected address, with unused labels blank on the final sheet. Fixed-text and single-label output remain one sheet. Finish & Merge template expansion is separate. - 2026-10-29: HTML import keeps preformatted lines with CR, CRLF or LF input newlines (#4810); numeric character references retain their separate meaning; readiness estimates unchanged. - 2026-10-27: File › Print: Return prints, as in Word, where ⌘P opens on Print as the default button; a field or list on the page keeps Return while it has the keyboard. +- 2026-10-28: A window restored to a place no monitor covers now opens centred on the primary monitor. Before, a monitor replaced by one of the same size left the window off screen, and the app seemed to close as soon as it started. - 2026-10-22: View › Window › New Window opens a second window on the document (desktop). The windows are titled `Name:1` and `Name:2` and each keeps its own caret, scroll position, zoom and view; edits and Undo are the document's and show in both. Closing one of them asks nothing; the last one asks about unsaved changes, and quitting asks once per document. - 2026-10-22: One native window per open document on the desktop. File › New and Open (from the ribbon, File, shortcuts, the file manager, the command line or the control channel) open the document in a window of its own, focused and placed a step below and right of the last one; opening a file replaces a blank document nobody has used in its own window, and a file that is already open brings its window to the front. Each window keeps its own ribbon tab, File page, dialogs, panes, scroll position and composition. View › Window › Switch Windows lists the windows and brings the chosen one forward. File › Close and a window's close button close that window, asking about unsaved changes first; the last window stays open with no document. Quitting asks about each document with unsaved changes in its own window, and Cancel keeps everything open. The control channel's input and screenshots go to the window focused last. The web app keeps one window. - 2026-10-22: Recovery covers every open document. Each dirty document keeps its own copy, written on the same ten-second cadence whether it is shown or not; closing a document without saving deletes its copy, as saving does, and a clean exit deletes the copies of every document saved or closed since its last copy. File › Recover lists only copies no open document keeps, and Restore opens a copy as another document (or in place of an untouched blank one), so it never replaces work. After a crash with several unsaved documents, the next start lists all of them. diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 7febc79f66..6b723be720 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -27,7 +27,7 @@ mod traffic_lights; use wordcraft_engine::Session; use wordcraft_ui_egui::{ Services, UiState, WordApp, - window_geometry::{RawWindowState, WindowGeometry, take_rescue}, + window_geometry::{RawWindowState, WindowGeometry, reachable, take_rescue}, }; /// The app, the restored window geometry (or, with none, the default: no position) until the @@ -54,7 +54,13 @@ impl eframe::App for App { apple_events::poll(&self.5, &mut self.0, ctx); self.0.logic(ctx); self.3.show(frame); - let window = self.4; + let mut window = self.4; + // Only until the off-screen check below has run: it is the one reader. + if self.1.is_some() { + let (on_a_monitor, primary_monitor_rect) = monitor_geometry(frame, ctx.pixels_per_point()); + window.on_a_monitor = on_a_monitor; + window.primary_monitor_rect = primary_monitor_rect; + } // A window that doesn't fit on its screen (the default size on a laptop) shrinks to fit // there, so all of the scroll bar can be dragged (#1215). Checked on the frame that first // knows the monitor: the off-screen check below takes the pending geometry then. @@ -126,6 +132,37 @@ impl eframe::App for App { } } +/// Whether a grippable part of the window is on a monitor, and the primary monitor bounds. +/// Winit reports physical pixels; egui positions use points. `None` where the platform cannot +/// report the window or primary monitor (Wayland, headless). +fn monitor_geometry(frame: &eframe::Frame, pixels_per_point: f32) -> (Option, Option) { + let Some(w) = frame.winit_window() else { + return (None, None); + }; + if !pixels_per_point.is_finite() || pixels_per_point <= 0.0 { + return (None, None); + } + let Some(pos) = w.outer_position().ok() else { + return (None, None); + }; + let size = w.outer_size(); + let physical_rect = |(x, y): (i32, i32), (width, height): (u32, u32)| { + egui::Rect::from_min_size(egui::pos2(x as f32, y as f32), egui::vec2(width as f32, height as f32)) + }; + let monitors: Vec = + w.available_monitors().map(|m| physical_rect((m.position().x, m.position().y), (m.size().width, m.size().height))).collect(); + let on_a_monitor = (!monitors.is_empty()).then(|| reachable(physical_rect((pos.x, pos.y), (size.width, size.height)), &monitors)); + let primary_monitor_rect = w.primary_monitor().map(|m| { + let pos = m.position(); + let size = m.size(); + egui::Rect::from_min_size( + egui::pos2(pos.x as f32, pos.y as f32) / pixels_per_point, + egui::vec2(size.width as f32, size.height as f32) / pixels_per_point, + ) + }); + (on_a_monitor, primary_monitor_rect) +} + /// Quit the way Command-Q does, on the next turn of the main queue. /// /// Called when the window is closing. Doing it on the next turn lets the close diff --git a/crates/ui-egui/src/window_geometry.rs b/crates/ui-egui/src/window_geometry.rs index 6308eed151..142ffb0a83 100644 --- a/crates/ui-egui/src/window_geometry.rs +++ b/crates/ui-egui/src/window_geometry.rs @@ -23,6 +23,10 @@ pub struct RawWindowState { pub maximized: Option, pub minimized: Option, pub fullscreen: Option, + /// The host's [`reachable`] answer: `None` where it can't list the monitors (Wayland). + pub on_a_monitor: Option, + /// The primary monitor's bounds in egui points, when the host can identify it. + pub primary_monitor_rect: Option, } impl RawWindowState { @@ -41,6 +45,8 @@ impl RawWindowState { maximized: viewport.and_then(|v| v.maximized), minimized: viewport.and_then(|v| v.minimized), fullscreen: viewport.and_then(|v| v.fullscreen), + on_a_monitor: None, + primary_monitor_rect: None, } } @@ -65,6 +71,19 @@ const MAX_COORD: f32 = 32768.0; /// Room a window leaves on each side of its monitor for the taskbar, the Dock or a desktop panel, /// which the monitor size (all egui reports) includes. const SYSTEM_BAR: egui::Vec2 = egui::vec2(48.0, 48.0); +/// How much of a window's top edge must be on one monitor for the pointer to drag it back. +const GRIP: egui::Vec2 = egui::vec2(96.0, 16.0); + +/// Whether a [`GRIP`] of `window`'s top edge lies on one of `monitors`, in one shared unit. +/// Monitor sizes alone can't tell: a monitor that went away can leave one of the same size. +pub fn reachable(window: egui::Rect, monitors: &[egui::Rect]) -> bool { + let top = egui::Rect::from_min_size(window.min, egui::vec2(window.width(), window.height().min(GRIP.y))); + let need = GRIP.min(top.size()); + monitors.iter().any(|m| { + let on = top.intersect(*m); + on.is_positive() && on.width() >= need.x && on.height() >= need.y + }) +} /// The window as it was when the app last closed. #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -144,22 +163,22 @@ impl WindowGeometry { } } - /// After a restore: where to move the window when the monitor it now reports differs from the - /// one it was saved on (an external monitor was unplugged, say), so a window restored to a - /// position no monitor covers any more doesn't open off screen. Centred on the primary - /// monitor, whose top-left corner is the origin. A maximized window fills whichever monitor - /// the system picks, so it stays put. + /// After a restore: where to move the window when it opened on no monitor, or when the monitor + /// it now reports differs from the one it was saved on (an external monitor was unplugged, + /// say). Centred on the primary monitor, whose top-left corner is the origin. A maximized + /// window fills whichever monitor the system picks, so it stays put. pub fn rescue_position(&self, window: &RawWindowState) -> Option { if self.maximized || self.position.is_none() { return None; } - let saved = egui::Vec2::from(self.monitor?); let now = window.monitor_size.filter(|s| s.is_finite() && s.x > 0.0 && s.y > 0.0)?; - if (now - saved).length() <= 1.0 { + let other_monitor = self.monitor.is_some_and(|saved| (now - egui::Vec2::from(saved)).length() > 1.0); + if !other_monitor && window.on_a_monitor != Some(false) { return None; } - let free = now - egui::vec2(self.width, self.height); - Some(egui::pos2((free.x / 2.0).max(0.0), (free.y / 2.0).max(0.0))) + let monitor = window.primary_monitor_rect.unwrap_or_else(|| egui::Rect::from_min_size(egui::Pos2::ZERO, now)); + let free = monitor.size() - egui::vec2(self.width, self.height); + Some(monitor.min + (free / 2.0).max(egui::Vec2::ZERO)) } /// Once the monitor size is known: a smaller size and a centred position for a window that @@ -396,6 +415,48 @@ mod tests { assert_eq!(saved().rescue_position(&RawWindowState::default()), None); } + #[test] + fn a_window_on_no_monitor_is_moved_on_screen_when_a_same_size_monitor_replaced_its_own() { + let off = egui::Rect::from_min_size(egui::pos2(-1920.0, -1080.0), egui::vec2(1200.0, 800.0)); + let lost = RawWindowState { on_a_monitor: Some(false), ..raw(off, egui::vec2(1920.0, 1080.0)) }; + assert_eq!(saved().rescue_position(&lost), Some(egui::pos2(360.0, 140.0))); + assert_eq!(WindowGeometry { monitor: None, ..saved() }.rescue_position(&lost), Some(egui::pos2(360.0, 140.0))); + assert_eq!(WindowGeometry { maximized: true, ..saved() }.rescue_position(&lost), None); + let found = RawWindowState { on_a_monitor: Some(true), ..lost }; + assert_eq!(saved().rescue_position(&found), None); + } + + #[test] + fn only_a_grippable_top_edge_on_a_monitor_is_reachable() { + let left = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1920.0, 1080.0)); + let right = egui::Rect::from_min_size(egui::pos2(1920.0, 0.0), egui::vec2(1920.0, 1080.0)); + let monitors = [left, right]; + let at = |x: f32, y: f32| egui::Rect::from_min_size(egui::pos2(x, y), egui::vec2(1440.0, 920.0)); + assert!(reachable(at(100.0, 50.0), &monitors)); + assert!(reachable(at(1000.0, 50.0), &monitors), "across both monitors"); + assert!(reachable(at(3840.0 - 96.0, 50.0), &monitors), "just enough of the right edge"); + assert!(!reachable(at(3840.0 - 95.0, 50.0), &monitors)); + assert!(!reachable(at(-1920.0, -1080.0), &monitors), "the gone monitor above-left"); + assert!(!reachable(at(100.0, -900.0), &monitors), "the top edge above every monitor"); + assert!(!reachable(at(100.0, 50.0), &[])); + assert!( + reachable(egui::Rect::from_min_size(egui::pos2(10.0, 1072.0), egui::vec2(40.0, 8.0)), &monitors), + "smaller than the grip at the monitor edge" + ); + } + + #[test] + fn an_unreachable_window_is_centered_on_the_primary_monitor_when_monitor_sizes_differ() { + let primary = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1280.0, 1080.0)); + let off = egui::Rect::from_min_size(egui::pos2(-3840.0, 0.0), egui::vec2(1200.0, 800.0)); + let window = RawWindowState { on_a_monitor: Some(false), primary_monitor_rect: Some(primary), ..raw(off, egui::vec2(3840.0, 2160.0)) }; + assert_eq!(saved().rescue_position(&window), Some(egui::pos2(40.0, 140.0))); + + let primary_left = egui::Rect::from_min_size(egui::pos2(-1280.0, 120.0), egui::vec2(1280.0, 1024.0)); + let window = RawWindowState { primary_monitor_rect: Some(primary_left), ..window }; + assert_eq!(saved().rescue_position(&window), Some(egui::pos2(-1240.0, 232.0))); + } + /// The rescue waits for a known monitor size, then runs once (moving when needed) and clears the /// pending geometry. #[test] diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 43f13f4838..665e10f664 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -30,6 +30,7 @@ File > Print offers 2, 4, 6, 9 or 16 logical pages per physical sheet side and l | 2026-11-01 | trivial | N-up and booklet print imposition (#641): integrate current print-job link handling (#4734), preserve markup preview and sheet/copy planning; readiness estimates unchanged. | | 2026-10-30 | trivial | Only the PDF sent to the printer leaves out link annotations; Save these pages as PDF (`file.printPdf`) keeps them (#4734) | | 2026-10-29 | trivial | A document with links prints: the print job's PDF leaves out link annotations, which the spooler refused to fit to the paper (#4734) | +| 2026-10-28 | trivial | A window restored to a place no monitor covers opens centred on the primary monitor. The check compared monitor sizes only, so when a monitor was replaced by one of the same size (a dock unplugged, a display rearranged), the window opened off screen and the app looked like it quit at once | | 2026-10-15 | trivial | Pen / stylus row: Ink to Shape also makes two-stroke arrows and arrows with a closed triangle head | | 2026-10-17 | minor | File › Print's Scale to paper size (#2108): pick Letter, Legal, A4 or any Layout › Size paper and every page is scaled to it and centred, the paper turned to each page's orientation, in the preview, the print job (with the spooler's media) and Save these pages as PDF; the document keeps its page size. `paper` param on `ui.print`/`file.printPdf` | | 2026-10-14 | trivial | Pen / stylus row corrected: ink, eraser and Ink to Shape (with arrows) work; pressure and Ink to Math missing |