mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 08:02:24 +00:00
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:
@@ -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(),
|
||||
|
||||
@@ -25,3 +25,4 @@ pub mod ssh_config;
|
||||
pub mod ssh_profile;
|
||||
pub mod threads;
|
||||
pub mod update;
|
||||
pub mod window_state;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user