Window: remember the window's size and position between launches

The window opened at a fixed 1680 × 1020 wherever the system put it. Its size, position
and maximized state are now saved to window.json in the config directory (when they change,
and on exit) and restored on the next launch, moved onto a monitor that is still connected.
The first launch fits the window to its screen. Agent-driven runs (--control) restore the
saved window but don't overwrite it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
pravdomilandClaude Opus 5.5 committed 2026-10-11 15:54:29 +02:00
1 parent 9a7d3e7699
commit 04e087ae58
4 files changed
+387 -4

No files matched your search

Generated
+1
View File
@@ -1159,6 +1159,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"pollster 0.4.0",
"rfd",
"serde",
"serde_json",
"winit",
"winresource",
+4 -2
View File
@@ -16,7 +16,11 @@ effectcraft-ui-egui = { workspace = true }
egui = { workspace = true }
# `links`: links (Help menu, Discord, Settings) open in the system browser.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "links", "wayland", "x11"] }
serde = { workspace = true }
serde_json = { workspace = true }
# The window's monitors, size and position (src/window.rs), and macOS event-loop options (no app
# activation when driven by an agent). The same winit eframe runs on.
winit = { version = "0.30", default-features = false }
rfd = { workspace = true }
pollster = { workspace = true }
cpal = { workspace = true }
@@ -32,8 +36,6 @@ zbus = "5.19"
winresource = "0.1"
[target.'cfg(target_os = "macos")'.dependencies]
# Same winit as eframe: macOS event-loop options (no app activation when driven by an agent).
winit = { version = "0.30", default-features = false }
# Keep the control channel responsive when the window is covered (App Nap / occlusion throttling).
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSProcessInfo", "NSString", "NSObject"] }
# The native menu bar (NSMenu). MIT OR Apache-2.0; macOS only, never in the web build.
+11 -2
View File
@@ -24,6 +24,7 @@ mod native_menu;
// Built everywhere so it stays checked; only the macOS app uses it.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
mod sheet_dialogs;
mod window;
use effectcraft_ui_egui::EffectcraftApp;
use serde_json::json;
@@ -108,8 +109,8 @@ fn main() -> eframe::Result {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("EffectCraft")
.with_inner_size([1680.0, 1020.0])
.with_min_inner_size([960.0, 600.0])
.with_inner_size(window::DEFAULT_SIZE)
.with_min_inner_size(window::MIN_SIZE)
.with_drag_and_drop(true)
// Started for an agent (`--control`): open without taking the user's keyboard focus.
.with_active(control_port.is_none())
@@ -170,6 +171,9 @@ fn main() -> eframe::Result {
session.config = Some(std::sync::Arc::new(effectcraft_engine::config::DirConfig::new(dir)));
}
session.load_settings();
// Fit the window to its monitor, or put it back where it was (still hidden).
let saved_window = session.config.as_deref().and_then(window::load);
let opened = cc.winit_window().map(|w| window::restore(w, saved_window));
let recovery = if control_port.is_none() { session.begin_recovery() } else { None };
let project = files.iter().find(|f| is_project_file(f)).cloned();
if let Some(p) = project {
@@ -264,6 +268,7 @@ fn main() -> eframe::Result {
#[cfg(target_os = "macos")]
apple_events: apple_events.connect(&cc.egui_ctx),
app,
window: window::Tracker::new(opened, saved_window, control_port.is_none()),
launch,
drawn: false,
}))
@@ -333,6 +338,8 @@ struct Desktop {
/// Documents opened from Finder, the Dock or `open` (macOS).
#[cfg(target_os = "macos")]
apple_events: apple_events::Opened,
/// The window's size and position, saved for the next launch.
window: window::Tracker,
launch: launch_guard::Launch,
/// The first frame was drawn (logged once).
drawn: bool,
@@ -347,6 +354,7 @@ impl eframe::App for Desktop {
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
self.app.ui(ui, frame);
self.window.frame(ui.ctx(), self.app.session.config.as_deref());
if !self.drawn {
self.drawn = true;
log::info!("first frame drawn");
@@ -360,6 +368,7 @@ impl eframe::App for Desktop {
fn on_exit(&mut self) {
self.launch.exited();
self.window.save(self.app.session.config.as_deref());
self.app.on_exit();
}
+371
View File
@@ -0,0 +1,371 @@
//! The main window's size and position. The first launch fits the window to its monitor, opening
//! it maximized where the default size leaves no room for a taskbar or Dock. Later launches restore
//! the size, position and maximized state saved in the config directory (`window.json`), moved
//! onto a monitor that is still connected.
use effectcraft_engine::config::ConfigStore;
use serde::{Deserialize, Serialize};
use winit::dpi::{LogicalSize, PhysicalPosition, PhysicalSize};
const FILE: &str = "window.json";
const MAX_BYTES: usize = 4096;
/// Seconds a window opened maximized gets to say so before its size is tracked.
const MAXIMIZE_WAIT: f64 = 2.0;
/// The window's geometry between launches.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct WindowGeometry {
/// The outer top-left corner in physical pixels; `None` where the system doesn't say (Wayland).
pub pos: Option<[i32; 2]>,
/// The inner size in points; while maximized, the size to return to.
pub size: [f32; 2],
pub maximized: bool,
}
/// The first window's size, in points.
pub const DEFAULT_SIZE: [f32; 2] = [1680.0, 1020.0];
/// The smallest window, in points.
pub const MIN_SIZE: [f32; 2] = [960.0, 600.0];
/// Room the first window leaves around itself for a taskbar, Dock or menu bar (the system doesn't
/// say how much of the screen they take): a monitor with less room opens it maximized.
const SCREEN_ROOM: [f32; 2] = [64.0, 128.0];
/// A monitor: its top-left corner and size in physical pixels, and its scale factor.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Monitor {
pos: [i64; 2],
size: [i64; 2],
scale: f32,
}
impl Monitor {
pub fn new(pos: PhysicalPosition<i32>, size: PhysicalSize<u32>, scale: f64) -> Self {
let scale = scale as f32;
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
Self { pos: [pos.x.into(), pos.y.into()], size: [size.width.into(), size.height.into()], scale }
}
/// Its size in points.
fn points(&self) -> [f32; 2] {
self.size.map(|s| s as f32 / self.scale)
}
/// Its size in points less room for a taskbar, Dock or menu bar.
fn usable(&self) -> [f32; 2] {
let points = self.points();
std::array::from_fn(|i| points[i] - SCREEN_ROOM[i])
}
/// A window of `size` points on this monitor, in physical pixels.
fn pixels(&self, size: [f32; 2]) -> [i64; 2] {
size.map(|s| (s * self.scale) as i64)
}
/// How much of a window at `pos` of `size` points this monitor shows, in square pixels.
fn overlap(&self, pos: [i32; 2], size: [f32; 2]) -> i64 {
let px = self.pixels(size);
let span = |i: usize| {
let start = i64::from(pos[i]).max(self.pos[i]);
let end = i64::from(pos[i]).saturating_add(px[i]).min(self.pos[i] + self.size[i]);
(end - start).max(0)
};
span(0).saturating_mul(span(1))
}
/// The top-left corner nearest to `pos` (centred when none) that keeps a window of `size`
/// points on this monitor.
fn keep_on(&self, pos: Option<[i32; 2]>, size: [f32; 2]) -> [i32; 2] {
let px = self.pixels(size);
std::array::from_fn(|i| {
let lo = self.pos[i];
let hi = lo + self.size[i].saturating_sub(px[i]).max(0);
let want = pos.map_or(lo + (hi - lo) / 2, |p| i64::from(p[i]));
i32::try_from(want.max(lo).min(hi)).unwrap_or_default()
})
}
}
/// Where the window opens: `saved` (the last session's geometry) kept on a connected monitor, else
/// the default size on `home` (the monitor the system opens it on). Without monitors (the system
/// doesn't list them, so nothing to check a saved geometry against) the default size.
pub fn place(saved: Option<WindowGeometry>, monitors: &[Monitor], home: Option<usize>) -> WindowGeometry {
let default = WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false };
let Some(home) = home.and_then(|i| monitors.get(i)).or(monitors.first()) else {
return default;
};
let Some(saved) = saved else {
let usable = home.usable();
if (0..2).all(|i| DEFAULT_SIZE[i] <= usable[i]) {
return default;
}
// A small screen: maximized, with a size that fits once un-maximized.
let size = fit(DEFAULT_SIZE, usable);
return WindowGeometry { pos: Some(home.keep_on(None, size)), size, maximized: true };
};
// The monitor showing most of the window; none when it was on a monitor that is gone.
let shown = saved.pos.and_then(|p| {
let (area, m) = monitors.iter().map(|m| (m.overlap(p, saved.size), m)).max_by_key(|(area, _)| *area)?;
(area > 0).then_some(m)
});
let m = shown.unwrap_or(home);
// Bigger than its monitor (a smaller screen than last time): maximized, like the first window.
let room = m.points();
let too_big = (0..2).any(|i| saved.size[i] > room[i]);
let size = fit(saved.size, if too_big { m.usable() } else { room });
WindowGeometry {
// Moved fully onto its monitor, or centred on the home monitor.
pos: saved.pos.map(|p| m.keep_on(shown.map(|_| p), size)),
size,
maximized: saved.maximized || too_big,
}
}
/// `size` no bigger than `room`, and no smaller than the smallest window unless `room` is.
fn fit(size: [f32; 2], room: [f32; 2]) -> [f32; 2] {
std::array::from_fn(|i| size[i].min(room[i]).max(MIN_SIZE[i].min(room[i])).max(1.0))
}
/// Size and place the new window (eframe shows it after drawing the first frame) and return its
/// geometry.
pub fn restore(window: &winit::window::Window, saved: Option<WindowGeometry>) -> WindowGeometry {
let handles: Vec<_> = window.available_monitors().collect();
let monitors: Vec<Monitor> = handles.iter().map(|m| Monitor::new(m.position(), m.size(), m.scale_factor())).collect();
let home = window.current_monitor().or_else(|| window.primary_monitor()).and_then(|c| handles.iter().position(|m| *m == c));
let g = place(saved, &monitors, home);
if let Some([x, y]) = g.pos {
window.set_outer_position(PhysicalPosition::new(x, y));
}
// Returns the new size when it applies at once; either way the window gets it.
let _ = window.request_inner_size(LogicalSize::new(g.size[0], g.size[1]));
if g.maximized {
window.set_maximized(true);
}
g
}
/// Per frame: remember the window's geometry for the next launch. Minimized or full screen keeps
/// the last one; maximized only sets its flag, so un-maximizing next time restores the last size.
pub fn track(ctx: &egui::Context, geometry: &mut Option<WindowGeometry>) {
let zoom = ctx.zoom_factor();
ctx.input(|i| {
let v = i.viewport();
if v.minimized == Some(true) || v.fullscreen == Some(true) {
return;
}
if v.maximized == Some(true) {
if let Some(g) = geometry {
g.maximized = true;
}
return;
}
let Some(inner) = v.inner_rect else { return };
let pos = v.outer_rect.map(|r| [r.min.x, r.min.y].map(|c| (c * i.pixels_per_point).round() as i32));
*geometry = Some(WindowGeometry { pos, size: [inner.width() * zoom, inner.height() * zoom], maximized: false });
});
}
/// The geometry saved by the last session; `None` when there is none or it can't be read.
pub fn load(config: &dyn ConfigStore) -> Option<WindowGeometry> {
let text = config.read_bounded(FILE, MAX_BYTES).ok()??;
serde_json::from_str(&text).ok()
}
/// Follows the window and saves its geometry for the next launch: when it has changed (at most
/// once a second, so a session that ends badly still has it) and on exit.
pub struct Tracker {
geometry: Option<WindowGeometry>,
saved: Option<WindowGeometry>,
last_attempt: f64,
/// Opened maximized, and the system hasn't reported it so yet (when the wait ends): the
/// window's first frames report the maximized size as an ordinary one, which would replace
/// the size to return to.
maximizing: Option<f64>,
/// Agent-driven runs (`--control`) leave the user's saved window alone.
save: bool,
}
impl Tracker {
/// `geometry`: what the window opened with.
pub fn new(geometry: Option<WindowGeometry>, saved: Option<WindowGeometry>, save: bool) -> Self {
let maximizing = geometry.is_some_and(|g| g.maximized).then_some(f64::INFINITY);
Self { geometry, saved, last_attempt: f64::NEG_INFINITY, maximizing, save }
}
/// Per frame.
pub fn frame(&mut self, ctx: &egui::Context, config: Option<&dyn ConfigStore>) {
let (now, maximized) = ctx.input(|i| (i.time, i.viewport().maximized == Some(true)));
if let Some(until) = self.maximizing {
// A system that never maximizes the window is believed after a moment.
let until = until.min(now + MAXIMIZE_WAIT);
self.maximizing = (!maximized && now < until).then_some(until);
}
if self.maximizing.is_none() {
track(ctx, &mut self.geometry);
}
if now - self.last_attempt >= 1.0 {
self.last_attempt = now;
self.save(config);
}
}
/// Write the geometry when it differs from the saved one.
pub fn save(&mut self, config: Option<&dyn ConfigStore>) {
let (Some(config), Some(g)) = (config, self.geometry) else { return };
if !self.save || self.saved == Some(g) {
return;
}
match serde_json::to_string(&g).map_err(|e| e.to_string()).and_then(|text| config.write(FILE, &text).map_err(|e| e.to_string())) {
Ok(()) => self.saved = Some(g),
Err(e) => log::warn!("could not save the window geometry: {e}"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use effectcraft_engine::config::MemoryConfig;
fn monitor(x: i32, y: i32, w: u32, h: u32, scale: f64) -> Monitor {
Monitor::new(PhysicalPosition::new(x, y), PhysicalSize::new(w, h), scale)
}
fn saved(pos: Option<[i32; 2]>, size: [f32; 2], maximized: bool) -> Option<WindowGeometry> {
Some(WindowGeometry { pos, size, maximized })
}
#[test]
fn the_first_window_fits_its_screen() {
// 2560 × 1440: the default size, where the system puts it.
let big = [monitor(0, 0, 2560, 1440, 1.0)];
assert_eq!(place(None, &big, Some(0)), WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false });
// 1920 × 1080, 1600 × 900, 1366 × 768 and 1920 × 1080 at 150% (1280 × 720 points):
// maximized, and a size that fits the screen with room for the taskbar once un-maximized.
for (m, size) in [
(monitor(0, 0, 1920, 1080, 1.0), [1680.0, 952.0]),
(monitor(0, 0, 1600, 900, 1.0), [1536.0, 772.0]),
(monitor(0, 0, 1366, 768, 1.0), [1302.0, 640.0]),
(monitor(0, 0, 1920, 1080, 1.5), [1216.0, 592.0]),
] {
let g = place(None, &[m], Some(0));
assert!(g.maximized, "{m:?}");
assert_eq!(g.size, size, "{m:?}");
let [x, y] = g.pos.unwrap();
let px = m.pixels(g.size);
assert!(x >= 0 && y >= 0 && i64::from(x) + px[0] <= m.size[0] && i64::from(y) + px[1] <= m.size[1], "{m:?}: {g:?}");
}
// No monitor information: the default size, whatever was saved.
assert_eq!(place(None, &[], None), WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false });
assert_eq!(place(saved(Some([9000, 9000]), [1e9, 1e9], true), &[], None).size, DEFAULT_SIZE);
}
#[test]
fn the_saved_window_comes_back_where_it_was() {
let two = [monitor(0, 0, 2560, 1440, 1.0), monitor(2560, 0, 1920, 1080, 1.0)];
for g in [saved(Some([100, 80]), [1200.0, 800.0], false), saved(Some([2700, 50]), [1000.0, 700.0], true), saved(None, [1000.0, 700.0], false)] {
assert_eq!(Some(place(g, &two, Some(0))), g);
}
}
#[test]
fn a_saved_window_stays_on_a_connected_monitor() {
let laptop = [monitor(0, 0, 1600, 900, 1.0)];
// Last time on a second monitor that is gone: centred on the remaining one.
let g = place(saved(Some([2700, 50]), [1000.0, 700.0], false), &laptop, Some(0));
assert_eq!(g, WindowGeometry { pos: Some([300, 100]), size: [1000.0, 700.0], maximized: false });
// Half off the monitor's right and bottom edges: moved fully onto it.
let g = place(saved(Some([1200, 600]), [1000.0, 700.0], false), &laptop, Some(0));
assert_eq!(g.pos, Some([600, 200]));
// Bigger than the screen now (it was on a larger one): it fits, and opens maximized.
let g = place(saved(Some([0, 0]), [2400.0, 1300.0], false), &laptop, Some(0));
assert_eq!(g, WindowGeometry { pos: Some([0, 0]), size: [1536.0, 772.0], maximized: true });
// A monitor left of the primary one (negative coordinates) still counts.
let left = [monitor(0, 0, 1920, 1080, 1.0), monitor(-1920, 0, 1920, 1080, 1.0)];
assert_eq!(place(saved(Some([-1800, 40]), [1000.0, 700.0], false), &left, Some(0)).pos, Some([-1800, 40]));
// Nonsense sizes from a damaged preferences file are kept within the screen.
let g = place(saved(Some([i32::MAX, i32::MIN]), [-5.0, f32::MAX], false), &laptop, Some(0));
assert_eq!(g, WindowGeometry { pos: Some([320, 64]), size: [960.0, 772.0], maximized: true });
let g = place(saved(Some([5, 5]), [f32::NEG_INFINITY, f32::INFINITY], false), &laptop, Some(0));
assert_eq!(g, WindowGeometry { pos: Some([320, 64]), size: [960.0, 772.0], maximized: true });
}
#[test]
fn the_window_geometry_is_tracked_for_the_next_launch() {
let ctx = egui::Context::default();
let mut geometry = None;
let mut frame = |info: egui::ViewportInfo| {
let mut input = egui::RawInput::default();
input.viewports.insert(egui::ViewportId::ROOT, info);
ctx.run_ui(input, |ui| track(ui.ctx(), &mut geometry)).textures_delta.clear();
geometry
};
let rect = egui::Rect::from_min_size(egui::pos2(20.0, 30.0), egui::vec2(1000.0, 600.0));
let normal = egui::ViewportInfo {
native_pixels_per_point: Some(1.5),
inner_rect: Some(rect),
outer_rect: Some(rect),
maximized: Some(false),
..Default::default()
};
// Position in physical pixels, size in points.
let g = WindowGeometry { pos: Some([30, 45]), size: [1000.0, 600.0], maximized: false };
assert_eq!(frame(normal.clone()), Some(g));
// Maximized or minimized: the size to restore stays.
let big = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 700.0));
let maximized = egui::ViewportInfo { inner_rect: Some(big), outer_rect: Some(big), maximized: Some(true), ..normal.clone() };
assert_eq!(frame(maximized), Some(WindowGeometry { maximized: true, ..g }));
let minimized = egui::ViewportInfo { inner_rect: None, outer_rect: None, minimized: Some(true), ..normal.clone() };
assert_eq!(frame(minimized), Some(WindowGeometry { maximized: true, ..g }));
assert_eq!(frame(normal), Some(g));
}
/// A window restored maximized keeps the size it returns to, though its first frames report
/// the maximized size as an ordinary one.
#[test]
fn opening_maximized_keeps_the_size_to_return_to() {
let ctx = egui::Context::default();
let g = WindowGeometry { pos: Some([40, 30]), size: [1000.0, 600.0], maximized: true };
let mut tracker = Tracker::new(Some(g), Some(g), true);
let big = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1728.0, 1052.0));
let small = egui::Rect::from_min_size(egui::pos2(40.0, 30.0), egui::vec2(900.0, 500.0));
let frame = |tracker: &mut Tracker, time: f64, rect: egui::Rect, maximized: bool| {
let info = egui::ViewportInfo { inner_rect: Some(rect), outer_rect: Some(rect), maximized: Some(maximized), ..Default::default() };
let mut input = egui::RawInput { time: Some(time), ..Default::default() };
input.viewports.insert(egui::ViewportId::ROOT, info);
ctx.run_ui(input, |ui| tracker.frame(ui.ctx(), None)).textures_delta.clear();
tracker.geometry
};
assert_eq!(frame(&mut tracker, 0.0, big, false), Some(g));
assert_eq!(frame(&mut tracker, 0.5, big, true), Some(g));
// Un-maximized by the user afterwards: tracked as usual.
let restored = WindowGeometry { pos: Some([40, 30]), size: [900.0, 500.0], maximized: false };
assert_eq!(frame(&mut tracker, 0.6, small, false), Some(restored));
// A system that doesn't maximize: the window's real size is tracked after the wait.
let mut tracker = Tracker::new(Some(g), Some(g), true);
assert_eq!(frame(&mut tracker, 10.0, small, false), Some(g));
assert_eq!(frame(&mut tracker, 10.0 + MAXIMIZE_WAIT, small, false), Some(restored));
}
/// The reported bug: the window opened at the default size and place on every launch.
#[test]
fn the_window_geometry_is_saved_and_loaded() {
let config = MemoryConfig::default();
assert_eq!(load(&config), None);
let g = WindowGeometry { pos: Some([30, 45]), size: [1000.0, 600.0], maximized: true };
let mut tracker = Tracker::new(Some(g), None, true);
tracker.save(Some(&config));
assert_eq!(load(&config), Some(g));
// Positions the system doesn't report (Wayland) round-trip too.
tracker.geometry = Some(WindowGeometry { pos: None, ..g });
tracker.save(Some(&config));
assert_eq!(load(&config), Some(WindowGeometry { pos: None, ..g }));
// An agent-driven run leaves the saved window alone.
Tracker::new(Some(g), None, false).save(Some(&config));
assert_eq!(load(&config).and_then(|g| g.pos), None);
// A damaged or oversized file is ignored: the window opens at its default.
for text in ["{", "null", "{\"pos\":[1],\"size\":[1,2],\"maximized\":false}", &" ".repeat(MAX_BYTES + 1)] {
config.write(FILE, text).unwrap();
assert_eq!(load(&config), None, "{text:.20}");
}
}
}