Files
tolaria/src-tauri/src/window_state.rs
2026-05-03 20:00:37 +02:00

568 lines
17 KiB
Rust

use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use tauri::{
App, AppHandle, LogicalPosition, LogicalSize, Manager, Position, RunEvent, Size, WebviewWindow,
WindowEvent,
};
pub(crate) const MAIN_WINDOW_LABEL: &str = "main";
const WINDOW_STATE_FILE: &str = "window-state.json";
const MIN_WINDOW_WIDTH: u32 = 480;
const MIN_WINDOW_HEIGHT: u32 = 400;
#[derive(Debug, Default)]
pub(crate) struct MainWindowFrameState(Mutex<Option<WindowFrame>>);
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
struct WindowFrame {
x: i32,
y: i32,
width: u32,
height: u32,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
struct ScreenArea {
x: i32,
y: i32,
width: u32,
height: u32,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct PersistedWindowState {
main: Option<WindowFrame>,
#[serde(default)]
coordinate_space: WindowFrameCoordinateSpace,
}
#[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
enum WindowFrameCoordinateSpace {
#[default]
Physical,
Logical,
}
pub(crate) fn restore_main_window_state(app: &mut App) {
let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) else {
return;
};
restore_main_window_frame(app.handle(), &window, "during setup");
}
pub(crate) fn handle_run_event(app_handle: &AppHandle, event: &RunEvent) {
match event {
event if restores_window_frame_after_runtime_ready(event) => {
restore_main_window_state_from_handle(app_handle)
}
RunEvent::WindowEvent {
label,
event:
WindowEvent::Moved(_) | WindowEvent::Resized(_) | WindowEvent::ScaleFactorChanged { .. },
..
} if label == MAIN_WINDOW_LABEL => cache_current_normal_frame(app_handle),
RunEvent::WindowEvent {
label,
event: WindowEvent::CloseRequested { .. } | WindowEvent::Destroyed,
..
} if label == MAIN_WINDOW_LABEL => save_main_window_frame(app_handle),
RunEvent::Exit => save_main_window_frame(app_handle),
_ => {}
}
}
fn restores_window_frame_after_runtime_ready(event: &RunEvent) -> bool {
matches!(event, RunEvent::Ready)
}
fn restore_main_window_state_from_handle(app_handle: &AppHandle) {
let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) else {
return;
};
restore_main_window_frame(app_handle, &window, "after runtime ready");
}
fn restore_main_window_frame(app_handle: &AppHandle, window: &WebviewWindow, phase: &str) {
let Some(frame) = read_main_window_frame(window_scale_factor(window)) else {
return;
};
let areas = current_screen_areas(window);
let Some(restored_frame) = fit_frame_to_screens(frame, &areas) else {
return;
};
if let Err(err) = apply_window_frame(window, restored_frame) {
log::warn!("Failed to restore main window state {phase}: {err}");
return;
}
cache_frame(app_handle, restored_frame);
}
fn cache_current_normal_frame(app_handle: &AppHandle) {
if let Some(frame) = current_normal_main_window_frame(app_handle) {
cache_frame(app_handle, frame);
}
}
fn save_main_window_frame(app_handle: &AppHandle) {
let frame = current_normal_main_window_frame(app_handle).or_else(|| cached_frame(app_handle));
if let Some(frame) = frame {
if let Err(err) = write_main_window_frame(frame) {
log::warn!("Failed to save main window state: {err}");
}
}
}
fn current_normal_main_window_frame(app_handle: &AppHandle) -> Option<WindowFrame> {
let window = app_handle.get_webview_window(MAIN_WINDOW_LABEL)?;
if !is_normal_window(&window) {
return None;
}
read_window_frame(&window).filter(is_valid_saved_frame)
}
fn is_normal_window(window: &WebviewWindow) -> bool {
let is_fullscreen = window.is_fullscreen().unwrap_or(false);
let is_maximized = window.is_maximized().unwrap_or(false);
let is_minimized = window.is_minimized().unwrap_or(false);
!is_fullscreen && !is_maximized && !is_minimized
}
fn read_window_frame(window: &WebviewWindow) -> Option<WindowFrame> {
let scale_factor = window_scale_factor(window);
let position = window.outer_position().ok()?;
let size = window.inner_size().ok()?;
Some(WindowFrame::from_logical_geometry(
position.to_logical::<f64>(scale_factor),
size.to_logical::<f64>(scale_factor),
))
}
fn apply_window_frame(window: &WebviewWindow, frame: WindowFrame) -> tauri::Result<()> {
window.set_size(Size::Logical(LogicalSize::new(
frame.width as f64,
frame.height as f64,
)))?;
window.set_position(Position::Logical(LogicalPosition::new(
frame.x as f64,
frame.y as f64,
)))
}
fn current_screen_areas(window: &WebviewWindow) -> Vec<ScreenArea> {
let scale_factor = window_scale_factor(window);
window
.available_monitors()
.unwrap_or_default()
.into_iter()
.map(|monitor| {
let area = monitor.work_area();
let position = area.position.to_logical::<f64>(scale_factor);
let size = area.size.to_logical::<f64>(scale_factor);
ScreenArea {
x: rounded_i32(position.x),
y: rounded_i32(position.y),
width: rounded_u32(size.width),
height: rounded_u32(size.height),
}
})
.filter(ScreenArea::has_area)
.collect()
}
fn window_scale_factor(window: &WebviewWindow) -> f64 {
window.scale_factor().unwrap_or(1.0).max(1.0)
}
fn fit_frame_to_screens(frame: WindowFrame, screens: &[ScreenArea]) -> Option<WindowFrame> {
if frame_is_visible(frame, screens) {
return Some(frame);
}
let screen = best_screen_for_frame(frame, screens)?;
let width = clamp_dimension(frame.width, MIN_WINDOW_WIDTH, screen.width);
let height = clamp_dimension(frame.height, MIN_WINDOW_HEIGHT, screen.height);
Some(WindowFrame {
x: clamp_axis(frame.x, width, screen.x, screen.width),
y: clamp_axis(frame.y, height, screen.y, screen.height),
width,
height,
})
}
fn frame_is_visible(frame: WindowFrame, screens: &[ScreenArea]) -> bool {
frame_corners(frame)
.into_iter()
.all(|point| screens.iter().any(|screen| screen.contains(point)))
}
fn frame_corners(frame: WindowFrame) -> [(i32, i32); 4] {
let right = frame.right() - 1;
let bottom = frame.bottom() - 1;
[
(frame.x, frame.y),
(right, frame.y),
(frame.x, bottom),
(right, bottom),
]
}
fn best_screen_for_frame(frame: WindowFrame, screens: &[ScreenArea]) -> Option<ScreenArea> {
screens
.iter()
.copied()
.filter(ScreenArea::has_area)
.max_by_key(|screen| intersection_area(frame, *screen))
}
fn intersection_area(frame: WindowFrame, screen: ScreenArea) -> u64 {
let left = frame.x.max(screen.x);
let top = frame.y.max(screen.y);
let right = frame.right().min(screen.right());
let bottom = frame.bottom().min(screen.bottom());
if right <= left || bottom <= top {
return 0;
}
(right - left) as u64 * (bottom - top) as u64
}
fn clamp_dimension(value: u32, min: u32, max: u32) -> u32 {
if max < min {
max
} else {
value.clamp(min, max)
}
}
fn clamp_axis(value: i32, size: u32, area_start: i32, area_size: u32) -> i32 {
let max_start = area_start + area_size as i32 - size as i32;
if max_start < area_start {
return area_start;
}
value.clamp(area_start, max_start)
}
fn cache_frame(app_handle: &AppHandle, frame: WindowFrame) {
let state: tauri::State<'_, MainWindowFrameState> = app_handle.state();
if let Ok(mut cached_frame) = state.0.lock() {
*cached_frame = Some(frame);
};
}
fn cached_frame(app_handle: &AppHandle) -> Option<WindowFrame> {
let state: tauri::State<'_, MainWindowFrameState> = app_handle.state();
state.0.lock().ok().and_then(|cached_frame| *cached_frame)
}
fn window_state_path() -> Result<PathBuf, String> {
crate::settings::preferred_app_config_path(WINDOW_STATE_FILE)
}
fn read_main_window_frame(scale_factor: f64) -> Option<WindowFrame> {
read_main_window_frame_at(&window_state_path().ok()?, scale_factor)
}
fn read_main_window_frame_at(path: &Path, scale_factor: f64) -> Option<WindowFrame> {
let content = fs::read_to_string(path).ok()?;
let persisted: PersistedWindowState = serde_json::from_str(&content).ok()?;
persisted
.main
.map(|frame| {
persisted
.coordinate_space
.to_logical_frame(frame, scale_factor)
})
.filter(is_valid_saved_frame)
}
fn write_main_window_frame(frame: WindowFrame) -> Result<(), String> {
write_main_window_frame_at(&window_state_path()?, frame)
}
fn write_main_window_frame_at(path: &Path, frame: WindowFrame) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create window state directory: {e}"))?;
}
let persisted = PersistedWindowState {
main: Some(frame),
coordinate_space: WindowFrameCoordinateSpace::Logical,
};
let json = serde_json::to_string_pretty(&persisted)
.map_err(|e| format!("Failed to serialize window state: {e}"))?;
fs::write(path, json).map_err(|e| format!("Failed to write window state: {e}"))
}
fn is_valid_saved_frame(frame: &WindowFrame) -> bool {
frame.width >= MIN_WINDOW_WIDTH && frame.height >= MIN_WINDOW_HEIGHT
}
fn rounded_i32(value: f64) -> i32 {
value.round() as i32
}
fn rounded_u32(value: f64) -> u32 {
value.round().max(0.0) as u32
}
impl WindowFrame {
fn from_logical_geometry(position: LogicalPosition<f64>, size: LogicalSize<f64>) -> Self {
Self {
x: rounded_i32(position.x),
y: rounded_i32(position.y),
width: rounded_u32(size.width),
height: rounded_u32(size.height),
}
}
fn to_logical(self, scale_factor: f64) -> Self {
let scale_factor = scale_factor.max(1.0);
Self {
x: rounded_i32(self.x as f64 / scale_factor),
y: rounded_i32(self.y as f64 / scale_factor),
width: rounded_u32(self.width as f64 / scale_factor),
height: rounded_u32(self.height as f64 / scale_factor),
}
}
fn right(self) -> i32 {
self.x + self.width as i32
}
fn bottom(self) -> i32 {
self.y + self.height as i32
}
}
impl WindowFrameCoordinateSpace {
fn to_logical_frame(self, frame: WindowFrame, scale_factor: f64) -> WindowFrame {
match self {
Self::Logical => frame,
Self::Physical => frame.to_logical(scale_factor),
}
}
}
impl ScreenArea {
fn right(self) -> i32 {
self.x + self.width as i32
}
fn bottom(self) -> i32 {
self.y + self.height as i32
}
fn has_area(&self) -> bool {
self.width > 0 && self.height > 0
}
fn contains(&self, point: (i32, i32)) -> bool {
let (x, y) = point;
x >= self.x && x < self.right() && y >= self.y && y < self.bottom()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn frame(x: i32, y: i32, width: u32, height: u32) -> WindowFrame {
WindowFrame {
x,
y,
width,
height,
}
}
fn screen(x: i32, y: i32, width: u32, height: u32) -> ScreenArea {
ScreenArea {
x,
y,
width,
height,
}
}
#[test]
fn records_logical_window_geometry_for_persistence() {
let saved = WindowFrame::from_logical_geometry(
LogicalPosition::new(80.0, 120.0),
LogicalSize::new(1100.0, 700.0),
);
assert_eq!(saved, frame(80, 120, 1100, 700));
}
#[test]
fn migrates_legacy_physical_frames_to_logical_points() {
let saved = frame(160, 240, 2200, 1400);
assert_eq!(
WindowFrameCoordinateSpace::Physical.to_logical_frame(saved, 2.0),
frame(80, 120, 1100, 700)
);
}
#[test]
fn keeps_explicit_logical_frames_unscaled() {
let saved = frame(80, 120, 1100, 700);
assert_eq!(
WindowFrameCoordinateSpace::Logical.to_logical_frame(saved, 2.0),
saved
);
}
#[test]
fn keeps_valid_frame_unchanged() {
let saved = frame(120, 80, 1400, 900);
let screens = [screen(0, 0, 1920, 1080)];
assert_eq!(fit_frame_to_screens(saved, &screens), Some(saved));
}
#[test]
fn clamps_oversized_frame_to_current_work_area() {
let saved = frame(-100, -80, 2600, 1800);
let screens = [screen(0, 0, 1440, 900)];
assert_eq!(
fit_frame_to_screens(saved, &screens),
Some(frame(0, 0, 1440, 900))
);
}
#[test]
fn moves_offscreen_frame_back_to_a_visible_screen() {
let saved = frame(3200, 1800, 900, 700);
let screens = [screen(0, 0, 1440, 900)];
assert_eq!(
fit_frame_to_screens(saved, &screens),
Some(frame(540, 200, 900, 700))
);
}
#[test]
fn picks_the_screen_with_the_largest_visible_overlap() {
let saved = frame(1700, 100, 900, 700);
let screens = [screen(0, 0, 1920, 1080), screen(1920, 0, 1440, 900)];
assert_eq!(fit_frame_to_screens(saved, &screens), Some(saved));
}
#[test]
fn ignores_empty_screen_areas_when_restoring() {
let saved = frame(100, 100, 800, 600);
let screens = [screen(0, 0, 0, 900), screen(0, 0, 1440, 900)];
assert_eq!(fit_frame_to_screens(saved, &screens), Some(saved));
}
#[test]
fn returns_none_when_no_usable_screens_exist() {
let saved = frame(100, 100, 800, 600);
assert_eq!(fit_frame_to_screens(saved, &[]), None);
assert_eq!(fit_frame_to_screens(saved, &[screen(0, 0, 0, 0)]), None);
}
#[test]
fn fits_to_tiny_work_area_when_it_is_smaller_than_minimum_size() {
let saved = frame(100, 100, 800, 600);
let screens = [screen(0, 0, 320, 240)];
assert_eq!(
fit_frame_to_screens(saved, &screens),
Some(frame(0, 0, 320, 240))
);
}
#[test]
fn reports_visibility_across_adjacent_screens() {
let screens = [screen(0, 0, 1920, 1080), screen(1920, 0, 1440, 900)];
assert!(frame_is_visible(frame(1700, 100, 900, 700), &screens));
assert!(!frame_is_visible(frame(1700, 850, 900, 300), &screens));
}
#[test]
fn computes_frame_and_screen_edges_for_overlap_checks() {
let saved = frame(10, 20, 800, 600);
let area = screen(0, 0, 500, 400);
assert_eq!(saved.right(), 810);
assert_eq!(saved.bottom(), 620);
assert_eq!(area.right(), 500);
assert_eq!(area.bottom(), 400);
assert_eq!(intersection_area(saved, area), 490 * 380);
assert_eq!(intersection_area(saved, screen(900, 900, 200, 200)), 0);
}
#[test]
fn rejects_corrupted_tiny_saved_frames() {
assert!(!is_valid_saved_frame(&frame(100, 100, 1, 900)));
assert!(!is_valid_saved_frame(&frame(100, 100, 1400, 1)));
}
#[test]
fn restores_again_after_runtime_ready() {
assert!(restores_window_frame_after_runtime_ready(&RunEvent::Ready));
assert!(!restores_window_frame_after_runtime_ready(
&RunEvent::Resumed
));
}
#[test]
fn persists_and_reads_logical_frame_from_disk() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("nested/window-state.json");
let saved = frame(80, 120, 1100, 700);
write_main_window_frame_at(&path, saved).unwrap();
let json = std::fs::read_to_string(&path).unwrap();
assert!(json.contains("\"coordinate_space\": \"logical\""));
assert_eq!(read_main_window_frame_at(&path, 2.0), Some(saved));
}
#[test]
fn reads_legacy_physical_frame_from_disk_as_logical_points() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("window-state.json");
std::fs::write(
&path,
r#"{
"main": { "x": 160, "y": 240, "width": 2200, "height": 1400 },
"coordinate_space": "physical"
}"#,
)
.unwrap();
assert_eq!(
read_main_window_frame_at(&path, 2.0),
Some(frame(80, 120, 1100, 700))
);
}
#[test]
fn ignores_missing_corrupt_or_tiny_window_state_files() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("window-state.json");
assert_eq!(read_main_window_frame_at(&path, 1.0), None);
std::fs::write(&path, "not json").unwrap();
assert_eq!(read_main_window_frame_at(&path, 1.0), None);
std::fs::write(&path, r#"{"main":{"x":0,"y":0,"width":100,"height":100}}"#).unwrap();
assert_eq!(read_main_window_frame_at(&path, 1.0), None);
}
}