diff --git a/architecture/notes/nebula_app/gpui_shell/workspace/2026-10-03-ordinary-window-geometry.md b/architecture/notes/nebula_app/gpui_shell/workspace/2026-10-03-ordinary-window-geometry.md new file mode 100644 index 000000000..3bc36c259 --- /dev/null +++ b/architecture/notes/nebula_app/gpui_shell/workspace/2026-10-03-ordinary-window-geometry.md @@ -0,0 +1,83 @@ +# Ordinary-window startup geometry + +## Status + +Proposed for review. + +## Context + +The GPUI startup path already reads explicit Lua grid dimensions, but ignores +`window.position`. Regular workspace snapshots omit `Session.window`, so the +last size and position disappear when the process exits. Quick Terminal already +owns an independent remembered size and monitor policy. + +## Evidence + +`workspace/windowing.rs` owns the registry and combined persistence boundary. +`session/window_layout.rs` preserves per-window metadata in the same atomic JSON +file as tabs. The pinned GPUI Windows backend (`fc05d637`) obtains normal bounds +with `GetWindowPlacement`; maximized and fullscreen bounds retain their restore +rectangle. Windows placement uses workspace coordinates, whereas display bounds +and configured positions use desktop coordinates. + +## Decision + +Attach normal native geometry at the ordinary-window persistence boundary, +including empty tab-close snapshots. Cold restore belongs only to the first +ordinary `RestoreOrDefault` window. New windows, Quick Terminal and isolated +administrator processes never consume this geometry. Geometry restoration is +independent of restoring shell tabs; the boot-attempt cutoff still prevents a +broken saved startup loop. Existing empty-session and background-close policies +remain owned by session persistence. + +Sizes remain logical GPUI pixels. Windows saved position is the physical desktop +client origin, paired with the stable display UUID. Restore first chooses the +configured physical monitor, otherwise the remembered display, otherwise the +nearest physical monitor, then the primary display. It clamps size and origin +within that display's work area. Working coordinates are translated only at the +Windows placement boundary. Fullscreen saves its normal rectangle but does not +force a fullscreen startup; maximized state may be restored. + +Explicit Lua dimensions override saved size; explicit Lua position overrides +saved position. Missing dimensions are not an implicit explicit default. +Configured Windows position describes the outer window in physical desktop +pixels, as the legacy configuration does. Apply it using `SetWindowPos` because native border insets are known only after +creation. Hidden Windows startup retains a one-shot position until its first +active frame, after GPUI consumes its cached native placement; applying it +in the builder would be overwritten by activation. +Other platforms keep their existing position behavior pending an equivalent +public/native placement contract. + +## Rejected alternatives + +- Persisting zoom-derived rows/columns: couples layout to font state and loses + the user's actual native size. +- Saving maximized/fullscreen viewport size: destroys the normal restore size. +- Always recentering on primary: discards a still-attached secondary display. +- Reopening all ordinary windows for cold startup: changes tab restoration policy. +- A second geometry file: adds a competing save lifecycle and atomicity boundary. + +## Consequences + +New JSON fields are optional; older sessions remain readable. The shared session +format still contains flattened tabs and per-window layouts, without a version +bump. Quick Terminal settings remain separate. Native DPI conversion follows the +pinned backend and must be reviewed when that dependency changes. + +## Validation + +The added GPUI snapshot regression exercises a real registered workspace and +normal native bounds. Session tests atomically roundtrip multiwindow and empty +geometry, accept old metadata, and enforce the crash-loop cutoff. Startup tests +cover explicit grid priority and selected-display offscreen clamping. Execution +and platform acceptance are performed in GitHub Actions; local execution is not +part of this change's evidence. + +## Supersedes + +None. + +## Revisit when + +GPUI exposes a cross-platform display scale/position API or cold startup begins +restoring separate ordinary windows. diff --git a/nebula_app/src/gpui_shell/config.rs b/nebula_app/src/gpui_shell/config.rs index c7859e911..e1f77719c 100644 --- a/nebula_app/src/gpui_shell/config.rs +++ b/nebula_app/src/gpui_shell/config.rs @@ -471,7 +471,7 @@ fn default_font_family() -> &'static str { crate::font_install::REQUIRED_FONT_FAMILY } -/// `config.window.dimensions`:启动窗口的网格行列。 +/// 显式窗口网格、物理坐标与首个普通窗口的冷启动恢复值。 /// /// 单独驻留而不是并入 [`Settings`],因为生命周期不同:`Settings` 随设置页写入 /// 反复重载,启动网格按定义只在开窗时生效一次,而 Lua 配置求值不该出现在每次 @@ -480,6 +480,8 @@ fn default_font_family() -> &'static str { #[derive(Default)] pub(crate) struct StartupWindow { pub dimensions: Option, + pub position: Option>, + pub(crate) restored: Option, } impl Global for StartupWindow {} @@ -510,7 +512,11 @@ impl StartupWindow { /// `dimensions()` 自带「行列必须同时非零」的旧壳合同。 fn from_config(config: &crate::config::UiConfig) -> Self { - Self { dimensions: config.window.dimensions() } + Self { + dimensions: config.window.dimensions(), + position: config.window.position, + restored: None, + } } } @@ -881,7 +887,7 @@ mod tests { &path, "local pebrel = require 'pebrel'\n\ local config = pebrel.config_builder()\n\ - config.window = { dimensions = { columns = 123, lines = 37 } }\n\ + config.window = { dimensions = { columns = 123, lines = 37 }, position = { x = 197, y = 102 } }\n\ return config\n", ) .unwrap(); @@ -892,10 +898,9 @@ mod tests { ]) .unwrap(); - assert_eq!( - StartupWindow::load(options.config_file).dimensions, - Some(Dimensions { columns: 123, lines: 37 }) - ); + let startup = StartupWindow::load(options.config_file); + assert_eq!(startup.dimensions, Some(Dimensions { columns: 123, lines: 37 })); + assert_eq!(startup.position, Some(crate::config::ui_config::Delta { x: 197, y: 102 })); } /// 用户写在 `pebrel.lua` 里的 `config.window.dimensions` 必须真的决定启动 @@ -933,6 +938,7 @@ mod tests { ); let configured = |columns: usize, lines: usize| StartupWindow { dimensions: Some(Dimensions { columns, lines }), + ..Default::default() }; cx.update(|cx| { assert_eq!(startup_grid(cx), builtin); diff --git a/nebula_app/src/gpui_shell/workspace.rs b/nebula_app/src/gpui_shell/workspace.rs index b726f3e81..012b9290e 100644 --- a/nebula_app/src/gpui_shell/workspace.rs +++ b/nebula_app/src/gpui_shell/workspace.rs @@ -838,12 +838,10 @@ impl NebulaWorkspace { let runtime = nebula_settings::RuntimeSettings::load(); let sidebar_width = runtime.sidebar_width; windowing::observe_window_bounds(runtime_window_id, window, cx); - let initial_grid = windowing::prepare_initial_grid( - window, - cx, - sidebar_width, - window_role == windowing::WindowRole::Regular, - ); + let fit_default_grid = window_role == windowing::WindowRole::Regular + && !windowing::startup_geometry::restored_size(runtime_window_id, &startup, cx); + let initial_grid = + windowing::prepare_initial_grid(window, cx, sidebar_width, fit_default_grid); let this = cx.entity().downgrade(); let appearance_sub = window.observe_window_appearance(move |_, cx| { if let Some(workspace) = this.upgrade() { @@ -1541,15 +1539,6 @@ impl NebulaWorkspace { (0..self.tabs.len()).find_map(|tab_ix| self.busy_process_in_tab(tab_ix, None, cx)) } - fn save_clean_window_session(&mut self, cx: &mut App) -> std::io::Result<()> { - windowing::save_current_window_session( - self.runtime_window_id, - self.snapshot_session(cx), - session_persistence::SaveReason::WindowClose, - cx, - ) - } - /// 聚焦另一个 pane(点击上报或方向导航落点)。 fn focus_pane( &mut self, @@ -1672,6 +1661,7 @@ impl NebulaWorkspace { self.runtime_window_id, self.snapshot_session(cx), session_persistence::SaveReason::TabsClosed, + window, cx, ) { log::warn!("Could not save closed tabs: {error}"); diff --git a/nebula_app/src/gpui_shell/workspace/closing.rs b/nebula_app/src/gpui_shell/workspace/closing.rs index 8002ec224..a2074b131 100644 --- a/nebula_app/src/gpui_shell/workspace/closing.rs +++ b/nebula_app/src/gpui_shell/workspace/closing.rs @@ -198,7 +198,7 @@ impl NebulaWorkspace { cx.notify(); return; } - if workspace.save_clean_window_session(cx).is_err() { + if workspace.save_clean_window_session(window, cx).is_err() { workspace.window_close_pending = false; let language = crate::gpui_shell::config::ui_language(cx); crate::gpui_shell::toast::banner( diff --git a/nebula_app/src/gpui_shell/workspace/render.rs b/nebula_app/src/gpui_shell/workspace/render.rs index 140a17ab0..c8c2a6654 100644 --- a/nebula_app/src/gpui_shell/workspace/render.rs +++ b/nebula_app/src/gpui_shell/workspace/render.rs @@ -7,7 +7,7 @@ impl Render for NebulaWorkspace { let theme_transition = self.theme_transition.render(window, cx); crate::gpui_shell::theme::sync_component_focus_ring(window, cx); if window.is_window_active() { - windowing::mark_active(self.runtime_window_id, cx); + windowing::mark_active(self.runtime_window_id, window, cx); } if !cx.has_active_drag() { self.cross_window_dock = None; diff --git a/nebula_app/src/gpui_shell/workspace/residency.rs b/nebula_app/src/gpui_shell/workspace/residency.rs index 94eba4693..c855506ce 100644 --- a/nebula_app/src/gpui_shell/workspace/residency.rs +++ b/nebula_app/src/gpui_shell/workspace/residency.rs @@ -1067,6 +1067,7 @@ impl NebulaWorkspace { } else { super::session_persistence::SaveReason::Checkpoint }, + window, cx, ) { log::warn!("Could not checkpoint before hiding window: {error}"); diff --git a/nebula_app/src/gpui_shell/workspace/session_persistence.rs b/nebula_app/src/gpui_shell/workspace/session_persistence.rs index f9fea1a1e..a61ad1fd2 100644 --- a/nebula_app/src/gpui_shell/workspace/session_persistence.rs +++ b/nebula_app/src/gpui_shell/workspace/session_persistence.rs @@ -28,6 +28,11 @@ impl Default for SessionPersistence { } impl SessionPersistence { + #[cfg(test)] + pub(super) fn ordinary_for_test() -> Self { + Self { isolated: false, ..Self::default() } + } + pub(super) fn update_windows(&self) -> std::io::Result> { // 管理员只在显式更新事务中落盘;commit 会在授权安装前持久保存这份内存快照。 (if self.isolated { &self.latest } else { &self.saved }) @@ -128,7 +133,7 @@ mod tests { fn ordinary_window() -> SessionPersistence { // Hosted Windows runners can be elevated. These tests exercise ordinary // window persistence; privileged isolation has its own negative test. - SessionPersistence { isolated: false, ..SessionPersistence::default() } + SessionPersistence::ordinary_for_test() } fn sample_session() -> Session { diff --git a/nebula_app/src/gpui_shell/workspace/session_recovery.rs b/nebula_app/src/gpui_shell/workspace/session_recovery.rs index 20d4099ca..c1926de91 100644 --- a/nebula_app/src/gpui_shell/workspace/session_recovery.rs +++ b/nebula_app/src/gpui_shell/workspace/session_recovery.rs @@ -3,6 +3,20 @@ use super::*; impl NebulaWorkspace { + pub(super) fn save_clean_window_session( + &mut self, + window: &Window, + cx: &mut App, + ) -> std::io::Result<()> { + windowing::save_current_window_session( + self.runtime_window_id, + self.snapshot_session(cx), + session_persistence::SaveReason::WindowClose, + window, + cx, + ) + } + /// 冷启动回退(没有恢复出任何标签)时首个终端的 cwd:与 `add_terminal` /// 同一合同,设置页「启动目录」优先,未设置或失效时才继承进程启动目录。 pub(super) fn cold_start_cwd() -> Option { diff --git a/nebula_app/src/gpui_shell/workspace/terminal_activity.rs b/nebula_app/src/gpui_shell/workspace/terminal_activity.rs index 13d7e5ceb..f7412f736 100644 --- a/nebula_app/src/gpui_shell/workspace/terminal_activity.rs +++ b/nebula_app/src/gpui_shell/workspace/terminal_activity.rs @@ -30,6 +30,7 @@ impl NebulaWorkspace { self.runtime_window_id, self.snapshot_session(cx), session_persistence::SaveReason::Checkpoint, + window, cx, ) { log::warn!("Could not checkpoint native recovery identity: {error}"); diff --git a/nebula_app/src/gpui_shell/workspace/windowing.rs b/nebula_app/src/gpui_shell/workspace/windowing.rs index 43e1183d9..4cef9be18 100644 --- a/nebula_app/src/gpui_shell/workspace/windowing.rs +++ b/nebula_app/src/gpui_shell/workspace/windowing.rs @@ -10,7 +10,7 @@ mod startup; use startup::{initial_startup, runtime_window_startup}; #[cfg(all(test, feature = "gpui-test-support"))] mod linux_native_tests; -mod startup_geometry; +pub(super) mod startup_geometry; pub(super) use startup_geometry::prepare_initial_grid; mod elevated_launch; @@ -33,10 +33,9 @@ use std::time::Duration; use super::DockTarget; use gpui::{ AnyWindowHandle, App, AppContext as _, Bounds, Context, Entity, Global, IntoElement, Render, - Styled as _, Subscription, WeakEntity, Window, WindowBounds, WindowOptions, div, point, px, - size, + Styled as _, Subscription, WeakEntity, Window, div, px, }; -use gpui_component::{Root, TitleBar}; +use gpui_component::Root; use nebula_split::SplitTree; use serde_json::json; @@ -125,6 +124,7 @@ struct WindowEntry { last_activated: u64, native_hwnd: isize, role: WindowRole, + configured_position_pending: bool, } struct QuickTerminalWindow { @@ -379,80 +379,6 @@ pub(super) fn open_recipe_window(session: crate::session::Session, cx: &mut App) }); } -fn workspace_window_options( - cx: &mut App, - focus: bool, - role: WindowRole, - sidebar_width: f32, -) -> WindowOptions { - match role { - WindowRole::Regular => { - let preferred = startup_geometry::preferred_size(cx, sidebar_width) - .unwrap_or_else(|| size(px(1080.0), px(720.0))); - let bounds = cx.primary_display().map_or_else( - || Bounds::centered(None, preferred, cx), - |display| { - let visible = display.visible_bounds(); - let fitted = preferred.min(&visible.size); - Bounds::new( - point( - visible.origin.x + (visible.size.width - fitted.width) / 2.0, - visible.origin.y + (visible.size.height - fitted.height) / 2.0, - ), - fitted, - ) - }, - ); - crate::platform::window_chrome::configure_options(WindowOptions { - window_bounds: Some(WindowBounds::Windowed(bounds)), - window_min_size: Some(size(px(760.0), px(540.0)).min(&bounds.size)), - titlebar: Some(TitleBar::title_bar_options()), - app_id: Some(crate::platform::window_chrome::application_id("pebrel")), - window_background: crate::gpui_shell::wallpaper::initial_background_appearance(), - focus, - ..Default::default() - }) - }, - WindowRole::QuickTerminal => { - let (display_id, visible) = quick_terminal_anchor_display(cx) - .map(|(id, bounds)| (Some(id), bounds)) - .or_else(|| { - cx.primary_display().map(|display| (Some(display.id()), display.bounds())) - }) - .unwrap_or_else(|| (None, Bounds::centered(None, size(px(1080.0), px(720.0)), cx))); - let remembered = nebula_settings::RuntimeSettings::load() - .quick_terminal_size - .map(|size| size.fit(visible.size.width.into(), visible.size.height.into())); - let width = remembered.map_or(visible.size.width, |size| px(size.width)); - let height = remembered.map_or_else( - || px((f32::from(visible.size.height) * 0.4).round().max(1.0)), - |size| px(size.height), - ); - let bounds = Bounds { - origin: point( - visible.origin.x + (visible.size.width - width) * 0.5, - if cfg!(windows) { visible.origin.y - height } else { visible.origin.y }, - ), - size: size(width, height), - }; - WindowOptions { - window_bounds: Some(WindowBounds::Windowed(bounds)), - // 与旧壳一致:无系统标题栏,但保留 Nebula 自绘标题栏及三枚 - // 窗口按钮。TitleBar options 负责 Windows 客户区命中测试。 - titlebar: Some(TitleBar::title_bar_options()), - app_id: Some(crate::platform::window_chrome::application_id( - "pebrel-quick-terminal", - )), - window_background: crate::gpui_shell::wallpaper::initial_background_appearance(), - focus: !cfg!(windows), - show: !cfg!(windows), - display_id, - ..Default::default() - } - }, - } -} - fn allocate_window(cx: &mut App) -> (u64, crate::runtime_api::RuntimeHub) { let registry = cx.global_mut::(); let id = registry.next_window_id; @@ -473,12 +399,15 @@ fn open_workspace_window( let start_hidden = shell_events.is_some() && matches!(startup, WorkspaceStartup::RestoreOrDefault) && crate::platform::startup::start_hidden(&runtime); - let mut options = workspace_window_options(cx, focus, role, runtime.sidebar_width); + startup_geometry::load_restore(runtime_window_id, &startup, role, cx); + let mut options = + startup_geometry::options(cx, focus, role, runtime.sidebar_width, runtime_window_id); if start_hidden { options.show = false; options.focus = false; } let deferred_show = crate::platform::startup::first_frame::defer_show(&mut options); + let configured_position_pending = !options.show && role == WindowRole::Regular; let workspace_slot = Rc::new(RefCell::new(None)); let hwnd_slot = Rc::new(RefCell::new(0isize)); let workspace_out = workspace_slot.clone(); @@ -486,6 +415,9 @@ fn open_workspace_window( let handle = cx.open_window(options, move |window, cx| { window.set_window_title(crate::brand::NAME); crate::platform::window_chrome::configure(window); + if role == WindowRole::Regular && !configured_position_pending { + startup_geometry::apply_configured_position(window, cx); + } *hwnd_out.borrow_mut() = native_hwnd(window).unwrap_or_default(); #[cfg(windows)] crate::gpui_shell::set_native_window_icon(window); @@ -529,6 +461,7 @@ fn open_workspace_window( last_activated, native_hwnd, role, + configured_position_pending, }); crate::gpui_shell::wallpaper::refresh(cx); if deferred_show { @@ -563,7 +496,16 @@ fn open_runtime_window( Ok((window_id, pane_id)) } -pub(crate) fn mark_active(runtime_window_id: u64, cx: &mut App) { +pub(crate) fn mark_active(runtime_window_id: u64, window: &Window, cx: &mut App) { + if let Some(entry) = cx + .global_mut::() + .entries + .iter_mut() + .find(|entry| entry.runtime_window_id == runtime_window_id) + && std::mem::take(&mut entry.configured_position_pending) + { + startup_geometry::apply_configured_position(window, cx); + } let registry = cx.global_mut::(); if !registry.entries.iter().any(|entry| { entry.runtime_window_id == runtime_window_id && entry.role == WindowRole::Regular @@ -1368,7 +1310,7 @@ pub(crate) fn autosave_tick(cx: &mut App) { fn combined_session( mut current: Option<(u64, crate::session::Session)>, - cx: &App, + cx: &mut App, ) -> Option { let mut entries = cx .global::() @@ -1390,7 +1332,12 @@ fn combined_session( current.take().expect("current window snapshot exists").1 } else { let Some(workspace) = entry.workspace.upgrade() else { continue }; - workspace.read(cx).snapshot_session(cx) + let mut session = workspace.read(cx).snapshot_session(cx); + session.window = entry + .handle + .update(cx, |_, window, cx| startup_geometry::capture_window(window, cx)) + .ok(); + session }; sessions.push((active_handle == Some(entry.handle), session)); } @@ -1405,8 +1352,9 @@ fn save_combined_session(cx: &mut App, clean: bool) -> std::io::Result<()> { pub(super) fn save_current_window_session( runtime_window_id: u64, - session: crate::session::Session, + mut session: crate::session::Session, reason: SaveReason, + window: &Window, cx: &mut App, ) -> std::io::Result<()> { if !cx.global::().entries.iter().any(|entry| { @@ -1414,6 +1362,7 @@ pub(super) fn save_current_window_session( }) { return Ok(()); } + session.window = Some(startup_geometry::capture_window(window, cx)); let session = combined_session(Some((runtime_window_id, session)), cx); cx.global_mut::().session_persistence.save(session, reason) } @@ -1489,7 +1438,7 @@ pub(super) fn close_saved_workspace_window( cx: &mut App, ) { if let Err(error) = - save_current_window_session(runtime_window_id, session, SaveReason::WindowClose, cx) + save_current_window_session(runtime_window_id, session, SaveReason::WindowClose, window, cx) { log::warn!("Could not checkpoint moved window: {error}"); } @@ -1510,7 +1459,11 @@ pub(crate) fn close_empty_workspace_window( let session = combined_session(None, cx); let reason = if session.is_some() { SaveReason::TabsClosed } else { SaveReason::WindowClose }; - let session = session.unwrap_or_else(|| crate::session::Session::new(0, Vec::new())); + let session = session.unwrap_or_else(|| { + let mut session = crate::session::Session::new(0, Vec::new()); + session.window = Some(startup_geometry::capture_window(window, cx)); + session + }); if let Err(error) = cx.global_mut::().session_persistence.save(Some(session), reason) { diff --git a/nebula_app/src/gpui_shell/workspace/windowing/linux_native_tests.rs b/nebula_app/src/gpui_shell/workspace/windowing/linux_native_tests.rs index 52b7de3e2..d80554e03 100644 --- a/nebula_app/src/gpui_shell/workspace/windowing/linux_native_tests.rs +++ b/nebula_app/src/gpui_shell/workspace/windowing/linux_native_tests.rs @@ -1,6 +1,6 @@ //! Native X11/Wayland lifecycle regression, run only with an isolated CI desktop. use super::*; -use gpui::{MouseButton, MouseDownEvent, MouseUpEvent, PlatformInput}; +use gpui::{MouseButton, MouseDownEvent, MouseUpEvent, PlatformInput, WindowOptions, point}; struct ActivationFixture; impl Render for ActivationFixture { diff --git a/nebula_app/src/gpui_shell/workspace/windowing/startup_geometry.rs b/nebula_app/src/gpui_shell/workspace/windowing/startup_geometry.rs index c2e77ae71..23ba1ddab 100644 --- a/nebula_app/src/gpui_shell/workspace/windowing/startup_geometry.rs +++ b/nebula_app/src/gpui_shell/workspace/windowing/startup_geometry.rs @@ -4,7 +4,11 @@ //! platform exposes display DPI, supply the final size before the window is //! shown; otherwise keep the existing post-creation sizing fallback. -use gpui::{App, Pixels, Size, Window, px, size}; +use super::{WindowRole, WorkspaceStartup}; +use crate::gpui_shell::config::StartupWindow; +use gpui::{ + App, Bounds, Pixels, Point, Size, Window, WindowBounds, WindowOptions, point, px, size, +}; use crate::gpui_shell::terminal::view::TerminalView; @@ -24,11 +28,12 @@ fn default_size( display_limit.map_or(preferred, |limit| preferred.min(&limit)) } -fn display_limit(cx: &App) -> Option> { - cx.primary_display().map(|display| { - let bounds = display.bounds().size; - size(bounds.width * 0.95, bounds.height * 0.95) - }) +fn default_display_limit(visible: Size) -> Size { + size(visible.width * 0.95, visible.height * 0.95) +} + +fn display_limit(window: &Window, cx: &App) -> Option> { + window.display(cx).map(|display| default_display_limit(display.visible_bounds().size)) } fn fit_native_size(preferred: Size, visible: Option>) -> Size { @@ -49,12 +54,221 @@ pub(super) fn preferred_size(cx: &App, sidebar_width: f32) -> Option, + origin: Option>, + visible: Bounds, +) -> Bounds { + let fitted = fit_native_size(preferred, Some(visible.size)); + let origin = origin.unwrap_or_else(|| { + point( + visible.origin.x + (visible.size.width - fitted.width) / 2.0, + visible.origin.y + (visible.size.height - fitted.height) / 2.0, + ) + }); + Bounds::new( + point( + px(origin.x.as_f32().clamp( + visible.left().as_f32(), + (visible.right() - fitted.width).as_f32().max(visible.left().as_f32()), + )), + px(origin.y.as_f32().clamp( + visible.top().as_f32(), + (visible.bottom() - fitted.height).as_f32().max(visible.top().as_f32()), + )), + ), + fitted, + ) +} + +pub(super) fn load_restore(id: u64, startup: &WorkspaceStartup, role: WindowRole, cx: &mut App) { + if id != 1 + || role != WindowRole::Regular + || !matches!(startup, WorkspaceStartup::RestoreOrDefault) + || crate::platform::elevation::requires_isolation() + { + return; + } + let restored = crate::session::load().and_then(|session| session.startup_window_state()); + if cx.has_global::() { + cx.global_mut::().restored = restored; + } +} + +pub(in crate::gpui_shell::workspace) fn restored_size( + id: u64, + startup: &WorkspaceStartup, + cx: &App, +) -> bool { + id == 1 + && matches!(startup, WorkspaceStartup::RestoreOrDefault) + && cx + .try_global::() + .is_some_and(|config| config.dimensions.is_none() && config.restored.is_some()) +} + +pub(super) fn options( + cx: &mut App, + focus: bool, + role: WindowRole, + sidebar_width: f32, + id: u64, +) -> WindowOptions { + match role { + WindowRole::Regular => regular_options(id, focus, sidebar_width, cx), + WindowRole::QuickTerminal => { + let (display_id, visible) = super::quick_terminal_anchor_display(cx) + .map(|(id, bounds)| (Some(id), bounds)) + .or_else(|| { + cx.primary_display().map(|display| (Some(display.id()), display.bounds())) + }) + .unwrap_or_else(|| (None, Bounds::centered(None, size(px(1080.0), px(720.0)), cx))); + let remembered = nebula_settings::RuntimeSettings::load() + .quick_terminal_size + .map(|size| size.fit(visible.size.width.into(), visible.size.height.into())); + let width = remembered.map_or(visible.size.width, |size| px(size.width)); + let height = remembered.map_or_else( + || px((f32::from(visible.size.height) * 0.4).round().max(1.0)), + |size| px(size.height), + ); + let bounds = Bounds { + origin: point( + visible.origin.x + (visible.size.width - width) * 0.5, + if cfg!(windows) { visible.origin.y - height } else { visible.origin.y }, + ), + size: size(width, height), + }; + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + // 与旧壳一致:无系统标题栏,但保留 Nebula 自绘标题栏及三枚 + // 窗口按钮。TitleBar options 负责 Windows 客户区命中测试。 + titlebar: Some(gpui_component::TitleBar::title_bar_options()), + app_id: Some(crate::platform::window_chrome::application_id( + "pebrel-quick-terminal", + )), + window_background: crate::gpui_shell::wallpaper::initial_background_appearance(), + focus: !cfg!(windows), + show: !cfg!(windows), + display_id, + ..Default::default() + } + }, + } +} + +pub(super) fn regular_options( + id: u64, + focus: bool, + sidebar_width: f32, + cx: &mut App, +) -> WindowOptions { + let config = cx.try_global::(); + let restored = (id == 1).then(|| config.and_then(|config| config.restored)).flatten(); + let configured = + config.and_then(|config| config.position).map(|position| (position.x, position.y)); + let physical = configured.or_else(|| restored.and_then(|window| window.position)); + let physical_display = configured.and_then(crate::platform::startup::physical_display); + let displays = cx.displays(); + let selected = physical_display + .and_then(|(id, _)| displays.iter().find(|display| u64::from(display.id()) == id).cloned()) + .or_else(|| { + restored.and_then(|window| window.display).and_then(|uuid| { + displays + .iter() + .find(|display| display.uuid().is_ok_and(|id| id.as_bytes() == &uuid)) + .cloned() + }) + }) + .or_else(|| { + physical.and_then(crate::platform::startup::physical_display).and_then(|(id, _)| { + displays.iter().find(|display| u64::from(display.id()) == id).cloned() + }) + }) + .or_else(|| cx.primary_display()); + let display_id = selected.as_ref().map(|display| display.id()); + let scale = display_id.and_then(|id| crate::platform::startup::display_scale(u64::from(id))); + let visible = selected + .map(|display| display.visible_bounds()) + .unwrap_or_else(|| Bounds::centered(None, size(px(1080.0), px(720.0)), cx)); + let configured_dimensions = config.is_some_and(|config| config.dimensions.is_some()); + let preferred = if !configured_dimensions && let Some(restored) = restored { + size(px(restored.width as f32), px(restored.height as f32)) + } else if let Some(scale) = scale { + default_size( + TerminalView::startup_cell_metrics_at_scale(scale, cx), + crate::gpui_shell::config::startup_grid(cx), + sidebar_width, + Some(default_display_limit(visible.size)), + ) + } else { + preferred_size(cx, sidebar_width).unwrap_or_else(|| size(px(1080.0), px(720.0))) + }; + let origin = physical + .zip(scale) + .map(|((x, y), scale)| point(px(x as f32 / scale), px(y as f32 / scale))); + let mut bounds = fit_bounds(preferred, origin, visible); + if let Some(scale) = scale { + let offset = crate::platform::startup::placement_offset(u64::from(display_id.unwrap())); + let desktop = (bounds.origin.x.as_f32(), bounds.origin.y.as_f32()); + let origin = crate::platform::startup::workspace_position( + ((desktop.0 * scale).round() as i32, (desktop.1 * scale).round() as i32), + scale, + offset, + ); + bounds.origin = point(px(origin.0), px(origin.1)); + } + let maximized = !configured_dimensions + && configured.is_none() + && restored.is_some_and(|window| window.maximized); + crate::platform::window_chrome::configure_options(WindowOptions { + window_bounds: Some(if maximized { + WindowBounds::Maximized(bounds) + } else { + WindowBounds::Windowed(bounds) + }), + window_min_size: Some(size(px(760.0), px(540.0)).min(&bounds.size)), + titlebar: Some(gpui_component::TitleBar::title_bar_options()), + app_id: Some(crate::platform::window_chrome::application_id("pebrel")), + window_background: crate::gpui_shell::wallpaper::initial_background_appearance(), + focus, + display_id, + ..Default::default() + }) +} + +pub(super) fn capture_window(window: &Window, cx: &App) -> crate::session::WindowState { + let normal = window.window_bounds(); + let bounds = normal.get_bounds(); + let display = window.display(cx); + let position = crate::platform::startup::normal_position( + bounds.origin, + window.scale_factor(), + display.as_ref().map(|display| u64::from(display.id())), + ); + crate::session::WindowState { + width: bounds.size.width.as_f32().round().max(1.0) as u32, + height: bounds.size.height.as_f32().round().max(1.0) as u32, + maximized: matches!(normal, WindowBounds::Maximized(_)), + position, + display: display.and_then(|display| display.uuid().ok()).map(|id| *id.as_bytes()), + } +} + +pub(super) fn apply_configured_position(window: &Window, cx: &App) { + if let Some(position) = cx.try_global::().and_then(|config| config.position) + && !crate::platform::startup::place_configured_window(window, (position.x, position.y)) + { + log::warn!("Could not apply configured window position"); + } +} + fn same_device_size(actual: Size, requested: Size, scale: f32) -> bool { // Window placement quantizes logical sizes to device pixels. A subpixel // round-trip difference must not schedule a second asynchronous SetWindowPos. @@ -75,10 +289,13 @@ pub(in crate::gpui_shell::workspace) fn prepare_initial_grid( TerminalView::startup_cell_metrics(window, cx), crate::gpui_shell::config::startup_grid(cx), sidebar_width, - display_limit(cx), + display_limit(window, cx), ); let requested = if crate::platform::startup::primary_display_scale().is_some() { - fit_preflight_size(requested, cx) + fit_native_size( + requested, + window.display(cx).map(|display| display.visible_bounds().size), + ) } else { // Keep the original sizing policy on platforms without preflight DPI. requested @@ -109,6 +326,57 @@ pub(in crate::gpui_shell::workspace) fn prepare_initial_grid( mod tests { use super::*; + #[test] + fn restoring_a_large_legal_window_does_not_shrink_it() { + let visible = Bounds::new(point(px(0.0), px(0.0)), size(px(1920.0), px(1040.0))); + let bounds = + fit_bounds(size(px(1900.0), px(1030.0)), Some(point(px(10.0), px(5.0))), visible); + assert_eq!(bounds.size, size(px(1900.0), px(1030.0))); + assert_eq!(bounds.origin, point(px(10.0), px(5.0))); + } + + #[test] + fn restored_bounds_stay_visible_on_the_selected_negative_origin_display() { + let visible = Bounds::new(point(px(-1920.0), px(40.0)), size(px(1920.0), px(1040.0))); + let bounds = + fit_bounds(size(px(1300.0), px(800.0)), Some(point(px(-1700.0), px(120.0))), visible); + assert_eq!(bounds.origin, point(px(-1700.0), px(120.0))); + assert_eq!(bounds.size, size(px(1300.0), px(800.0))); + let offscreen = + fit_bounds(size(px(3000.0), px(2000.0)), Some(point(px(9000.0), px(-9000.0))), visible); + assert!(visible.contains(&offscreen.origin)); + assert!(offscreen.right() <= visible.right()); + assert!(offscreen.bottom() <= visible.bottom()); + } + + #[cfg(feature = "gpui-test-support")] + #[gpui::test] + fn restored_dimensions_apply_only_to_the_first_window_and_explicit_grid_wins( + cx: &mut gpui::TestAppContext, + ) { + cx.update(|cx| { + cx.set_global(StartupWindow { + restored: Some(crate::session::WindowState { + width: 900, + height: 650, + ..Default::default() + }), + ..Default::default() + }); + assert!(restored_size(1, &WorkspaceStartup::RestoreOrDefault, cx)); + assert!(!restored_size(2, &WorkspaceStartup::RestoreOrDefault, cx)); + assert!(!restored_size(1, &WorkspaceStartup::Empty, cx)); + let options = regular_options(1, false, 230.0, cx); + assert_eq!( + options.window_bounds.unwrap().get_bounds().size, + size(px(900.0), px(650.0)) + ); + cx.global_mut::().dimensions = + Some(crate::config::window::Dimensions { columns: 155, lines: 43 }); + assert!(!restored_size(1, &WorkspaceStartup::RestoreOrDefault, cx)); + }); + } + #[test] fn initial_window_keeps_the_base_font_grid_and_fits_the_display() { let metrics = (px(8.0), px(20.0)); diff --git a/nebula_app/src/gpui_shell/workspace/windowing/transfer_tests.rs b/nebula_app/src/gpui_shell/workspace/windowing/transfer_tests.rs index 49cb28391..5ce7eab50 100644 --- a/nebula_app/src/gpui_shell/workspace/windowing/transfer_tests.rs +++ b/nebula_app/src/gpui_shell/workspace/windowing/transfer_tests.rs @@ -1,7 +1,7 @@ use super::*; use crate::gpui_shell::terminal::view::TerminalLaunch; use crate::gpui_shell::workspace::tab_drag::{TabDrag, TabDragAxis}; -use gpui::TestAppContext; +use gpui::{TestAppContext, size}; pub(super) fn initialize_test(cx: &mut TestAppContext) { cx.update(|cx| { @@ -112,6 +112,52 @@ fn moving_one_of_two_tabs_preserves_source_and_identity(cx: &mut TestAppContext) }); } +#[gpui::test] +fn ordinary_window_geometry_is_in_the_durable_snapshot(cx: &mut TestAppContext) { + initialize_test(cx); + cx.update(|cx| { + let (id, _) = open_test_window(cx, 1); + let entry = entry_by_id(id, cx).unwrap(); + let expected = entry + .handle + .update(cx, |_, window, _| { + window.resize(size(px(1300.0), px(800.0))); + window.window_bounds().get_bounds().size + }) + .unwrap(); + let saved = combined_session(None, cx).unwrap().into_update_windows().unwrap(); + let geometry = saved[0].window.expect("ordinary window geometry must survive process exit"); + assert_eq!(geometry.width, expected.width.as_f32().round() as u32); + assert_eq!(geometry.height, expected.height.as_f32().round() as u32); + assert!(!geometry.maximized); + }); +} + +#[gpui::test] +fn ordinary_window_geometry_survives_closing_the_last_empty_window(cx: &mut TestAppContext) { + initialize_test(cx); + cx.update(|cx| { + cx.global_mut::().session_persistence = + SessionPersistence::ordinary_for_test(); + let (id, _) = open_test_window(cx, 0); + let entry = entry_by_id(id, cx).unwrap(); + let expected = entry + .handle + .update(cx, |_, window, cx| { + let expected = window.window_bounds().get_bounds().size; + close_empty_workspace_window(id, window, cx); + expected + }) + .unwrap(); + let saved = cx.global::().session_persistence.update_windows().unwrap(); + assert!(saved[0].tabs.is_empty(), "closing the last tab must not resurrect a shell"); + let geometry = + saved[0].window.expect("last empty window must preserve its native geometry"); + assert_eq!(geometry.width, expected.width.as_f32().round() as u32); + assert_eq!(geometry.height, expected.height.as_f32().round() as u32); + }); +} + #[gpui::test] fn alt_release_tears_out_tab_from_within_the_window(cx: &mut TestAppContext) { // 最大化/铺满单屏时光标无法移出 viewport,Alt 强制撕出必须在窗口内部的 diff --git a/nebula_app/src/platform/startup.rs b/nebula_app/src/platform/startup.rs index 144b0b7b8..c8f93bd96 100644 --- a/nebula_app/src/platform/startup.rs +++ b/nebula_app/src/platform/startup.rs @@ -203,3 +203,251 @@ mod tests { } } } + +/// GPUI Windows display ids are the borrowed HMONITOR value at the pinned backend. +pub(crate) fn physical_display(point: (i32, i32)) -> Option<(u64, f32)> { + #[cfg(windows)] + { + use windows_sys::Win32::Foundation::POINT; + use windows_sys::Win32::Graphics::Gdi::{MONITOR_DEFAULTTONEAREST, MonitorFromPoint}; + // SAFETY: this queries a borrowed monitor handle without retaining it. + let monitor = + unsafe { MonitorFromPoint(POINT { x: point.0, y: point.1 }, MONITOR_DEFAULTTONEAREST) }; + display_scale(monitor as u64).map(|scale| (monitor as u64, scale)) + } + #[cfg(not(windows))] + { + let _ = point; + None + } +} + +pub(crate) fn display_scale(id: u64) -> Option { + #[cfg(windows)] + { + use windows_sys::Win32::UI::HiDpi::{GetDpiForMonitor, MDT_EFFECTIVE_DPI}; + let (mut x, mut y) = (0, 0); + let monitor = id as *mut core::ffi::c_void; + // SAFETY: the borrowed monitor and local DPI output pointers stay valid. + if monitor.is_null() + || unsafe { GetDpiForMonitor(monitor, MDT_EFFECTIVE_DPI, &mut x, &mut y) } < 0 + || x == 0 + || x != y + { + None + } else { + Some(x as f32 / 96.0) + } + } + #[cfg(not(windows))] + { + let _ = id; + None + } +} + +pub(crate) fn placement_offset(id: u64) -> (i32, i32) { + #[cfg(windows)] + { + use windows_sys::Win32::Graphics::Gdi::{GetMonitorInfoW, MONITORINFO}; + // SAFETY: MONITORINFO is a plain output structure; cbSize is set below. + let mut info: MONITORINFO = unsafe { std::mem::zeroed() }; + info.cbSize = std::mem::size_of::() as u32; + // SAFETY: the borrowed display handle and initialized output are valid. + if unsafe { GetMonitorInfoW(id as *mut core::ffi::c_void, &mut info) } != 0 { + return (info.rcWork.left - info.rcMonitor.left, info.rcWork.top - info.rcMonitor.top); + } + } + let _ = id; + (0, 0) +} + +#[cfg(windows)] +pub(crate) fn place_normal_window(hwnd: isize, desired: (i32, i32)) -> bool { + use windows_sys::Win32::Foundation::RECT; + use windows_sys::Win32::Graphics::Gdi::{ + GetMonitorInfoW, MONITOR_DEFAULTTONEAREST, MONITORINFO, MonitorFromWindow, + }; + use windows_sys::Win32::UI::WindowsAndMessaging::{ + GetWindowRect, SWP_NOACTIVATE, SWP_NOSIZE, SWP_NOZORDER, SetWindowPos, + }; + let hwnd = hwnd as *mut core::ffi::c_void; + // SAFETY: both native output structures admit zero initialization. + let mut rect: RECT = unsafe { std::mem::zeroed() }; + let mut monitor: MONITORINFO = unsafe { std::mem::zeroed() }; + monitor.cbSize = std::mem::size_of::() as u32; + // SAFETY: the caller owns the live HWND; the local outputs remain valid. + if unsafe { GetWindowRect(hwnd, &mut rect) } == 0 + || unsafe { + GetMonitorInfoW(MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST), &mut monitor) + } == 0 + { + return false; + } + let x = desired.0.clamp( + monitor.rcWork.left, + (monitor.rcWork.right - (rect.right - rect.left)).max(monitor.rcWork.left), + ); + let y = desired.1.clamp( + monitor.rcWork.top, + (monitor.rcWork.bottom - (rect.bottom - rect.top)).max(monitor.rcWork.top), + ); + // SAFETY: only the live window position changes; ownership and size are preserved. + unsafe { + SetWindowPos( + hwnd, + std::ptr::null_mut(), + x, + y, + 0, + 0, + SWP_NOSIZE | SWP_NOACTIVATE | SWP_NOZORDER, + ) != 0 + } +} + +#[cfg(feature = "gpui-shell")] +pub(crate) fn normal_position( + origin: gpui::Point, + scale: f32, + display: Option, +) -> Option<(i32, i32)> { + #[cfg(windows)] + return display.map(|display| { + let offset = placement_offset(display); + desktop_position((origin.x.as_f32(), origin.y.as_f32()), scale, offset) + }); + #[cfg(not(windows))] + { + let _ = (origin, scale, display); + None + } +} + +#[cfg(feature = "gpui-shell")] +pub(crate) fn place_configured_window(window: &gpui::Window, desired: (i32, i32)) -> bool { + #[cfg(windows)] + { + use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle}; + let Ok(handle) = HasWindowHandle::window_handle(window) else { return false }; + let RawWindowHandle::Win32(handle) = handle.as_raw() else { return false }; + place_normal_window(handle.hwnd.get(), desired) + } + #[cfg(not(windows))] + { + let _ = (window, desired); + true + } +} + +/// Convert the pinned Windows backend's client/workspace point to desktop pixels. +pub(crate) fn desktop_position(origin: (f32, f32), scale: f32, offset: (i32, i32)) -> (i32, i32) { + ((origin.0 * scale).round() as i32 + offset.0, (origin.1 * scale).round() as i32 + offset.1) +} + +/// Convert desktop pixels to the client/workspace point accepted by GPUI placement. +pub(crate) fn workspace_position( + position: (i32, i32), + scale: f32, + offset: (i32, i32), +) -> (f32, f32) { + ((position.0 - offset.0) as f32 / scale, (position.1 - offset.1) as f32 / scale) +} + +#[cfg(test)] +mod geometry_tests { + #[test] + fn windows_workspace_desktop_roundtrip_keeps_top_and_left_taskbars_at_fractional_dpi() { + for scale in [1.0, 1.25, 1.5, 2.0] { + for offset in [(0, 0), (0, 60), (80, 0)] { + for position in [(-1700, 120), (197, 102), (0, 0)] { + let origin = super::workspace_position(position, scale, offset); + assert_eq!(super::desktop_position(origin, scale, offset), position); + } + } + } + } +} + +#[cfg(all(test, windows, feature = "gpui-shell"))] +mod native_geometry_tests { + use windows_sys::Win32::Foundation::RECT; + use windows_sys::Win32::Graphics::Gdi::{ + GetMonitorInfoW, MONITOR_DEFAULTTONEAREST, MONITORINFO, MonitorFromWindow, + }; + use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW; + use windows_sys::Win32::UI::WindowsAndMessaging::{ + CreateWindowExW, DestroyWindow, GetClientRect, GetWindowPlacement, GetWindowRect, + SetWindowPlacement, WINDOWPLACEMENT, WS_OVERLAPPEDWINDOW, + }; + + struct NativeWindow(isize); + impl Drop for NativeWindow { + fn drop(&mut self) { + // SAFETY: this test owns the native window until teardown. + assert_ne!(unsafe { DestroyWindow(self.0 as _) }, 0); + } + } + + #[test] + fn physical_outer_position_and_backend_normal_placement_roundtrip() { + // SAFETY: STATIC is a registered class; no pointer escapes the native call. + let hwnd = unsafe { + CreateWindowExW( + 0, + windows_core::w!("STATIC").as_ptr(), + windows_core::w!("Pebrel geometry test").as_ptr(), + WS_OVERLAPPEDWINDOW, + 0, + 0, + 320, + 240, + std::ptr::null_mut(), + std::ptr::null_mut(), + GetModuleHandleW(std::ptr::null()), + std::ptr::null(), + ) + }; + assert!(!hwnd.is_null()); + let _window = NativeWindow(hwnd as isize); + // SAFETY: plain native output structures admit zero initialization. + let (mut rect, mut client): (RECT, RECT) = unsafe { std::mem::zeroed() }; + let mut info: MONITORINFO = unsafe { std::mem::zeroed() }; + info.cbSize = std::mem::size_of::() as u32; + // SAFETY: the owned HWND and initialized output structure are valid. + let monitor = unsafe { MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST) }; + assert_ne!(unsafe { GetMonitorInfoW(monitor, &mut info) }, 0); + let desired = (info.rcWork.left + 197, info.rcWork.top + 102); + assert!(super::place_normal_window(hwnd as isize, desired)); + // SAFETY: all output pointers refer to initialized local structures. + assert_ne!(unsafe { GetWindowRect(hwnd, &mut rect) }, 0); + assert_ne!(unsafe { GetClientRect(hwnd, &mut client) }, 0); + assert_eq!((rect.left, rect.top), desired); + let before = (rect.left, rect.top, rect.right, rect.bottom); + let border = ( + (rect.right - rect.left - client.right) / 2, + (rect.bottom - rect.top - client.bottom) / 2, + ); + let mut placement: WINDOWPLACEMENT = unsafe { std::mem::zeroed() }; + placement.length = std::mem::size_of::() as u32; + assert_ne!(unsafe { GetWindowPlacement(hwnd, &mut placement) }, 0); + let scale = super::display_scale(monitor as u64).unwrap(); + let native = placement.rcNormalPosition; + let logical = + ((native.left + border.0) as f32 / scale, (native.top + border.1) as f32 / scale); + let offset = super::placement_offset(monitor as u64); + let saved = super::desktop_position(logical, scale, offset); + let restored = super::workspace_position(saved, scale, offset); + let x = (restored.0 * scale).round() as i32 - border.0; + let y = (restored.1 * scale).round() as i32 - border.1; + placement.rcNormalPosition = RECT { + left: x, + top: y, + right: x + native.right - native.left, + bottom: y + native.bottom - native.top, + }; + assert_ne!(unsafe { SetWindowPlacement(hwnd, &placement) }, 0); + assert_ne!(unsafe { GetWindowRect(hwnd, &mut rect) }, 0); + assert_eq!((rect.left, rect.top, rect.right, rect.bottom), before); + } +} diff --git a/nebula_app/src/session.rs b/nebula_app/src/session.rs index 87117cba3..8e8e163f8 100644 --- a/nebula_app/src/session.rs +++ b/nebula_app/src/session.rs @@ -219,16 +219,19 @@ impl TabSession { } /// Last normal (non-maximized, non-fullscreen) inner size in logical pixels. -/// Logical units keep the perceived size stable when the next launch lands on -/// a monitor with a different DPI scale factor. Write-only since v4: startup -/// always sizes the window from the configured column/line count, so this -/// record is diagnostic and forward-compat data, never replayed at boot. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +/// Logical units keep the perceived size stable across display DPI changes. +/// Windows position is the normal client origin in physical desktop pixels; +/// the stable display identity keeps an existing monitor as the restore target. +#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] pub struct WindowState { pub width: u32, pub height: u32, #[serde(default)] pub maximized: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub position: Option<(i32, i32)>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display: Option<[u8; 16]>, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -447,7 +450,8 @@ mod tests { #[test] fn window_state_round_trip_preserves_logical_size_and_maximize() { let mut session = Session::new(0, Vec::new()); - session.window = Some(WindowState { width: 1280, height: 720, maximized: true }); + session.window = + Some(WindowState { width: 1280, height: 720, maximized: true, ..Default::default() }); let json = serde_json::to_string(&session).unwrap(); let restored: Session = serde_json::from_str(&json).unwrap(); assert_eq!(restored.window, session.window); diff --git a/nebula_app/src/session/window_layout.rs b/nebula_app/src/session/window_layout.rs index ee44a7801..c168920b0 100644 --- a/nebula_app/src/session/window_layout.rs +++ b/nebula_app/src/session/window_layout.rs @@ -35,6 +35,21 @@ pub(crate) fn combine_sessions( } impl Session { + pub(crate) fn startup_window_state(&self) -> Option { + if self.boot_attempts >= super::MAX_BOOT_ATTEMPTS { + return None; + } + self.window + .or_else(|| { + self.window_layout + .iter() + .find(|layout| layout.active) + .and_then(|layout| layout.window) + }) + .or_else(|| self.window_layout.first().and_then(|layout| layout.window)) + .filter(|window| window.width > 0 && window.height > 0) + } + /// Open the previously active window last so native activation restores it. /// Invalid boundaries are an error, never permission to drop or duplicate tabs. pub(crate) fn into_update_windows(self) -> std::io::Result> { @@ -90,6 +105,36 @@ mod tests { ) } + #[test] + fn startup_geometry_survives_atomic_empty_and_multiwindow_roundtrips() { + let first = WindowState { + width: 1300, + height: 800, + position: Some((-1500, 120)), + display: Some([7; 16]), + ..Default::default() + }; + let active = + WindowState { width: 1100, height: 700, maximized: true, ..Default::default() }; + let mut left = window(&["/first"], 0); + left.window = Some(first); + let mut right = window(&[], 0); + right.window = Some(active); + let combined = combine_sessions([(false, left), (true, right)]).unwrap(); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("session.json"); + crate::session::save_to(&path, &combined).unwrap(); + let mut loaded = crate::session::load_from(&path).unwrap(); + assert_eq!(loaded.startup_window_state(), Some(active)); + assert_eq!(loaded.clone().into_update_windows().unwrap()[0].window, Some(first)); + loaded.boot_attempts = super::super::MAX_BOOT_ATTEMPTS; + assert_eq!(loaded.startup_window_state(), None); + let legacy: WindowState = + serde_json::from_str(r#"{"width":900,"height":650,"maximized":false}"#).unwrap(); + assert_eq!(legacy.position, None); + assert_eq!(legacy.display, None); + } + #[test] fn saved_workspace_retains_window_boundaries_and_active_tabs_for_update() { let first = window(&["/first", "/second"], 1); diff --git a/nebula_app/src/window_context/session_snapshot.rs b/nebula_app/src/window_context/session_snapshot.rs index b779e205c..181f25596 100644 --- a/nebula_app/src/window_context/session_snapshot.rs +++ b/nebula_app/src/window_context/session_snapshot.rs @@ -26,6 +26,7 @@ impl WindowContext { width: self.windowed_size.width, height: self.windowed_size.height, maximized, + ..Default::default() } } else { // Normal state: take the current size straight from the window. @@ -34,7 +35,12 @@ impl WindowContext { // every relaunch. let logical: LogicalSize = self.display.window.inner_size().to_logical(self.display.window.scale_factor); - session::WindowState { width: logical.width, height: logical.height, maximized } + session::WindowState { + width: logical.width, + height: logical.height, + maximized, + ..Default::default() + } }); session } diff --git a/scripts/tests/test_pebrel_branding.py b/scripts/tests/test_pebrel_branding.py index cf2978c9c..6509e8867 100644 --- a/scripts/tests/test_pebrel_branding.py +++ b/scripts/tests/test_pebrel_branding.py @@ -34,8 +34,12 @@ def test_visible_identity_and_cli_use_pebrel(self): self.assertIn('DEFAULT_CLASS: &str = "Pebrel";', window) gpui = self.source("nebula_app/src/gpui_shell/workspace/windowing.rs") self.assertIn('window.set_window_title(crate::brand::NAME)', gpui) + self.assertIn("startup_geometry::options(", gpui) + geometry = self.source( + "nebula_app/src/gpui_shell/workspace/windowing/startup_geometry.rs" + ) for fallback in ('pebrel', 'pebrel-quick-terminal'): - self.assertRegex(gpui, rf'application_id\(\s*"{fallback}"\s*,?\s*\)') + self.assertRegex(geometry, rf'application_id\(\s*"{fallback}"\s*,?\s*\)') for constant, desktop in ( ('LINUX_APP_ID', 'io.github.kuddev.pebrel'), ('LINUX_PREVIEW_APP_ID', 'io.github.kuddev.pebrel.preview'),