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
This commit is contained in:
l0ng-ai
2026-07-15 21:51:31 +08:00
parent 846032183d
commit 25c8ea6b79
6 changed files with 187 additions and 7 deletions
+7
View File
@@ -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(),
+1
View File
@@ -25,3 +25,4 @@ pub mod ssh_config;
pub mod ssh_profile;
pub mod threads;
pub mod update;
pub mod window_state;
+114
View File
@@ -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<std::path::PathBuf> {
crate::core::config::config_path("window.json")
}
pub fn from_bounds(bounds: Bounds<Pixels>) -> 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<Pixels> {
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<Self> {
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::<WindowState>(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"));
}
}
+24 -6
View File
@@ -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::<Config>().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::<Config>().startup_mode);
let window_bounds = match startup_mode {
+25 -1
View File
@@ -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<SshCloseKind>,
/// 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<Pixels>,
}
/// 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>) {
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
+16
View File
@@ -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.",