diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 56993361..1ff5f226 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -96,6 +96,15 @@ pub fn settings_set( settings::save_setting(&conn, "min_to_tray", "false").map_err(|e| e.to_string())?; settings::save_setting(&conn, "min_to_tray_on_close", "false").map_err(|e| e.to_string())?; } + // When remember_window_state is turned off, clear the stored coordinates + // immediately so the window reverts to OS-default position on next launch. + if key == "remember_window_state" && value == "false" { + conn.execute( + "DELETE FROM settings WHERE key IN ('window_x','window_y','window_width','window_height')", + [], + ).map_err(|e| e.to_string())?; + log::debug!("[settings] window coordinates cleared (remember_window_state disabled)"); + } settings::load(&conn).map_err(|e| { log::error!("[settings] failed to reload after save: {e}"); e.to_string() @@ -247,6 +256,13 @@ pub fn settings_reset_defaults( conn.execute("DELETE FROM settings", []) .map_err(|e| e.to_string())?; settings::seed_defaults(&conn).map_err(|e| e.to_string())?; + // Also remove any stored window coordinates so the window returns to + // OS-default position after a full reset (seed_defaults does not seed + // these keys since they have no meaningful default). + conn.execute( + "DELETE FROM settings WHERE key IN ('window_x','window_y','window_width','window_height')", + [], + ).map_err(|e| e.to_string())?; settings::load(&conn).map_err(|e| e.to_string())? }; diff --git a/src-tauri/src/db/migrations.rs b/src-tauri/src/db/migrations.rs index 113c3ac0..a69dd185 100644 --- a/src-tauri/src/db/migrations.rs +++ b/src-tauri/src/db/migrations.rs @@ -90,6 +90,14 @@ const MIGRATION_6: &str = " INSERT INTO schema_version VALUES (6); "; +/// Seeds the `remember_window_state` setting for users upgrading from a version +/// that did not have this feature. Defaults to 'false' so the feature is opt-in +/// and existing users are not surprised by a position change on first upgrade. +const MIGRATION_7: &str = " + INSERT OR IGNORE INTO settings (key, value) VALUES ('remember_window_state', 'false'); + INSERT INTO schema_version VALUES (7); +"; + /// Apply any pending migrations. Each migration is wrapped in a transaction /// so a partial failure leaves the database unchanged. pub fn run(conn: &Connection) -> Result<()> { @@ -131,6 +139,12 @@ pub fn run(conn: &Connection) -> Result<()> { log::info!("[db/migrations] MIGRATION_6 complete"); } + if version < 7 { + log::info!("[db/migrations] applying MIGRATION_7: seed remember_window_state setting"); + conn.execute_batch(&format!("BEGIN; {MIGRATION_7} COMMIT;"))?; + log::info!("[db/migrations] MIGRATION_7 complete"); + } + Ok(()) } @@ -166,7 +180,7 @@ mod tests { let v: i64 = conn .query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0)) .unwrap(); - assert_eq!(v, 6); + assert_eq!(v, 7); } #[test] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3f4596d7..2ff6f2b0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -15,6 +15,8 @@ use log::LevelFilter; use tauri::Manager; use tauri_plugin_log::{Builder as LogBuilder, RotationStrategy, Target, TargetKind}; +use std::sync::atomic::{AtomicBool, Ordering}; + use commands::{ accessibility_trusted, tray_supported, @@ -283,37 +285,51 @@ pub fn run() { let _ = main_window.set_always_on_top(true); } - // Restore saved window position/size if all four values are present and - // the saved rectangle still intersects at least one connected monitor. - if let (Some(wx), Some(wy), Some(ww), Some(wh)) = ( - initial_settings.window_x, - initial_settings.window_y, - initial_settings.window_width, - initial_settings.window_height, - ) { - let on_screen = app - .available_monitors() - .unwrap_or_default() - .into_iter() - .any(|m| { - let mp = m.position(); - let ms = m.size(); - let mx0 = mp.x as i64; - let my0 = mp.y as i64; - let mx1 = mx0 + ms.width as i64; - let my1 = my0 + ms.height as i64; - let wx0 = wx as i64; - let wy0 = wy as i64; - let wx1 = wx0 + ww as i64; - let wy1 = wy0 + wh as i64; - wx0 < mx1 && wx1 > mx0 && wy0 < my1 && wy1 > my0 - }); - if on_screen { - let _ = main_window.set_position(tauri::PhysicalPosition::new(wx, wy)); - let _ = main_window.set_size(tauri::PhysicalSize::new(ww, wh)); + // Startup-suppression flag: blocks the Moved/Resized window event + // handlers from writing to the DB until after the restore phase + // completes. On Windows and Linux the OS fires those events when the + // window is first shown, which would otherwise overwrite any + // previously-saved position with the tauri.conf.json defaults. + let window_ready = Arc::new(AtomicBool::new(false)); + + // Restore saved window position/size if all four values are present, + // remember_window_state is enabled, and the saved rectangle still + // intersects at least one connected monitor. + if initial_settings.remember_window_state { + if let (Some(wx), Some(wy), Some(ww), Some(wh)) = ( + initial_settings.window_x, + initial_settings.window_y, + initial_settings.window_width, + initial_settings.window_height, + ) { + let on_screen = app + .available_monitors() + .unwrap_or_default() + .into_iter() + .any(|m| { + let mp = m.position(); + let ms = m.size(); + let mx0 = mp.x as i64; + let my0 = mp.y as i64; + let mx1 = mx0 + ms.width as i64; + let my1 = my0 + ms.height as i64; + let wx0 = wx as i64; + let wy0 = wy as i64; + let wx1 = wx0 + ww as i64; + let wy1 = wy0 + wh as i64; + wx0 < mx1 && wx1 > mx0 && wy0 < my1 && wy1 > my0 + }); + if on_screen { + let _ = main_window.set_position(tauri::PhysicalPosition::new(wx, wy)); + let _ = main_window.set_size(tauri::PhysicalSize::new(ww, wh)); + } } } + // Allow the window event handlers to start persisting geometry now + // that the restore phase is complete. + window_ready.store(true, Ordering::SeqCst); + // Persist window position/size on move and resize, and close child windows // when the main window is truly closed (not hidden to tray). let db_for_close = db.clone(); @@ -321,6 +337,7 @@ pub fn run() { let app_for_close = app.handle().clone(); let db_for_pos = db.clone(); let win_for_pos = main_window.clone(); + let window_ready_for_event = Arc::clone(&window_ready); main_window.on_window_event(move |event| { match event { tauri::WindowEvent::CloseRequested { api, .. } => { @@ -343,23 +360,48 @@ pub fn run() { } } tauri::WindowEvent::Moved(pos) => { - if let Ok(conn) = db_for_pos.lock() { - let _ = settings::save_setting(&conn, "window_x", &pos.x.to_string()); - let _ = settings::save_setting(&conn, "window_y", &pos.y.to_string()); + // Ignore events fired during the startup/restore phase to + // prevent the OS default position from overwriting saved values. + if !window_ready_for_event.load(Ordering::SeqCst) { + return; } - } - tauri::WindowEvent::Resized(size) => { - if let Ok(conn) = db_for_pos.lock() { - let _ = settings::save_setting(&conn, "window_width", &size.width.to_string()); - let _ = settings::save_setting(&conn, "window_height", &size.height.to_string()); - // Also capture position, since some window managers shift the - // window origin when resizing. - if let Ok(pos) = win_for_pos.outer_position() { + let should_save = db_for_pos + .lock() + .ok() + .and_then(|conn| settings::load(&conn).ok()) + .map(|s| s.remember_window_state) + .unwrap_or(false); + if should_save { + if let Ok(conn) = db_for_pos.lock() { let _ = settings::save_setting(&conn, "window_x", &pos.x.to_string()); let _ = settings::save_setting(&conn, "window_y", &pos.y.to_string()); } } } + tauri::WindowEvent::Resized(size) => { + // Ignore events fired during the startup/restore phase. + if !window_ready_for_event.load(Ordering::SeqCst) { + return; + } + let should_save = db_for_pos + .lock() + .ok() + .and_then(|conn| settings::load(&conn).ok()) + .map(|s| s.remember_window_state) + .unwrap_or(false); + if should_save { + if let Ok(conn) = db_for_pos.lock() { + let _ = settings::save_setting(&conn, "window_width", &size.width.to_string()); + let _ = settings::save_setting(&conn, "window_height", &size.height.to_string()); + // Also capture position, since some window managers shift the + // window origin when resizing. + if let Ok(pos) = win_for_pos.outer_position() { + let _ = settings::save_setting(&conn, "window_x", &pos.x.to_string()); + let _ = settings::save_setting(&conn, "window_y", &pos.y.to_string()); + } + } + } + } _ => {} } }); diff --git a/src-tauri/src/settings/defaults.rs b/src-tauri/src/settings/defaults.rs index 2edea8ff..94c5818b 100644 --- a/src-tauri/src/settings/defaults.rs +++ b/src-tauri/src/settings/defaults.rs @@ -39,4 +39,5 @@ pub const DEFAULTS: &[(&str, &str)] = &[ ("local_shortcut_volume_up", "ArrowUp"), ("local_shortcut_mute", "m"), ("local_shortcut_fullscreen", "F11"), + ("remember_window_state", "false"), ]; diff --git a/src-tauri/src/settings/mod.rs b/src-tauri/src/settings/mod.rs index 268c76c1..61ceee5c 100644 --- a/src-tauri/src/settings/mod.rs +++ b/src-tauri/src/settings/mod.rs @@ -65,6 +65,9 @@ pub struct Settings { pub window_width: Option, /// Last known window height (physical pixels). `None` = use OS default. pub window_height: Option, + /// When true, the window's size and position are saved on move/resize and + /// restored on the next launch. + pub remember_window_state: bool, } impl Default for Settings { @@ -124,6 +127,7 @@ impl Default for Settings { window_y: None, window_width: None, window_height: None, + remember_window_state: false, } } } @@ -249,6 +253,7 @@ pub fn load(conn: &Connection) -> Result { window_y: parse_opt_i32(&map, "window_y"), window_width: parse_opt_u32(&map, "window_width"), window_height: parse_opt_u32(&map, "window_height"), + remember_window_state: parse_bool(&map, "remember_window_state", d.remember_window_state), }) } diff --git a/src/lib/components/settings/sections/SystemSection.svelte b/src/lib/components/settings/sections/SystemSection.svelte index d417425a..71883a78 100644 --- a/src/lib/components/settings/sections/SystemSection.svelte +++ b/src/lib/components/settings/sections/SystemSection.svelte @@ -236,6 +236,12 @@ onclick={() => toggle('break_always_on_top', $settings.break_always_on_top)} /> {/if} + toggle('remember_window_state', $settings.remember_window_state)} + />
{m.system_group_data()}
diff --git a/src/lib/stores/settings.ts b/src/lib/stores/settings.ts index cb9ef1b7..e111ccf5 100644 --- a/src/lib/stores/settings.ts +++ b/src/lib/stores/settings.ts @@ -43,6 +43,11 @@ const defaults: Settings = { local_shortcut_volume_up: 'ArrowUp', local_shortcut_mute: 'm', local_shortcut_fullscreen: 'F11', + window_x: null, + window_y: null, + window_width: null, + window_height: null, + remember_window_state: false, }; export const settings = writable(defaults); diff --git a/src/lib/types.ts b/src/lib/types.ts index 88a82594..5a6aa057 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -56,6 +56,11 @@ export interface Settings { local_shortcut_volume_up: string; local_shortcut_mute: string; local_shortcut_fullscreen: string; + window_x: number | null; + window_y: number | null; + window_width: number | null; + window_height: number | null; + remember_window_state: boolean; } /** Returned by `check_update` — describes an available update. */ diff --git a/src/messages/en.json b/src/messages/en.json index 77f4f239..c6cc5f96 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -57,6 +57,8 @@ "system_toggle_aot_desc": "Keep the timer window above all other windows.", "system_toggle_break_aot": "Lower Priority During Breaks", "system_toggle_break_aot_desc": "Disable always-on-top while a break is running.", + "system_toggle_remember_window": "Remember Size and Position", + "system_toggle_remember_window_desc": "Restore the window to its last size and position when the app launches.", "system_group_tray": "System Tray", "system_toggle_show_tray": "Show in System Tray", "system_toggle_show_tray_desc": "Display a persistent icon in the system tray.",