Files
openidea/src-tauri/src/linux_window_state.rs
T
xdpan1234andxdpan1234 0a70b0af01 fix(vcs): open comparisons in independently resizable native windows (#818)
Compare with Local, Compare Versions and the other revision comparisons open in a separate window that belongs to their project window. The window can be moved outside the main window, resized from its edges and corners, and shows the full diff. Edits made there and in the project window are mirrored both ways.

Root cause of #798: the comparison was an in-window dialog with a fixed size.

Settings and comparison windows now share one auxiliary-window mechanism (`AuxKind`, `open_auxiliary_window`). It handles labels, the title bar, placement over the parent, event forwarding, document-edit mirroring, menu routing, closing together with the parent, and remembered geometry per kind.

Co-authored-by: xdpan1234 <277608284+xdpan1234@users.noreply.github.com>
2026-11-04 12:27:45 +08:00

522 lines
22 KiB
Rust

//! 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 std::sync::OnceLock;
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,
}
static WAYLAND: OnceLock<bool> = OnceLock::new();
/// GDK's backend, as tao tests it (`GDK_BACKEND=x11` under a Wayland session is X11).
/// Known once the plugin is set up, before any window exists.
pub fn is_wayland() -> bool {
WAYLAND.get().copied().unwrap_or(false)
}
/// The client size that `set_size` (`gtk_window_resize`) takes. On Wayland, decorated windows
/// get a GTK CSD shadow margin that tao's inner and outer sizes (the configure size) include.
pub fn content_size(window: &Window) -> tauri::Result<tauri::PhysicalSize<u32>> {
let inner = window.inner_size()?;
if !is_wayland() {
return Ok(inner);
}
// GTK objects belong to the main thread. `gtk_window_get_size` follows the configure
// synchronously; the webview's allocation only catches up on the next layout.
let (tx, rx) = std::sync::mpsc::sync_channel(1);
let cloned = window.clone();
window.run_on_main_thread(move || {
let sizes = cloned.gtk_window().ok().zip(cloned.inner_size().ok()).zip(cloned.scale_factor().ok());
let _ = tx.send(sizes.map(|((gtk, inner), scale)| {
let (width, height) = gtk::prelude::GtkWindowExt::size(&gtk);
let content = tauri::LogicalSize::new(width.max(0) as u32, height.max(0) as u32);
(content.to_physical::<u32>(scale), inner)
}));
})?;
let Some((content, inner)) = rx.recv().ok().flatten() else { return Ok(inner) };
// An unmapped window reports its size request; never exceed the configure size.
let usable =
content.width > 1 && content.height > 1 && content.width <= inner.width && content.height <= inner.height;
Ok(if usable { content } else { inner })
}
fn frame(window: &Window) -> Option<Frame> {
let size = content_size(window).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 {
crate::windows::state_key(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 place_aux = 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;
place_aux = crate::windows::auxiliary_parent(window.label()).is_some();
}
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 place_aux {
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::auxiliary_parent(&label).and_then(|p| app.get_webview_window(p))
&& let Some(aux) = app.get_webview_window(&label)
&& let Err(error) = crate::windows::place_over_parent(&aux, &parent)
{
tracing::warn!("cannot fit restored auxiliary 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, _| {
// Plugins are set up on the main thread once GTK is initialised, before any window exists.
use gtk::gdk::prelude::DisplayExtManual;
let _ = WAYLAND.set(gtk::gdk::Display::default().is_some_and(|d| d.backend().is_wayland()));
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::{content_size, is_wayland, 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));
}
}