diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 87dd3522..17395681 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -75,7 +75,8 @@ fn custom_titlebar(prefs_file: Option<&std::path::Path>) -> bool { /// The main window: 1440 × 900 (shrunk to fit the monitor, and maximized on the first frame /// when it still doesn't fit, `work_area::fit_window`), centred on the main monitor. Without /// `centered`, Windows cascades each new window from the top-left corner, so it opened at a -/// different offset every launch (#419). Wayland compositors place windows themselves. +/// different offset every launch (#419). A later launch restores the saved position instead +/// (`main` turns `centered` off, #4826). Wayland compositors place windows themselves. fn native_options(custom_titlebar: bool) -> eframe::NativeOptions { eframe::NativeOptions { viewport: egui::ViewportBuilder::default() @@ -330,6 +331,9 @@ fn main() -> eframe::Result { } // eframe restores the saved window layout before our code runs; drop values that would crash it. ui_state::sanitize(options.persistence_path.as_deref()); + // Centre only the first window: eframe centres after restoring the saved position, so a + // window closed on another monitor reopened in the middle of the main one (#4826). + options.centered = !ui_state::has_saved_position(options.persistence_path.as_deref()); // Crash-safe GPU startup (#4): pick the backend (a marker left by a start that died in the // driver moves to a safer one), and lock this start's marker until the first frames render. let t_sentinel = std::time::Instant::now(); diff --git a/apps/photocraft/src/ui_state.rs b/apps/photocraft/src/ui_state.rs index 4f226dca..c4de67a9 100644 --- a/apps/photocraft/src/ui_state.rs +++ b/apps/photocraft/src/ui_state.rs @@ -27,6 +27,23 @@ pub fn sanitize(path: Option<&Path>) { } } +/// Whether `path` holds a window position from an earlier session, which eframe restores. Then +/// `NativeOptions::centered` must be off: eframe applies it after restoring the saved geometry, +/// so it moved the window back to the centre of the main monitor on every launch (#4826). +pub fn has_saved_position(path: Option<&Path>) -> bool { + let Some(text) = path.and_then(|p| std::fs::read_to_string(p).ok()) else { return false }; + saved_position(&text) +} + +fn saved_position(text: &str) -> bool { + let Ok(kv) = ron::from_str::>(text) else { return false }; + let Some(window) = kv.get("window").and_then(|w| ron::from_str::(w).ok()) else { return false }; + // eframe restores the inner position on macOS and the outer position elsewhere + // (`egui_winit::WindowSettings::initialize_viewport_builder`). + let key = if cfg!(target_os = "macos") { "inner_position_pixels" } else { "outer_position_pixels" }; + matches!(field(&window, key), Some(ron::Value::Option(Some(_)))) +} + /// `Err` names the first value that would crash startup. fn check(text: &str) -> Result<(), String> { // eframe stores a map of key → RON string; a file that doesn't parse is ignored by eframe. @@ -78,7 +95,7 @@ fn field<'a>(v: &'a ron::Value, name: &str) -> Option<&'a ron::Value> { #[cfg(test)] mod tests { - use super::check; + use super::{check, saved_position}; fn file(window: &str, zoom: &str) -> String { let egui = format!("(options:(zoom_factor:{zoom},max_passes:2),data:([(18446744073709551615,(ron:\"(x:1.0)\"))]))"); @@ -105,6 +122,21 @@ mod tests { } } + #[test] + fn a_saved_window_position_is_detected() { + // #4826: the position from the report (Linux Mint, window moved to the third monitor). + let window = "(inner_position_pixels:Some((x:2048.0,y:1265.0)),outer_position_pixels:Some((x:2048.0,y:1228.0)),fullscreen:false,maximized:false,inner_size_points:Some((x:880.0,y:480.0)))"; + assert!(saved_position(&file(window, "1.0"))); + let no_position = "(inner_position_pixels:None,outer_position_pixels:None,fullscreen:false,maximized:false,inner_size_points:Some((x:880.0,y:480.0)))"; + assert!(!saved_position(&file(no_position, "1.0"))); + assert!(!saved_position(&format!("{{\"egui\":{:?}}}", "()")), "no window entry"); + for text in ["", "not ron {{{", "{\"window\":\"(((\"}"] { + assert!(!saved_position(text), "{text:?}"); + } + assert!(!super::has_saved_position(None)); + assert!(!super::has_saved_position(Some(&std::env::temp_dir().join("photocraft-missing-ui.ron")))); + } + #[test] fn unreadable_files_are_left_to_eframe() { // eframe ignores files and entries it can't parse, so they can't crash it.