mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
fix(linux): restore window geometry after GTK mapping (#619)
On Linux the window-state plugin restored the saved size before GTK's decorations and output scale had settled, so the saved physical inner size went through the creation-time CSD/scale conversion and drifted on every restart (52 px per axis under Mutter, the remembered size collapsing on other setups), and maximize transitions overwrote the remembered normal size. Linux now builds project and settings windows hidden and restores them after the first configure, keeping the normal size apart from maximize, fullscreen and minimize, protecting it during transitions, and snapshotting each window before close instead of querying GTK during exit. The state file format is unchanged; macOS and Windows keep the plugin as before. Verified under Mutter and Weston: three restarts at 1234×678, three maximized restarts followed by unmaximize, the settings window, several project windows across a full exit, and window groups all restore exactly. macOS restore is unchanged. Not run: GNOME on real hardware, mixed-DPI monitors, X11. Fixes #594 Fixes #636
This commit is contained in:
+16
-9
@@ -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::<WindowsState>() {
|
||||
@@ -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
|
||||
|
||||
@@ -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<String, Geometry>,
|
||||
windows: HashMap<String, Pending>,
|
||||
next_request: u64,
|
||||
}
|
||||
|
||||
struct Frame {
|
||||
size: Size,
|
||||
scale: f64,
|
||||
mode: Mode,
|
||||
position: Option<tauri::PhysicalPosition<i32>>,
|
||||
monitors: Vec<tauri::Monitor>,
|
||||
work_area: Option<Size>,
|
||||
visible: bool,
|
||||
}
|
||||
|
||||
fn frame(window: &Window) -> Option<Frame> {
|
||||
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::<Mutex<Cache>>();
|
||||
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::<Mutex<Cache>>();
|
||||
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::<Mutex<Cache>>().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<u32>) {
|
||||
let cache = app.state::<Mutex<Cache>>();
|
||||
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<dyn std::error::Error>> {
|
||||
let dir = app.path().app_config_dir()?;
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let cache = app.state::<Mutex<Cache>>();
|
||||
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::Wry> {
|
||||
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::<Presented>(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::<Mutex<Cache>>().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::<Mutex<Cache>>()
|
||||
.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::<Mutex<Cache>>().lock().windows.get_mut(cloned.label()) {
|
||||
pending.configured = true;
|
||||
}
|
||||
let request = cloned
|
||||
.state::<Mutex<Cache>>()
|
||||
.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::<Mutex<Cache>>().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));
|
||||
}
|
||||
}
|
||||
@@ -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<f64>, Option<f64>),
|
||||
}
|
||||
|
||||
impl Limits {
|
||||
pub(super) fn in_work_area(self, area: Option<Size>, 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<f64>| {
|
||||
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<String, Geometry> = serde_json::from_str(
|
||||
r#"{"main":{"width":1234,"height":678,"maximized":true},"settings":{"width":1001,"height":701}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let copy: std::collections::HashMap<String, Geometry> =
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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)?;
|
||||
}
|
||||
|
||||
@@ -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(());
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user