diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4cfeb1ff..aa413b6f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,6 +8,8 @@ mod commands; #[cfg(debug_assertions)] mod dev; mod events; +#[cfg(any(target_os = "linux", test))] +mod linux_window_state; mod logging; mod menu; mod remote_dev; @@ -68,16 +70,18 @@ pub fn run() { // Single instance first: a second launch focuses this one and opens its path argument. // Debug builds have a separate identifier per worktree. let app = app.plugin(tauri_plugin_single_instance::init(windows::on_second_instance)); + let app = app.plugin(tauri_plugin_opener::init()).plugin(tauri_plugin_dialog::init()); + #[cfg(target_os = "linux")] + let app = app.plugin(linux_window_state::plugin()); + #[cfg(not(target_os = "linux"))] + let app = app.plugin( + tauri_plugin_window_state::Builder::default() + .with_state_flags(window_state_flags()) + // One remembered geometry shared by every project's settings window. + .map_label(|l| if windows::is_settings_window(l) { "settings" } else { l }) + .build(), + ); let app = app - .plugin(tauri_plugin_opener::init()) - .plugin(tauri_plugin_dialog::init()) - .plugin( - tauri_plugin_window_state::Builder::default() - .with_state_flags(window_state_flags()) - // One remembered geometry shared by every project's settings window. - .map_label(|l| if windows::is_settings_window(l) { "settings" } else { l }) - .build(), - ) // Inert until an endpoint is configured (see `commands::window::app_check_update`). .plugin(tauri_plugin_updater::Builder::new().build()); // E2E automation bridge (Tauri MCP), debug builds only, localhost only. @@ -152,6 +156,8 @@ pub fn run() { Ok(()) }) .on_window_event(|window, event| match event { + #[cfg(target_os = "linux")] + tauri::WindowEvent::CloseRequested { .. } => linux_window_state::save_before_close(window.app_handle()), tauri::WindowEvent::Focused(true) => { application_focus::changed(window.app_handle(), true); if let Some(w) = window.try_state::() { @@ -204,6 +210,7 @@ pub fn run() { }); } +#[cfg(not(target_os = "linux"))] fn window_state_flags() -> tauri_plugin_window_state::StateFlags { use tauri_plugin_window_state::StateFlags; StateFlags::SIZE | StateFlags::POSITION | StateFlags::MAXIMIZED | StateFlags::FULLSCREEN diff --git a/src-tauri/src/linux_window_state.rs b/src-tauri/src/linux_window_state.rs new file mode 100644 index 00000000..3d9816dc --- /dev/null +++ b/src-tauri/src/linux_window_state.rs @@ -0,0 +1,483 @@ +//! Restore GTK geometry after mapping, using the scale of the window's actual output. + +mod geometry; +#[cfg(target_os = "linux")] +use geometry::*; +use tauri::utils::config::WindowConfig; + +fn build_hidden(config: &mut WindowConfig) -> bool { + std::mem::replace(&mut config.visible, false) +} + +#[cfg(target_os = "linux")] +mod runtime { + use super::*; + use parking_lot::Mutex; + use std::collections::HashMap; + use tauri::{AppHandle, Listener, Manager, Window, WindowEvent, plugin::TauriPlugin}; + + const FILE: &str = tauri_plugin_window_state::DEFAULT_FILENAME; + + #[derive(Default)] + enum Phase { + #[default] + Mapping, + Size { + target: Size, + scale: f64, + request: u64, + }, + Mode, + Tracking, + } + + #[derive(Default)] + struct Pending { + limits: Limits, + configured: bool, + presented: bool, + presentation_request: u64, + saved: Geometry, + normal: Size, + history: ModeHistory, + restore_position: bool, + phase: Phase, + mode_requested: bool, + mode_request: u64, + } + + #[derive(Default)] + struct Cache { + saved: HashMap, + windows: HashMap, + next_request: u64, + } + + struct Frame { + size: Size, + scale: f64, + mode: Mode, + position: Option>, + monitors: Vec, + work_area: Option, + visible: bool, + } + + fn frame(window: &Window) -> Option { + let size = window.inner_size().ok()?; + Some(Frame { + visible: window.is_visible().ok()?, + size: Size(size.width, size.height), + scale: window.scale_factor().ok()?, + mode: Mode { + maximized: window.is_maximized().ok()?, + fullscreen: window.is_fullscreen().ok()?, + minimized: window.is_minimized().ok()?, + }, + position: window.outer_position().ok(), + monitors: window.available_monitors().unwrap_or_default(), + work_area: window + .current_monitor() + .ok() + .flatten() + .map(|m| Size(m.work_area().size.width, m.work_area().size.height)), + }) + } + + fn key(label: &str) -> &str { + if crate::windows::is_settings_window(label) { "settings" } else { label } + } + + pub fn prepare(app: &AppHandle, config: &mut WindowConfig) -> bool { + let hint_monitor = app.primary_monitor().ok().flatten(); + let hint_scale = hint_monitor.as_ref().map_or(1.0, |m| m.scale_factor()); + let hint_area = hint_monitor.map(|m| Size(m.work_area().size.width, m.work_area().size.height)); + let cache = app.state::>(); + let mut cache = cache.lock(); + let saved = cache.saved.get(key(&config.label)).cloned().unwrap_or_else(|| Geometry { + maximized: config.maximized, + fullscreen: config.fullscreen, + ..Default::default() + }); + cache.next_request += 1; + let presentation_request = cache.next_request; + let pending = Pending { + presentation_request, + limits: Limits { + min: (config.min_width.unwrap_or(1.0), config.min_height.unwrap_or(1.0)), + max: (config.max_width, config.max_height), + }, + normal: Size(saved.width, saved.height), + saved, + restore_position: true, + ..Default::default() + }; + if pending.saved.width > 0 && pending.saved.height > 0 { + // This only reduces the first-frame jump; mapped geometry is authoritative. + let size = restore_size(pending.normal, hint_scale, pending.limits.in_work_area(hint_area, hint_scale)); + config.width = f64::from(size.0) / hint_scale; + config.height = f64::from(size.1) / hint_scale; + } + cache.windows.insert(config.label.clone(), pending); + // Establish a normal window before asking GTK to maximize or fullscreen it. + config.maximized = false; + config.fullscreen = false; + build_hidden(config) + } + + fn sample(window: &Window, allow_restore: bool, accept_constrained_size: bool) { + let Some(frame) = frame(window) else { return }; + if !frame.visible { + return; + } + let cache = window.state::>(); + let mut cache = cache.lock(); + cache.next_request += 1; + let request = cache.next_request; + let Some(pending) = cache.windows.get_mut(window.label()) else { return }; + if !pending.presented && pending.saved.width == 0 { + pending.normal = remember_size(pending.normal, frame.size, false, frame.mode); + } + if !pending.configured || !pending.presented { + return; + } + if !allow_restore && !matches!(pending.phase, Phase::Tracking) { + return; + } + let transition = if matches!(pending.phase, Phase::Tracking) { + pending.history.observe(frame.mode, allow_restore) + } else { + NormalTransition::Stable + }; + if transition == NormalTransition::WaitForResize { + let normal = pending.normal; + let saved = cache.saved.entry(key(window.label()).to_owned()).or_default(); + (saved.width, saved.height) = (normal.0, normal.1); + saved.maximized = frame.mode.maximized; + saved.fullscreen = frame.mode.fullscreen; + return; + } + if transition == NormalTransition::Restore { + // The compositor may have changed output/DPI while maximized or minimized. + pending.saved.set_normal_size(pending.normal); + pending.phase = Phase::Mapping; + pending.mode_requested = false; + } + let mut fit_settings = false; + let mut position = None; + if matches!(pending.phase, Phase::Mapping) { + if pending.restore_position { + pending.restore_position = false; + let saved = &pending.saved; + let (x, y) = if saved.maximized { (saved.prev_x, saved.prev_y) } else { (saved.x, saved.y) }; + if frame.monitors.iter().any(|m| { + let p = m.position(); + let s = m.size(); + i64::from(x) < i64::from(p.x) + i64::from(s.width) + && i64::from(x) + i64::from(saved.width) > i64::from(p.x) + && i64::from(y) < i64::from(p.y) + i64::from(s.height) + && i64::from(y) + i64::from(saved.height) > i64::from(p.y) + }) { + position = Some(tauri::PhysicalPosition::new(x, y)); + } + } + if pending.saved.width > 0 && pending.saved.height > 0 { + let target = restore_size( + Size(pending.saved.width, pending.saved.height), + frame.scale, + pending.limits.in_work_area(frame.work_area, frame.scale), + ); + pending.phase = Phase::Size { target, scale: frame.scale, request: 0 }; + } else { + pending.normal = remember_size(pending.normal, frame.size, false, frame.mode); + pending.phase = Phase::Mode; + } + } + if let Phase::Size { target, scale, request: sent } = pending.phase { + if frame.size != target && !accept_constrained_size { + if sent != 0 && scale == frame.scale { + return; + } + // Re-evaluate after output scale changes, rather than reusing monitor 0's scale. + let target = restore_size( + Size(pending.saved.width, pending.saved.height), + frame.scale, + pending.limits.in_work_area(frame.work_area, frame.scale), + ); + pending.phase = Phase::Size { target, scale: frame.scale, request }; + drop(cache); + if let Some(position) = position { + let _ = window.set_position(position); + } + if let Err(error) = window.set_size(tauri::PhysicalSize::new(target.0, target.1)) { + tracing::warn!("cannot restore Linux window size: {error}"); + } + settle_constrained_size(window, request); + return; + } + if scale != frame.scale && !accept_constrained_size { + pending.phase = Phase::Mapping; + drop(cache); + sample(window, true, false); + return; + } + pending.normal = remember_size(pending.normal, frame.size, false, frame.mode); + pending.phase = Phase::Mode; + } + if matches!(pending.phase, Phase::Mode) { + let (maximize, fullscreen) = (pending.saved.maximized, pending.saved.fullscreen); + let reached = if fullscreen { frame.mode.fullscreen } else { !maximize || frame.mode.maximized }; + let action = mode_action(reached, pending.mode_requested, accept_constrained_size); + if action == ModeAction::Wait { + return; + } + if action == ModeAction::Request { + pending.mode_requested = true; + pending.mode_request = request; + let mut snapshot = pending.saved.clone(); + (snapshot.width, snapshot.height) = (pending.normal.0, pending.normal.1); + cache.saved.insert(key(window.label()).to_owned(), snapshot); + drop(cache); + if let Some(position) = position { + let _ = window.set_position(position); + } + if maximize { + let _ = window.maximize(); + } + if fullscreen { + let _ = window.set_fullscreen(true); + } + settle_constrained_size(window, request); + return; + } + pending.phase = Phase::Tracking; + fit_settings = crate::windows::is_settings_window(window.label()); + } + pending.history = ModeHistory(frame.mode); + pending.normal = remember_size(pending.normal, frame.size, false, frame.mode); + let normal = pending.normal; + let saved = cache.saved.entry(key(window.label()).to_owned()).or_default(); + (saved.width, saved.height) = (normal.0, normal.1); + saved.maximized = frame.mode.maximized; + saved.fullscreen = frame.mode.fullscreen; + if frame.mode.normal() + && let Some(pos) = frame.position + { + (saved.x, saved.y, saved.prev_x, saved.prev_y) = (pos.x, pos.y, pos.x, pos.y); + } + drop(cache); + if let Some(position) = position { + let _ = window.set_position(position); + } + if fit_settings { + let app = window.app_handle().clone(); + let label = window.label().to_owned(); + let _ = window.run_on_main_thread(move || { + if let Some(parent) = crate::windows::settings_parent(&label).and_then(|p| app.get_webview_window(p)) + && let Some(settings) = app.get_webview_window(&label) + && let Err(error) = crate::windows::place_settings_window(&settings, &parent) + { + tracing::warn!("cannot fit restored settings window: {error}"); + } + }); + } + } + + fn settle_constrained_size(window: &Window, request: u64) { + let window = window.clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + let cloned = window.clone(); + let _ = window.run_on_main_thread(move || { + let waiting = cloned.state::>().lock().windows.get(cloned.label()).is_some_and(|p| { + matches!(p.phase, Phase::Size { request: current, .. } if current == request) + || matches!(p.phase, Phase::Mode) && p.mode_request == request + }); + if waiting { + // Window-manager constraints may reject a size without producing an exact match. + sample(&cloned, true, true); + } + }); + }); + } + + pub fn normal_size_requested(app: &AppHandle, label: &str, size: tauri::PhysicalSize) { + let cache = app.state::>(); + let mut cache = cache.lock(); + cache.next_request += 1; + let request = cache.next_request; + if let Some(pending) = cache.windows.get_mut(label) { + pending.normal = Size(size.width, size.height); + pending.saved.set_normal_size(pending.normal); + pending.mode_requested = false; + pending.presented = false; + pending.presentation_request = request; + pending.phase = Phase::Mapping; + } + let saved = cache.saved.entry(key(label).to_owned()).or_default(); + saved.set_normal_size(Size(size.width, size.height)); + drop(cache); + if let Some(webview) = app.get_webview_window(label) { + request_presentation(webview.as_ref(), request); + } + } + + fn request_presentation(webview: &tauri::Webview, request: u64) { + // A configure precedes output-enter. A second animation frame follows the + // first paint, allowing GTK to learn the scale of the assigned output. + let script = r#" +(() => { + const state = window.__OPENIDEA_WINDOW_GEOMETRY__ ??= { request: 0, scheduled: false }; + state.request = __REQUEST_ID__; + state.ready ??= () => { + if (document.hidden || state.scheduled) return; + const request = state.request; + state.scheduled = true; + requestAnimationFrame(() => requestAnimationFrame(() => { + state.scheduled = false; + if (document.hidden) return; + const api = window.__TAURI_INTERNALS__; + const label = api?.metadata?.currentWindow?.label; + if (label) api.invoke('plugin:event|emit', { + event: 'openidea://window-state-presented', payload: { label, request } + }); + if (request !== state.request) state.ready(); + })); + }; + if (!state.listening) { + state.listening = true; + document.addEventListener('visibilitychange', state.ready); + } + state.ready(); +})(); +"# + .replace("__REQUEST_ID__", &request.to_string()); + let _ = webview.eval(script); + } + + pub fn save_before_close(app: &AppHandle) { + for window in app.webview_windows().values() { + sample(&window.as_ref().window(), false, false); + } + persist(app); + } + + fn persist(app: &AppHandle) { + let write = || -> Result<(), Box> { + let dir = app.path().app_config_dir()?; + std::fs::create_dir_all(&dir)?; + let cache = app.state::>(); + let data = serde_json::to_vec_pretty(&cache.lock().saved)?; + let temporary = dir.join(format!("{FILE}.tmp")); + std::fs::write(&temporary, data)?; + std::fs::rename(temporary, dir.join(FILE))?; + Ok(()) + }; + if let Err(error) = write() { + tracing::warn!("cannot save Linux window state: {error}"); + } + } + + pub fn plugin() -> TauriPlugin { + tauri::plugin::Builder::new("linux-window-state") + .setup(|app, _| { + let saved = app + .path() + .app_config_dir() + .ok() + .and_then(|dir| std::fs::read(dir.join(FILE)).ok()) + .and_then(|data| serde_json::from_slice(&data).ok()) + .unwrap_or_default(); + app.manage(Mutex::new(Cache { saved, ..Default::default() })); + let handle = app.clone(); + app.listen("openidea://window-state-presented", move |event| { + #[derive(serde::Deserialize)] + struct Presented { + label: String, + request: u64, + } + let Ok(presented) = serde_json::from_str::(event.payload()) else { return }; + let Some(webview) = handle.get_webview_window(&presented.label) else { return }; + let window = webview.as_ref().window(); + let cloned = window.clone(); + let _ = window.run_on_main_thread(move || { + if !cloned.is_visible().unwrap_or(false) { + return; + } + if let Some(pending) = cloned.state::>().lock().windows.get_mut(cloned.label()) { + if pending.presentation_request != presented.request { + return; + } + pending.presented = true; + } + sample(&cloned, true, false); + }); + }); + Ok(()) + }) + .on_page_load(|webview, payload| { + if payload.event() == tauri::webview::PageLoadEvent::Finished { + let request = webview + .state::>() + .lock() + .windows + .get(webview.window().label()) + .map(|p| p.presentation_request); + if let Some(request) = request { + request_presentation(webview, request); + } + } + }) + .on_window_ready(|window| { + let cloned = window.clone(); + window.on_window_event(move |event| match event { + WindowEvent::Resized(_) => { + if let Some(pending) = cloned.state::>().lock().windows.get_mut(cloned.label()) { + pending.configured = true; + } + let request = cloned + .state::>() + .lock() + .windows + .get(cloned.label()) + .filter(|p| !p.presented) + .map(|p| p.presentation_request); + if let Some(request) = request + && let Some(webview) = cloned.app_handle().get_webview_window(cloned.label()) + { + request_presentation(webview.as_ref(), request); + } + sample(&cloned, true, false); + } + WindowEvent::Moved(_) => sample(&cloned, false, false), + WindowEvent::Destroyed => { + cloned.state::>().lock().windows.remove(cloned.label()); + } + _ => {} + }); + }) + .on_event(|app, event| { + if let tauri::RunEvent::Exit = event { + persist(app); + } + }) + .build() + } +} + +#[cfg(target_os = "linux")] +pub use runtime::{normal_size_requested, plugin, prepare, save_before_close}; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hidden_creation_preserves_geometry() { + let mut config = WindowConfig { width: 1201.0, height: 801.0, ..Default::default() }; + assert!(build_hidden(&mut config)); + assert!(!config.visible); + assert_eq!((config.width, config.height), (1201.0, 801.0)); + assert!(!build_hidden(&mut config)); + } +} diff --git a/src-tauri/src/linux_window_state/geometry.rs b/src-tauri/src/linux_window_state/geometry.rs new file mode 100644 index 00000000..4bd747b4 --- /dev/null +++ b/src-tauri/src/linux_window_state/geometry.rs @@ -0,0 +1,237 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(super) struct Size(pub(super) u32, pub(super) u32); + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(super) struct Mode { + pub(super) maximized: bool, + pub(super) fullscreen: bool, + pub(super) minimized: bool, +} + +impl Mode { + pub(super) fn normal(self) -> bool { + !self.maximized && !self.fullscreen && !self.minimized + } +} + +#[derive(Clone, Copy, Debug, Default)] +pub(super) struct ModeHistory(pub(super) Mode); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum NormalTransition { + Stable, + WaitForResize, + Restore, +} + +impl ModeHistory { + pub(super) fn observe(&mut self, current: Mode, can_restore: bool) -> NormalTransition { + let returned = !self.0.normal() && current.normal(); + if returned && !can_restore { + return NormalTransition::WaitForResize; + } + self.0 = current; + if returned { NormalTransition::Restore } else { NormalTransition::Stable } + } +} + +#[derive(Clone, Copy, Debug, Default)] +pub(super) struct Limits { + pub(super) min: (f64, f64), + pub(super) max: (Option, Option), +} + +impl Limits { + pub(super) fn in_work_area(self, area: Option, scale: f64) -> Self { + let Some(Size(width, height)) = area.filter(|s| s.0 > 0 && s.1 > 0) else { return self }; + Self { + max: ( + Some(self.max.0.unwrap_or(f64::INFINITY).min((f64::from(width) / scale).floor())), + Some(self.max.1.unwrap_or(f64::INFINITY).min((f64::from(height) / scale).floor())), + ), + ..self + } + } +} + +pub(super) fn restore_size(saved: Size, scale: f64, limits: Limits) -> Size { + let dimension = |pixels: u32, min: f64, max: Option| { + let logical = (f64::from(pixels) / scale).max(min).max(1.0); + // gtk_window_resize takes integer logical dimensions. + (logical.min(max.unwrap_or(f64::INFINITY).max(min)).round() * scale).round() as u32 + }; + Size(dimension(saved.0, limits.min.0, limits.max.0), dimension(saved.1, limits.min.1, limits.max.1)) +} + +pub(super) fn remember_size(previous: Size, current: Size, restoring: bool, mode: Mode) -> Size { + if restoring || !mode.normal() || current.0 == 0 || current.1 == 0 { previous } else { current } +} + +#[derive(Debug, PartialEq, Eq)] +pub(super) enum ModeAction { + Request, + Wait, + Track, +} + +pub(super) fn mode_action(reached: bool, requested: bool, timed_out: bool) -> ModeAction { + if reached { + ModeAction::Track + } else if !requested { + ModeAction::Request + } else if timed_out { + ModeAction::Track + } else { + ModeAction::Wait + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(default)] +pub(super) struct Geometry { + pub(super) width: u32, + pub(super) height: u32, + pub(super) x: i32, + pub(super) y: i32, + pub(super) prev_x: i32, + pub(super) prev_y: i32, + pub(super) maximized: bool, + pub(super) fullscreen: bool, + pub(super) visible: bool, + pub(super) decorated: bool, +} + +impl Geometry { + pub(super) fn set_normal_size(&mut self, size: Size) { + (self.width, self.height) = (size.0, size.1); + self.maximized = false; + self.fullscreen = false; + } +} + +impl Default for Geometry { + fn default() -> Self { + Self { + width: 0, + height: 0, + x: 0, + y: 0, + prev_x: 0, + prev_y: 0, + maximized: false, + fullscreen: false, + visible: true, + decorated: true, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn constrained_size_does_not_skip_the_first_mode_request() { + assert_eq!(mode_action(false, false, true), ModeAction::Request); + assert_eq!(mode_action(false, false, false), ModeAction::Request); + assert_eq!(mode_action(false, true, false), ModeAction::Wait); + assert_eq!(mode_action(false, true, true), ModeAction::Track); + assert_eq!(mode_action(true, false, false), ModeAction::Track); + } + + #[test] + fn physical_sizes_use_the_actual_output_scale() { + for scale in [1.0, 2.0] { + assert_eq!(restore_size(Size(2468, 1356), scale, Limits::default()), Size(2468, 1356)); + } + // Saved at scale 1, mapped on scale 2: keep physical pixels, not twice that size. + assert_eq!(restore_size(Size(1234, 678), 2.0, Limits::default()), Size(1234, 678)); + } + + #[test] + fn gtk_limits_and_integer_logical_dimensions_are_applied_once() { + let limits = Limits { min: (800.0, 500.0), max: (Some(1600.0), Some(1000.0)) }; + assert_eq!(restore_size(Size(1201, 801), 1.0, limits), Size(1201, 801)); + assert_eq!(restore_size(Size(1201, 801), 2.0, limits), Size(1600, 1000)); + assert_eq!(restore_size(Size(2001, 1401), 2.0, limits), Size(2002, 1402)); + assert_eq!(restore_size(Size(9000, 6000), 2.0, limits), Size(3200, 2000)); + } + + #[test] + fn damaged_sizes_are_bounded_in_the_actual_outputs_units() { + let limits = Limits { min: (800.0, 500.0), ..Default::default() }; + for scale in [1.0, 2.0] { + let bounded = limits.in_work_area(Some(Size(3840, 2160)), scale); + assert_eq!(restore_size(Size(45068, 30000), scale, bounded), Size(3840, 2160)); + assert_eq!(restore_size(Size(2468, 1356), scale, bounded), Size(2468, 1356)); + } + } + + #[test] + fn restoration_and_non_normal_events_preserve_the_normal_size() { + let normal = Size(1234, 678); + for current in [Size(2468, 1356), Size(2648, 1602), Size(5476, 3204)] { + assert_eq!(remember_size(normal, current, true, Mode::default()), normal); + for mode in [ + Mode { maximized: true, ..Default::default() }, + Mode { fullscreen: true, ..Default::default() }, + Mode { minimized: true, ..Default::default() }, + ] { + assert_eq!(remember_size(normal, current, false, mode), normal); + } + } + assert_eq!(remember_size(normal, Size(1201, 801), false, Mode::default()), Size(1201, 801)); + assert_eq!(remember_size(normal, Size(0, 678), false, Mode::default()), normal); + } + + #[test] + fn a_mixed_dpi_maximized_launch_does_not_compound() { + let mut saved = Size(2468, 1356); + for _ in 0..3 { + saved = remember_size(saved, Size(saved.0 * 2, saved.1 * 2), true, Mode::default()); + let restored = restore_size(saved, 2.0, Limits::default()); + saved = remember_size(saved, restored, false, Mode::default()); + saved = remember_size(saved, Size(3840, 2160), false, Mode { maximized: true, ..Default::default() }); + assert_eq!(saved, Size(2468, 1356)); + } + } + + #[test] + fn moved_does_not_consume_the_unmaximize_resize() { + for mode in [ + Mode { maximized: true, ..Default::default() }, + Mode { fullscreen: true, ..Default::default() }, + Mode { minimized: true, ..Default::default() }, + ] { + let mut history = ModeHistory(mode); + assert_eq!(history.observe(Mode::default(), false), NormalTransition::WaitForResize); + assert_eq!(history.0, mode); + assert_eq!(history.observe(Mode::default(), true), NormalTransition::Restore); + assert_eq!(history.observe(Mode::default(), true), NormalTransition::Stable); + } + } + + #[test] + fn an_explicit_normal_frame_overrides_old_window_modes() { + let mut saved = Geometry { maximized: true, fullscreen: true, x: 123, y: 456, ..Default::default() }; + saved.set_normal_size(Size(1100, 700)); + assert_eq!((saved.width, saved.height), (1100, 700)); + assert!(!saved.maximized && !saved.fullscreen); + assert_eq!((saved.x, saved.y), (123, 456)); + } + + #[test] + fn state_files_remain_compatible_and_keep_closed_windows() { + let saved: std::collections::HashMap = serde_json::from_str( + r#"{"main":{"width":1234,"height":678,"maximized":true},"settings":{"width":1001,"height":701}}"#, + ) + .unwrap(); + let copy: std::collections::HashMap = + serde_json::from_value(serde_json::to_value(&saved).unwrap()).unwrap(); + assert_eq!(copy["settings"].height, 701); + assert!(copy["main"].maximized); + assert_eq!(copy["main"].width, 1234); + } +} diff --git a/src-tauri/src/window_groups.rs b/src-tauri/src/window_groups.rs index 4aaea46d..66fe0c88 100644 --- a/src-tauri/src/window_groups.rs +++ b/src-tauri/src/window_groups.rs @@ -60,7 +60,10 @@ fn copy_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> { if !maximized { to.unmaximize().map_err(err)?; to.set_position(from.outer_position().map_err(err)?).map_err(err)?; - to.set_size(from.inner_size().map_err(err)?).map_err(err)?; + let size = from.inner_size().map_err(err)?; + #[cfg(target_os = "linux")] + crate::linux_window_state::normal_size_requested(to.app_handle(), to.label(), size); + to.set_size(size).map_err(err)?; } else { to.maximize().map_err(err)?; } diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index 54b6a35c..f905ed51 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -298,6 +298,8 @@ fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult< } else if let Some(a) = &appearance { config.background_color = Some(frame_color(a, false)); } + #[cfg(target_os = "linux")] + let show_after_build = crate::linux_window_state::prepare(app, &mut config); let window = WebviewWindowBuilder::from_config(app, &config) .and_then(|b| dev_font_preferences(b).build()) .map_err(|e| IdeError::internal(format!("cannot create window: {e}")))?; @@ -310,6 +312,10 @@ fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult< { let _ = window.set_background_color(Some(frame_color(&a, true))); } + #[cfg(target_os = "linux")] + if show_after_build { + window.show().map_err(|e| IdeError::internal(format!("cannot show window: {e}")))?; + } if visible { window.show().map_err(|e| IdeError::internal(format!("cannot show window: {e}")))?; if focused { @@ -765,6 +771,8 @@ pub fn open_settings_window( config.background_color = Some(color); } windows.set_launch(&label, Launch { settings_page: page, new_projects, ..Default::default() }); + #[cfg(target_os = "linux")] + crate::linux_window_state::prepare(app, &mut config); let builder = WebviewWindowBuilder::from_config(app, &config); // AppKit child windows can disappear when dragged to another display's Space. // The settings label already tracks ownership and parent-close cleanup. @@ -832,7 +840,7 @@ fn fit_settings_window(window: &WebviewWindow, parent: &WebviewWindow) -> tauri: /// may still be on another display with a different scale, so its size is converted with its own /// scale and its position is given in physical pixels. #[cfg(not(target_os = "macos"))] -fn place_settings_window(window: &WebviewWindow, parent: &WebviewWindow) -> tauri::Result<()> { +pub(super) fn place_settings_window(window: &WebviewWindow, parent: &WebviewWindow) -> tauri::Result<()> { let Some(monitor) = parent.current_monitor()?.or(parent.primary_monitor()?) else { return Ok(()); };