From 25c8ea6b799dcc8f45a5b32f5363faed6eed64cb Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:51:31 +0800 Subject: [PATCH] feat(window): remember window size and position across launches Persist the window's final geometry to window.json on quit (written by the app-quit hook, tracked live by a bounds observer that records the restore bounds while fullscreen). On launch, a normal startup window reopens at the remembered size and position instead of the hardcoded centered 1440x900; a remembered window that no longer touches any display keeps its size but re-centers, and degenerate or malformed state falls back to the default. Gated by a new remember_window_size config (default on) with a switch in Settings -> Window & Tabs and a search entry. The geometry is written unconditionally so toggling the setting back on restores the most recent quit rather than stale pre-toggle state. Closes #89 --- src/core/config.rs | 7 +++ src/core/mod.rs | 1 + src/core/window_state.rs | 114 +++++++++++++++++++++++++++++++++++++++ src/main.rs | 30 ++++++++--- src/ui/app.rs | 26 ++++++++- src/ui/settings.rs | 16 ++++++ 6 files changed, 187 insertions(+), 7 deletions(-) create mode 100644 src/core/window_state.rs diff --git a/src/core/config.rs b/src/core/config.rs index 173f0d81..7c2cddb9 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -156,6 +156,12 @@ pub struct Config { /// Window state at launch: normal / maximized / fullscreen. #[serde(default, deserialize_with = "de_lenient")] pub startup_mode: StartupMode, + /// Reopen a normal (non-maximized/fullscreen) startup window at the size + /// and position it had when tty7 last quit. On by default; off opens + /// centered at the built-in default size. The remembered geometry itself + /// lives in `window.json` (see [`crate::core::window_state`]), not here. + #[serde(default = "default_true")] + pub remember_window_size: bool, // ── Shell environment ─────────────────────────────────────────────────── /// Where a shell starts when the client doesn't pass an explicit directory @@ -436,6 +442,7 @@ impl Default for Config { clipboard_trim_trailing_spaces: false, copy_on_select: false, startup_mode: StartupMode::Normal, + remember_window_size: true, working_directory: WorkingDirectory::default(), env: HashMap::new(), ssh_profiles: Vec::new(), diff --git a/src/core/mod.rs b/src/core/mod.rs index 44b7e97c..bd65e704 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -25,3 +25,4 @@ pub mod ssh_config; pub mod ssh_profile; pub mod threads; pub mod update; +pub mod window_state; diff --git a/src/core/window_state.rs b/src/core/window_state.rs new file mode 100644 index 00000000..b43d7ceb --- /dev/null +++ b/src/core/window_state.rs @@ -0,0 +1,114 @@ +//! Persisted last-window geometry, stored at `window.json` in the config dir +//! (alongside `config.json` / `session.json`). The quit hook in `ui::app` +//! writes the window's final bounds here unconditionally; startup reads it +//! back only when `Config::remember_window_size` is on, so toggling the +//! setting off and on again still restores the most recent quit's geometry. +//! Same durability contract as the other config-dir files: missing/malformed +//! reads fall back to "nothing remembered", writes are atomic. + +use gpui::{Bounds, Pixels, point, px}; +use serde::{Deserialize, Serialize}; + +/// Don't restore a window smaller than this (logical px) — a corrupt or +/// hand-edited file shouldn't reopen tty7 as a sliver. +const MIN_SIZE: f32 = 200.0; + +/// Last known window geometry, in gpui's global coordinate space (logical +/// pixels; origins can be negative or beyond the primary display on +/// multi-monitor setups). For a fullscreen window this records the *restore* +/// bounds, so the next normal launch isn't screen-sized. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct WindowState { + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, +} + +impl WindowState { + fn path() -> Option { + crate::core::config::config_path("window.json") + } + + pub fn from_bounds(bounds: Bounds) -> Self { + Self { + x: bounds.origin.x.into(), + y: bounds.origin.y.into(), + width: bounds.size.width.into(), + height: bounds.size.height.into(), + } + } + + pub fn bounds(&self) -> Bounds { + Bounds { + origin: point(px(self.x), px(self.y)), + size: gpui::size(px(self.width), px(self.height)), + } + } + + /// Load the remembered geometry; `None` when nothing usable is on disk + /// (never saved, unreadable, malformed, or degenerate values), in which + /// case the caller falls back to the centered default. + pub fn load() -> Option { + let path = Self::path()?; + let text = std::fs::read_to_string(&path).ok()?; + let state: Self = serde_json::from_str(&text) + .map_err(|e| log::warn!("failed to parse {}: {e}; ignoring", path.display())) + .ok()?; + state.is_usable().then_some(state) + } + + /// A geometry worth restoring: all values finite and the size at least + /// [`MIN_SIZE`] each way. + fn is_usable(&self) -> bool { + [self.x, self.y, self.width, self.height] + .iter() + .all(|v| v.is_finite()) + && self.width >= MIN_SIZE + && self.height >= MIN_SIZE + } + + /// Persist the geometry; IO / serialization errors are logged and swallowed + /// (worst case the next launch opens at the default size). + pub fn save(&self) { + let Some(path) = Self::path() else { + return; + }; + let json = match serde_json::to_string_pretty(self) { + Ok(j) => j, + Err(e) => { + log::warn!("failed to serialize window state: {e}"); + return; + } + }; + if let Err(e) = crate::core::config::write_atomic(&path, json.as_bytes()) { + log::warn!("failed to write {}: {e}", path.display()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips_through_bounds() { + let state = WindowState { + x: -120.5, + y: 42.0, + width: 1440.0, + height: 900.0, + }; + assert_eq!(WindowState::from_bounds(state.bounds()), state); + } + + #[test] + fn rejects_degenerate_geometry() { + let usable = + |json: &str| serde_json::from_str::(json).is_ok_and(|s| s.is_usable()); + assert!(usable(r#"{"x":-120.5,"y":42,"width":1440,"height":900}"#)); + assert!(!usable(r#"{"x":0,"y":0,"width":50,"height":900}"#)); + assert!(!usable(r#"{"x":null,"y":0,"width":1440,"height":900}"#)); + assert!(!usable("not json")); + } +} diff --git a/src/main.rs b/src/main.rs index 015e267f..96227fbc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -347,12 +347,30 @@ fn main() { keymap::init(cx); cx.spawn(async move |cx| { - // Open at a roomy default, centred on the primary display (`centered` - // needs `&App`, which the async cx hands out via `update`). - let default_size = size(px(1440.), px(900.)); - let bounds = cx.update(|cx| Bounds::centered(None, default_size, cx)); - // Launch state from config: a normal centered window, or maximized / - // fullscreen. Each variant still carries the centered bounds as the + // Open where the user left off: `window.json` holds the geometry from + // the last quit (written by the quit hook in `ui::app`), applied only + // while `remember_window_size` is on. A remembered window that no + // longer touches any display (monitor unplugged, resolution change) + // keeps its size but re-centers; with nothing remembered, open at a + // roomy default, centred on the primary display (`centered` needs + // `&App`, which the async cx hands out via `update`). + let remembered = cx + .update(|cx| cx.global::().remember_window_size) + .then(crate::core::window_state::WindowState::load) + .flatten(); + let bounds = cx.update(|cx| match remembered { + Some(state) => { + let bounds = state.bounds(); + if cx.displays().iter().any(|d| d.bounds().intersects(&bounds)) { + bounds + } else { + Bounds::centered(None, bounds.size, cx) + } + } + None => Bounds::centered(None, size(px(1440.), px(900.)), cx), + }); + // Launch state from config: a normal window, or maximized / + // fullscreen. Each variant still carries the bounds above as the // size to restore to when the user un-maximizes / exits fullscreen. let startup_mode = cx.update(|cx| cx.global::().startup_mode); let window_bounds = match startup_mode { diff --git a/src/ui/app.rs b/src/ui/app.rs index ec35ffe2..1301153b 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -2,7 +2,8 @@ //! with the active terminal filling the rest. Owns all tabs (each its own PTY). use gpui::{ - App, Axis, Context, Entity, Focusable, PromptLevel, Subscription, Window, div, prelude::*, px, + App, Axis, Bounds, Context, Entity, Focusable, Pixels, PromptLevel, Subscription, Window, div, + prelude::*, px, }; use gpui_component::color_picker::{ColorPickerEvent, ColorPickerState}; use gpui_component::input::{InputEvent, InputState}; @@ -334,6 +335,10 @@ pub struct Tty7App { /// In-pane "confirm close of a live SSH session" state (PRD FR-E3): the close /// action awaiting confirmation, or `None` when no prompt is up. pub(crate) ssh_close_confirm: Option, + /// Latest window geometry (the restore bounds while fullscreen), kept + /// current by a bounds observer so the quit hook can persist it to + /// `window.json` — at quit time no `&Window` is in reach to ask directly. + window_bounds: Bounds, } /// Which close action a live-SSH close-confirmation is gating (PRD FR-E3). @@ -540,6 +545,7 @@ impl Tty7App { settings: None, ssh_prompt: crate::ui::ssh_prompt::SshPromptState::new(cx), ssh_close_confirm: None, + window_bounds: window.window_bounds().get_bounds(), }; // Discover this machine's shells for the "+" dropdown off the UI thread // (the WSL probe on Windows spawns a process, and /etc/shells hits the @@ -567,10 +573,24 @@ impl Tty7App { // after teardown). cx.on_app_quit(|app, cx| { app.save_session(cx); + // Also persist the window's final geometry so the next launch can + // reopen there (`remember_window_size`). Written unconditionally — + // startup gates on the config — so toggling the setting back on + // restores the most recent quit, not some stale pre-toggle state. + crate::core::window_state::WindowState::from_bounds(app.window_bounds).save(); async move {} }) .detach(); + // Keep `window_bounds` tracking the live window: moves and resizes both + // fire this observer, and `window_bounds()` reports the *restore* bounds + // while fullscreen, so a fullscreen quit doesn't record a screen-sized + // window for the next normal launch. + cx.observe_window_bounds(window, |this, window, _cx| { + this.window_bounds = window.window_bounds().get_bounds(); + }) + .detach(); + // Confirm before the red traffic light closes the window. Closing quits // the app, but the panes are *detached, not killed* — they keep running in // the daemon and re-attach on the next launch — so the prompt reassures @@ -1435,6 +1455,10 @@ impl Tty7App { self.update_config(cx, |cfg| cfg.startup_mode = mode); } + pub(crate) fn set_remember_window_size(&mut self, on: bool, cx: &mut Context) { + self.update_config(cx, |cfg| cfg.remember_window_size = on); + } + pub(crate) fn focus_active(&self, window: &mut Window, cx: &mut App) { // While the settings overlay is open it owns focus (so Esc-to-close and // keybinding capture keep working); tab operations behind it don't steal diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 072a73df..d214b8d4 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -245,6 +245,11 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: "Startup window", keywords: "restore session launch open", }, + SearchEntry { + section: WindowTabs, + title: "Remember window size", + keywords: "window size position bounds geometry launch startup remember", + }, SearchEntry { section: WindowTabs, title: "New tab position", @@ -2885,6 +2890,7 @@ impl Tty7App { NewTabPosition::End => 1, }; let restore_session = cfg.restore_session; + let remember_window_size = cfg.remember_window_size; let tab_bar_idx = match cfg.tab_bar_position { TabBarPosition::Top => 0, TabBarPosition::Left => 1, @@ -2894,6 +2900,10 @@ impl Tty7App { .checked(restore_session) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_restore_session(*on, cx))) .into_any_element(); + let remember_window_switch = Switch::new("wt-remember-window") + .checked(remember_window_size) + .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_remember_window_size(*on, cx))) + .into_any_element(); let startup_radio = self.segmented( "wt-startup", &["Normal", "Maximized", "Fullscreen"], @@ -2945,6 +2955,12 @@ impl Tty7App { startup_radio, cx, )) + .child(self.settings_row( + "Remember window size", + "Reopen at the size and position the window had when tty7 last quit. Off opens centered at the default size.", + remember_window_switch, + cx, + )) .child(self.settings_row( "Restore previous session", "Reopen the last window's tabs, splits, and directories on launch. Off starts with a single fresh terminal.",