From e9700d0e324cbfdf2a2cf697d41d7b489edfb67a Mon Sep 17 00:00:00 2001 From: Xuz Date: Tue, 6 Oct 2026 23:17:55 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E6=96=B0=E5=A2=9E=E3=80=8C?= =?UTF-8?q?=E5=BA=94=E7=94=A8=20=E2=80=BA=20=E7=AA=97=E5=8F=A3=E3=80=8D?= =?UTF-8?q?=EF=BC=9A=E8=AE=B0=E4=BD=8F=E7=AA=97=E5=8F=A3=E5=A4=A7=E5=B0=8F?= =?UTF-8?q?=E5=92=8C=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 设置左栏在「关于」上面新增「应用」,「窗口」组两个开关,默认都打开: 记住窗口大小(含最大化状态)、记住窗口位置(连同所在屏幕),下次启动 时生效。 - 窗口移动、缩放停下后记进数据目录的 window.json,退出时再记一次。 - 恢复时那块屏幕不在了、或窗口大半在屏幕外,就放在屏幕中间;比屏幕大 就缩到能放下。全屏不记,按退出全屏后的大小恢复。 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/windows.yml | 1 + CLAUDE.md | 5 + docs/manual.md | 3 +- src/app/assets.rs | 1 + src/app/mod.rs | 1 + src/app/paths.rs | 7 + src/main.rs | 7 +- src/settings/mod.rs | 1 + src/settings/model.rs | 31 +++ src/settings/settings_panel.rs | 48 +++- src/workspace/mod.rs | 2 + src/workspace/window_state.rs | 387 ++++++++++++++++++++++++++++++++ src/workspace/workspace_view.rs | 20 +- tests/ui/settings.rs | 34 +++ tests/ui/update.rs | 2 +- 15 files changed, 541 insertions(+), 9 deletions(-) create mode 100644 src/workspace/window_state.rs diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 1eb62e9..658af0f 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -26,6 +26,7 @@ on: - "src/sftp/upload.rs" - "src/credential/credential_dialog.rs" - "src/settings/settings_panel.rs" + - "src/workspace/window_state.rs" - "src/testing.rs" - "tests/cli_bin.rs" - "build.rs" diff --git a/CLAUDE.md b/CLAUDE.md index 2b2a6ee..362775f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,6 +56,7 @@ ShellRS(crate 与二进制都叫 `shellrs`)是 Xshell / WinSCP 式的 SSH - 双击、回车、F4、右键「编辑」都打开它,本地文件也行;新建文件后直接打开。 - 只认 UTF-8(不做 GBK),上限 5 MB,约 15 种语言语法高亮。 - 远程文件原地改写:截断后写原文件,不走 `.filepart` 加改名。保存前核对,被改过就问「覆盖」。 +- **设置 › 应用 › 窗口**:「记住窗口大小」(含最大化)和「记住窗口位置」两个开关,默认都开,下次启动生效。屏幕不在了或窗口不在屏幕上就居中,比屏幕大就缩小;全屏不记。 - **终端通知**:只做程序请求的(OSC 9 / 777)和响铃,不做 OSC 133 长命令通知:那要每台服务器装 shell 集成,收益不抵成本。不在前台发系统通知,在前台而终端在别的标签发应用内通知;响铃在终端就在眼前时不提醒。 - **主题**:「应用外观」(浅色 / 深色 / 跟随系统)决定深浅;「外观 › 主题」左右两栏里浅色、深色各选一套,按当前外观生效。组里第一项「界面跟随主题」默认关闭:界面是中性的黑白灰(gpui-kit 默认主题),只有终端按主题配色;打开后界面也按主题配色。用户试过默认跟随,觉得中性界面更合适,别改回去。页面「重置」对卡片和开关各管各的。 - 左栏只列浅色主题,右栏只列深色主题;卡片是名称加示例输出,选中的整张加底色,右上角是蓝底(主题的 `blue`)对勾。 @@ -143,6 +144,10 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - 改表要升版本、在 `STEPS` 里加一步,并加一个「用冻结的旧 schema 升级、再和新库比对」的测试。 - 加列直接 `ALTER TABLE ADD COLUMN`,在 `SCHEMA` 里也写在最后一列之后。 - 库只往前升:旧版本遇到新库直接报错、不动文件。迁移前先 `VACUUM INTO` 备份。 +- **窗口的大小和位置存在 `window.json`,不进 `settings.json`**(`workspace/window_state.rs`):移动、缩放时 `observe_window_bounds` 记下,停 0.5 秒再写;退出时再写一次,用 App 级的 `on_app_quit`,因为 Windows / Linux 关窗口时工作区已经先没了。只有 `Workspace::new` 记,UI 测试不碰这个文件。 + - 启动时 `main` 先读设置和 `window.json` 再开窗口,`window_options` 按两个开关算 `window_bounds` 和 `display_id`。 + - 屏幕按 `PlatformDisplay::uuid` 认(`DisplayId` 重启会变)。GPUI 的窗口坐标在 macOS 上从所在屏幕左上角算,别处是整个桌面,原样存、原样交回,配上同一个 `display_id` 就能落回原处。 + - macOS 的 `window_bounds()` 从不报最大化,靠 `is_maximized()`(大小等于屏幕可见区)判断,恢复时给可见区大小的普通窗口;别的平台用 `WindowBounds::Maximized`。 - **一个数据目录只跑一个 ShellRS。** 起图形界面之前先连外部 CLI 的套接字:连得上就发 `Request::Activate`(带启动链接)然后退出。为兼容已装的 CLI,不升 `PROTOCOL_VERSION`。 - **临时主机在 `HostStore` 的 `temporary` 里**:`host()` / `login()` 找得到,`hosts()` 看不到,也不写库。会写外键的检查用 `saved_host`。最后一个标签关掉就 `remove_temporary`。 diff --git a/docs/manual.md b/docs/manual.md index c04dedc..8da6ea9 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -100,7 +100,7 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已 ## 设置 -侧栏最下方的「设置」按钮(或 ⌘,,其他平台 Ctrl+,)在中间区打开「设置」标签页;已经打开时切换到该标签,不会重复打开。设置页分两栏:左边是分类(外观、终端、关键字高亮、外部 CLI、关于),上方可搜索,栏宽可拖动;右边是所选分类的设置。设置改动立即生效并保存在数据目录的 `settings.json` 里。 +侧栏最下方的「设置」按钮(或 ⌘,,其他平台 Ctrl+,)在中间区打开「设置」标签页;已经打开时切换到该标签,不会重复打开。设置页分两栏:左边是分类(外观、终端、关键字高亮、外部 CLI、应用、关于),上方可搜索,栏宽可拖动;右边是所选分类的设置。设置改动立即生效并保存在数据目录的 `settings.json` 里。 - **外观 → 常规 → 界面语言**:跟随系统、简体中文、English,默认简体中文。英文界面尚在翻译中,目前只有 GPUI Kit 组件自带的文字(如搜索框提示)会切换。 - **外观 → 常规 → 应用外观**:深色、浅色、跟随系统,默认跟随系统;选「跟随系统」时随系统的深浅色切换。标题栏的主题按钮也会改写这一项。 @@ -108,6 +108,7 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已 - **终端 → 字体配置**:字体(只列出本机安装的等宽字体,默认是系统的等宽字体,macOS 上为 Menlo)、字号(8–32 像素,默认 13)、行高(字号的 1.0–2.0 倍,默认 1.54,即原来的 20 像素行高)。改动立即作用于所有已打开的终端。下方的预览按终端的排法显示字母、数字、易混字符、符号、中文、制表符和一行命令。设置文件里的字体在本机没有安装时,使用默认字体。 - **关键字高亮**:总开关、预览和规则表,终端里按规则给文字上色、匹配时提醒,见「终端」一节。 - **外部 CLI**:见下一节。 +- **应用 → 窗口**:「记住窗口大小」恢复上次的窗口大小和最大化状态,「记住窗口位置」把窗口放回上次所在的屏幕和位置,默认都打开,下次启动时生效。那块屏幕不在了、或窗口大半在屏幕外时,放在屏幕中间;窗口比屏幕大时缩到能放下。全屏不记,按退出全屏后的大小恢复。窗口的大小和位置随移动随时记在数据目录的 `window.json` 里。 - **关于**:「应用更新」一组:当前版本和检查更新、更新渠道、自动升级,见「下载与更新」。 ## 外部 CLI diff --git a/src/app/assets.rs b/src/app/assets.rs index 48e69c7..2caa1b3 100644 --- a/src/app/assets.rs +++ b/src/app/assets.rs @@ -52,6 +52,7 @@ icon_assets!( RotateCcwClock, Activity, Monitor, + AppWindow, Network, RadioTower, ArrowDownLeft, diff --git a/src/app/mod.rs b/src/app/mod.rs index 97aaf85..4b30ebc 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -12,6 +12,7 @@ pub use app_icon::show_logo_when_unbundled; pub use assets::{AppAssets, CatalogIcon, DOCKER_ICON}; pub use paths::{ cli_endpoint, cli_socket_path, data_dir, database_path, keys_dir, settings_path, updates_dir, + window_state_path, }; pub use quit::{quit_held_back, set_quit_guard}; pub use window_hiding::{bring_forward, hide_when_closed}; diff --git a/src/app/paths.rs b/src/app/paths.rs index 0f90073..c132200 100644 --- a/src/app/paths.rs +++ b/src/app/paths.rs @@ -50,6 +50,13 @@ pub fn settings_path() -> std::io::Result { Ok(dir.join("settings.json")) } +/// The main window's last size, maximized state and place, for the next +/// launch (设置 › 应用 › 窗口). Kept apart from the settings: it changes +/// whenever the window moves. +pub fn window_state_path() -> PathBuf { + data_dir().join("window.json") +} + /// Where the running app listens for the `shellrs` command. The command /// and the app both ask here, so both follow `SHELLRS_DATA_DIR`. pub fn cli_socket_path() -> PathBuf { diff --git a/src/main.rs b/src/main.rs index 329e219..7e0d60c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,7 +43,11 @@ fn main() { shellrs::app::show_logo_when_unbundled(cx); cx.activate(true); - let options = shellrs::workspace::window_options(cx); + // Read before the window opens: 应用 › 窗口 decides where. + let (settings, settings_problem) = open_settings(); + let saved = shellrs::workspace::WindowState::load(&shellrs::app::window_state_path()); + let options = + shellrs::workspace::window_options(saved.as_ref(), settings.settings().window, cx); cx.spawn(async move |cx| { cx.open_window(options, |window, cx| { window.activate_window(); @@ -51,7 +55,6 @@ fn main() { shellrs::app::hide_when_closed(window, cx); let (store, problem) = open_store(); let store = cx.new(|_| store); - let (settings, settings_problem) = open_settings(); let settings = cx.new(|_| settings); let workspace = cx.new(|cx| shellrs::workspace::Workspace::new(store, settings, window, cx)); diff --git a/src/settings/mod.rs b/src/settings/mod.rs index fb5fc22..04be418 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -12,6 +12,7 @@ pub use apply::apply; pub use model::{ AppSettings, Appearance, Choice, ExternalCliSettings, InterfaceLanguage, NotificationSettings, TerminalFontSettings, TerminalHighlightSettings, TerminalThemeSettings, UpdateSettings, + WindowSettings, }; pub use settings_panel::{SettingsPanel, SettingsPanelEvent}; pub use store::{SettingsStore, SettingsStoreEvent}; diff --git a/src/settings/model.rs b/src/settings/model.rs index d2bd9bb..fc91770 100644 --- a/src/settings/model.rs +++ b/src/settings/model.rs @@ -32,6 +32,28 @@ pub struct AppSettings { pub notifications: NotificationSettings, /// 关键字高亮. pub terminal_highlight: TerminalHighlightSettings, + /// 应用 → 窗口. + pub window: WindowSettings, +} + +/// 应用 → 窗口: what the next launch restores of the main window. Both on +/// until the user turns them off. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct WindowSettings { + /// 记住窗口大小: its size, and whether it was maximized. + pub remember_size: bool, + /// 记住窗口位置: where it was, on which screen. + pub remember_position: bool, +} + +impl Default for WindowSettings { + fn default() -> Self { + Self { + remember_size: true, + remember_position: true, + } + } } /// 外观 → 主题: by key, the theme used while the app is light and the one @@ -531,6 +553,15 @@ mod tests { assert!(!themes.is_default_choice()); } + #[test] + fn the_window_is_remembered_unless_the_file_says_otherwise() { + let settings: AppSettings = serde_json::from_str("{}").unwrap(); + assert!(settings.window.remember_size && settings.window.remember_position); + let settings: AppSettings = + serde_json::from_str(r#"{"window":{"remember_position":false}}"#).unwrap(); + assert!(settings.window.remember_size && !settings.window.remember_position); + } + #[test] fn automatic_updates_are_on_unless_the_file_says_otherwise() { let settings: AppSettings = serde_json::from_str("{}").unwrap(); diff --git a/src/settings/settings_panel.rs b/src/settings/settings_panel.rs index 852f45b..60eda08 100644 --- a/src/settings/settings_panel.rs +++ b/src/settings/settings_panel.rs @@ -35,7 +35,7 @@ use crate::update::{Phase, Tone, UpdateSnapshot, UpdateStep, Updater, build_info use super::highlight_rules::HighlightRulesEditor; use super::terminal_themes::{app_follows_item, terminal_theme_item}; -use super::{AppSettings, Choice, NotificationSettings, SettingsStore}; +use super::{AppSettings, Choice, NotificationSettings, SettingsStore, WindowSettings}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SettingsPanelEvent { @@ -230,7 +230,7 @@ struct Category { type CategoryGroups = fn(&SettingsPanel, &App) -> Vec; /// The categories, in the order the left column lists them. -const CATEGORIES: [Category; 5] = [ +const CATEGORIES: [Category; 6] = [ Category { title: "外观", icon: CatalogIcon::Palette, @@ -251,6 +251,11 @@ const CATEGORIES: [Category; 5] = [ icon: CatalogIcon::SquareTerminal, groups: external_cli_groups, }, + Category { + title: "应用", + icon: CatalogIcon::AppWindow, + groups: application_groups, + }, Category { title: "关于", icon: CatalogIcon::Info, @@ -501,6 +506,45 @@ fn highlight_preview_item() -> SettingItem { .keywords(["预览", "高亮"]) } +/// 应用 → 窗口: what the next launch restores of the main window. +fn application_groups(panel: &SettingsPanel, _: &App) -> Vec { + let store = &panel.store; + let switch = |read: fn(&WindowSettings) -> bool, write: fn(&mut WindowSettings, bool)| { + let (reader, writer) = (store.clone(), store.clone()); + SettingField::switch( + move |cx| read(&reader.read(cx).settings().window), + move |on, cx| { + writer.update(cx, |store, cx| { + store.update(|settings| write(&mut settings.window, on), cx) + }); + }, + ) + .default_value(true) + }; + vec![ + SettingGroup::new().title("窗口").items([ + SettingItem::new( + "记住窗口大小", + switch( + |window| window.remember_size, + |window, on| window.remember_size = on, + ), + ) + .description("下次启动时恢复上次关闭时的窗口大小和最大化状态。"), + SettingItem::new( + "记住窗口位置", + switch( + |window| window.remember_position, + |window, on| window.remember_position = on, + ), + ) + .description( + "下次启动时把窗口放回上次所在的屏幕和位置;那块屏幕不在时放在主屏幕中间。", + ), + ]), + ] +} + /// 外部 CLI: whether the `shellrs` command may use the saved hosts, the /// command itself, and the skill that teaches agents to use it. fn external_cli_groups(panel: &SettingsPanel, cx: &App) -> Vec { diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs index e3d5731..579e168 100644 --- a/src/workspace/mod.rs +++ b/src/workspace/mod.rs @@ -16,7 +16,9 @@ mod title_bar; mod tool_sidebar; mod tools; mod updates; +mod window_state; mod workspace_view; pub use links::open_link_once_open; +pub use window_state::WindowState; pub use workspace_view::{Workspace, notify_once_open, window_options}; diff --git a/src/workspace/window_state.rs b/src/workspace/window_state.rs new file mode 100644 index 0000000..58be985 --- /dev/null +++ b/src/workspace/window_state.rs @@ -0,0 +1,387 @@ +//! 设置 › 应用 › 窗口: the main window's size, maximized state and place, +//! kept in `window.json` as they change, and where the next launch opens +//! the window from them. + +use std::cell::RefCell; +use std::path::{Path, PathBuf}; +use std::rc::Rc; +use std::time::Duration; + +use gpui_kit::*; +use serde::{Deserialize, Serialize}; + +use crate::settings::WindowSettings; + +use super::Workspace; + +/// How long a move or resize has to settle before it is written: a drag +/// reports every step. +const SAVE_DELAY: Duration = Duration::from_millis(500); + +/// A new window's size, and the size of one whose size is not remembered. +fn default_size() -> Size { + size(px(1280.), px(800.)) +} + +/// How much of a window has to be on its screen for it to open where it +/// was: enough of the title bar to drag it by. +const VISIBLE_WIDTH: f32 = 120.; +const VISIBLE_HEIGHT: f32 = 40.; + +/// The main window as it last was. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct WindowState { + /// The screen it was on, as the system names it across restarts. + display: Option, + /// Its size and place when neither maximized nor full screen, as GPUI + /// gives and takes them: from the screen's top left on macOS, the + /// desktop's elsewhere. + bounds: Bounds, + maximized: bool, +} + +impl WindowState { + pub fn capture(window: &Window, cx: &App) -> Self { + // macOS never reports a window maximized, only its frame filling + // the screen; a full screen window comes back as it was before. + let (bounds, maximized) = match window.window_bounds() { + WindowBounds::Windowed(bounds) => (bounds, window.is_maximized()), + WindowBounds::Maximized(bounds) => (bounds, true), + WindowBounds::Fullscreen(bounds) => (bounds, false), + }; + let display = window + .display(cx) + .and_then(|display| display.uuid().ok()) + .map(|uuid| uuid.to_string()); + Self { + display, + bounds, + maximized, + } + } + + /// The state saved at `path`; none when there is none or it does not + /// read, and the window opens as a new one would. + pub fn load(path: &Path) -> Option { + serde_json::from_slice(&std::fs::read(path).ok()?).ok() + } + + /// Written whole, through a temporary file, as the settings are. + pub fn save(&self, path: &Path) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let temporary = path.with_extension("json.tmp"); + std::fs::write(&temporary, serde_json::to_vec_pretty(self)?)?; + std::fs::rename(temporary, path) + } +} + +impl Workspace { + /// Keep the window's size and place at `path` as they change, for the + /// next launch. Always kept: 设置 › 应用 › 窗口 decides what of it the + /// next launch uses. + pub(super) fn remember_window( + &mut self, + path: PathBuf, + window: &mut Window, + cx: &mut Context, + ) { + let latest: Rc>> = Rc::default(); + // Written again on quitting, in case a change has not settled. The + // app's, not this view's: on Windows and Linux closing the window + // has dropped the view by then. + App::on_app_quit(cx, { + let (latest, path) = (latest.clone(), path.clone()); + move |_| { + if let Some(state) = latest.borrow().as_ref() { + write(state, &path); + } + async {} + } + }) + .detach(); + let subscription = cx.observe_window_bounds(window, move |this, window, cx| { + let state = WindowState::capture(window, cx); + if latest.borrow().as_ref() == Some(&state) { + return; + } + *latest.borrow_mut() = Some(state.clone()); + let path = path.clone(); + this.window_state_save = Some(cx.spawn(async move |_, cx| { + cx.background_executor().timer(SAVE_DELAY).await; + cx.background_spawn(async move { write(&state, &path) }) + .await; + })); + }); + self._subscriptions.push(subscription); + } +} + +/// Saving the window's place is a convenience: a failure is only logged. +fn write(state: &WindowState, path: &Path) { + if let Err(error) = state.save(path) { + eprintln!("shellrs: 无法保存窗口位置:{error}"); + } +} + +/// Where the main window opens: `saved`, as much of it as `settings` +/// remember, on the screens there are now. +pub fn window_placement( + saved: Option<&WindowState>, + settings: WindowSettings, + cx: &App, +) -> (WindowBounds, Option) { + let screens: Vec = cx + .displays() + .iter() + .map(|display| Screen { + id: display.id(), + uuid: display.uuid().ok().map(|uuid| uuid.to_string()), + bounds: display.bounds(), + visible: display.visible_bounds(), + }) + .collect(); + let primary = cx.primary_display().map(|display| display.id()); + placement( + saved, + settings, + &screens, + primary, + cfg!(target_os = "macos"), + ) +} + +/// A screen the window could open on. +struct Screen { + id: DisplayId, + uuid: Option, + bounds: Bounds, + /// Without the menu bar, Dock or taskbar. + visible: Bounds, +} + +/// [`window_placement`] over given screens. `macos`: maximized is a frame +/// filling the screen's visible part, as macOS shows it, not a state. +fn placement( + saved: Option<&WindowState>, + settings: WindowSettings, + screens: &[Screen], + primary: Option, + macos: bool, +) -> (WindowBounds, Option) { + let primary = screens + .iter() + .find(|screen| Some(screen.id) == primary) + .or(screens.first()); + let centered = |screen: Option<&Screen>, size: Size| match screen { + Some(screen) => Bounds::centered_at(screen.bounds.center(), size), + None => Bounds::new(point(px(0.), px(0.)), size), + }; + let Some(saved) = saved.filter(|_| settings.remember_size || settings.remember_position) else { + return ( + WindowBounds::Windowed(centered(primary, default_size())), + None, + ); + }; + + // The screen it was on, if remembered and still there; else the main + // one. + let remembered = saved + .display + .as_ref() + .filter(|_| settings.remember_position) + .and_then(|uuid| { + screens + .iter() + .find(|screen| screen.uuid.as_ref() == Some(uuid)) + }); + let screen = remembered.or(primary); + let size = if settings.remember_size { + // Taken from a larger screen, it shrinks to fit this one. + match screen { + Some(screen) => size( + saved.bounds.size.width.min(screen.visible.size.width), + saved.bounds.size.height.min(screen.visible.size.height), + ), + None => saved.bounds.size, + } + } else { + default_size() + }; + let bounds = match remembered { + Some(screen) if reachable(Bounds::new(saved.bounds.origin, size), screen.bounds) => { + Bounds::new(saved.bounds.origin, size) + } + _ => centered(screen, size), + }; + let display = remembered.map(|screen| screen.id); + if settings.remember_size && saved.maximized { + return match screen { + Some(screen) if macos => (WindowBounds::Windowed(screen.visible), display), + _ => (WindowBounds::Maximized(bounds), display), + }; + } + (WindowBounds::Windowed(bounds), display) +} + +/// Whether enough of `bounds` is on the screen to take hold of: its top +/// edge on the screen, and a stretch of title bar with it. +fn reachable(bounds: Bounds, screen: Bounds) -> bool { + let overlap = bounds.intersect(&screen); + bounds.top() >= screen.top() + && bounds.top() <= screen.bottom() - px(VISIBLE_HEIGHT) + && overlap.size.width >= px(VISIBLE_WIDTH) + && overlap.size.height >= px(VISIBLE_HEIGHT) +} + +#[cfg(test)] +mod tests { + use gpui_kit::{Bounds, DisplayId, Pixels, WindowBounds, point, px, size}; + + use super::{Screen, WindowState, default_size, placement}; + use crate::settings::WindowSettings; + + fn rect(x: f32, y: f32, width: f32, height: f32) -> Bounds { + Bounds::new(point(px(x), px(y)), size(px(width), px(height))) + } + + /// A laptop screen and a larger one to its right, the laptop's menu + /// bar and Dock taken from its visible part. + fn screens() -> Vec { + vec![ + Screen { + id: DisplayId::new(1), + uuid: Some("laptop".into()), + bounds: rect(0., 0., 1512., 982.), + visible: rect(0., 33., 1512., 890.), + }, + Screen { + id: DisplayId::new(2), + uuid: Some("monitor".into()), + bounds: rect(1512., 0., 2560., 1440.), + visible: rect(1512., 0., 2560., 1440.), + }, + ] + } + + fn saved(display: &str, bounds: Bounds, maximized: bool) -> WindowState { + WindowState { + display: Some(display.into()), + bounds, + maximized, + } + } + + fn remember(remember_size: bool, remember_position: bool) -> WindowSettings { + WindowSettings { + remember_size, + remember_position, + } + } + + fn place( + saved: Option<&WindowState>, + settings: WindowSettings, + macos: bool, + ) -> (WindowBounds, Option) { + placement(saved, settings, &screens(), Some(DisplayId::new(1)), macos) + } + + fn centered_on_laptop(size: gpui_kit::Size) -> WindowBounds { + WindowBounds::Windowed(Bounds::centered_at( + rect(0., 0., 1512., 982.).center(), + size, + )) + } + + #[test] + fn a_first_launch_opens_in_the_middle_of_the_main_screen() { + let opened = place(None, WindowSettings::default(), false); + assert_eq!(opened, (centered_on_laptop(default_size()), None)); + } + + #[test] + fn the_window_comes_back_where_it_was_on_the_screen_it_was_on() { + let was = rect(1700., 120., 1600., 1000.); + let state = saved("monitor", was, false); + assert_eq!( + place(Some(&state), WindowSettings::default(), false), + (WindowBounds::Windowed(was), Some(DisplayId::new(2))) + ); + + // Without its place, it keeps its size in the middle of the main + // screen, shrunk to fit there. + assert_eq!( + place(Some(&state), remember(true, false), false), + (centered_on_laptop(size(px(1512.), px(890.))), None) + ); + // Without its size, a new window's size where it was. + assert_eq!( + place(Some(&state), remember(false, true), false), + ( + WindowBounds::Windowed(Bounds::new(was.origin, default_size())), + Some(DisplayId::new(2)) + ) + ); + // Neither: as a new window. + assert_eq!( + place(Some(&state), remember(false, false), false), + (centered_on_laptop(default_size()), None) + ); + } + + #[test] + fn a_window_whose_screen_is_gone_or_that_is_off_it_is_centered() { + let state = saved("projector", rect(100., 100., 1000., 700.), false); + assert_eq!( + place(Some(&state), WindowSettings::default(), false), + (centered_on_laptop(size(px(1000.), px(700.))), None) + ); + // Dragged up past the top, or almost all off the side. + for was in [ + rect(100., -200., 1000., 700.), + rect(1450., 100., 1000., 700.), + ] { + let state = saved("laptop", was, false); + let (bounds, display) = place(Some(&state), WindowSettings::default(), false); + assert_eq!(bounds, centered_on_laptop(was.size)); + assert_eq!(display, Some(DisplayId::new(1))); + } + } + + #[test] + fn a_maximized_window_opens_maximized_if_its_size_is_remembered() { + let restored = rect(1700., 120., 1600., 1000.); + let state = saved("monitor", restored, true); + assert_eq!( + place(Some(&state), WindowSettings::default(), false), + (WindowBounds::Maximized(restored), Some(DisplayId::new(2))) + ); + // On macOS, as a frame filling the screen but its menu bar and Dock. + let state = saved("laptop", rect(0., 33., 1512., 890.), true); + assert_eq!( + place(Some(&state), WindowSettings::default(), true), + ( + WindowBounds::Windowed(rect(0., 33., 1512., 890.)), + Some(DisplayId::new(1)) + ) + ); + assert!(matches!( + place(Some(&state), remember(false, true), true).0, + WindowBounds::Windowed(bounds) if bounds.size == default_size() + )); + } + + #[test] + fn the_state_is_kept_in_a_file_and_a_bad_one_is_ignored() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("data").join("window.json"); + assert_eq!(WindowState::load(&path), None); + let state = saved("monitor", rect(1700., 120., 1600., 1000.), true); + state.save(&path).unwrap(); + assert_eq!(WindowState::load(&path), Some(state)); + std::fs::write(&path, "{\"bounds\": 7}").unwrap(); + assert_eq!(WindowState::load(&path), None); + } +} diff --git a/src/workspace/workspace_view.rs b/src/workspace/workspace_view.rs index 86ac371..1e92a02 100644 --- a/src/workspace/workspace_view.rs +++ b/src/workspace/workspace_view.rs @@ -53,6 +53,7 @@ use crate::host::{ }; use crate::settings::{ Appearance, SettingsPanel, SettingsPanelEvent, SettingsStore, SettingsStoreEvent, + WindowSettings, }; use crate::sftp::{ SharedLocalDirectoryProvider, SharedSftpTransportProvider, SshSftpTransportProvider, @@ -75,6 +76,7 @@ use super::{ title_bar::render_title_bar, tool_sidebar::{ToolSidebar, render_tool_switch}, tools::{TOOL_SIDEBAR_WIDTH, set_right_dock_open}, + window_state::{WindowState, window_placement}, }; const DOCK_ID: &str = "shellrs-dock"; @@ -106,11 +108,18 @@ pub fn notify_once_open(notification: Notification, window: &mut Window, cx: &mu }); } -/// Window options for the main workspace window. -pub fn window_options(cx: &mut App) -> WindowOptions { +/// Window options for the main workspace window: where it was last, as much +/// of it as 设置 › 应用 › 窗口 remembers. +pub fn window_options( + saved: Option<&WindowState>, + settings: WindowSettings, + cx: &mut App, +) -> WindowOptions { + let (window_bounds, display_id) = window_placement(saved, settings, cx); WindowOptions { + window_bounds: Some(window_bounds), + display_id, // Window geometry is a platform boundary; `px` is the API's unit. - window_bounds: Some(WindowBounds::centered(size(px(1280.), px(800.)), cx)), window_min_size: Some(gpui_kit::Size { width: px(960.), height: px(600.), @@ -192,6 +201,9 @@ pub struct Workspace { /// When each terminal last notified, of each kind, keyed by /// `notices::notice_tag` and the kind. pub(super) notice_times: HashMap, + /// The pending write of the window's size and place, see + /// `window_state.rs`. + pub(super) window_state_save: Option>, pub(super) _subscriptions: Vec, } @@ -250,6 +262,7 @@ impl Workspace { ), } this.sync_cli_server(cx); + this.remember_window(crate::app::window_state_path(), window, cx); let bundle = cx.app_path().ok(); this.updater.update(cx, |updater, cx| { updater.set_services(UpdateServices::system(bundle), cx); @@ -548,6 +561,7 @@ impl Workspace { editors: HashMap::new(), next_editor_id: 1, notice_times: HashMap::new(), + window_state_save: None, _subscriptions: subscriptions, } } diff --git a/tests/ui/settings.rs b/tests/ui/settings.rs index dae05eb..04e9308 100644 --- a/tests/ui/settings.rs +++ b/tests/ui/settings.rs @@ -436,6 +436,40 @@ fn terminal_themes_follow_the_appearance_and_a_card_chooses_one(cx: &mut TestApp cx.update(|cx| assert_eq!(cx.theme().theme_name().as_ref(), "Default Light")); } +#[gpui_kit::test] +fn the_window_settings_are_on_until_turned_off(cx: &mut TestAppContext) { + let (handle, workspace) = open_workspace(cx); + let window = |cx: &mut TestAppContext| { + cx.update(|cx| workspace.read(cx).settings().read(cx).settings().window) + }; + in_frame(cx, handle, |window, cx| window.click("open-settings", cx)); + // 应用 comes right before 关于. + in_frame(cx, handle, |window, cx| { + window.within("settings").click("0-4", cx) + }); + let switch = |item: usize| { + move |window: &mut gpui_kit::Window| { + window + .within("settings") + .within("group-0") + .within(format!("item-{item}")) + .find("check") + .checked() + } + }; + in_frame(cx, handle, |window, cx| { + assert_eq!(switch(0)(window), Some(true)); + assert_eq!(switch(1)(window), Some(true)); + window + .within("settings") + .within("group-0") + .within("item-1") + .click("check", cx); + }); + let settings = window(cx); + assert!(settings.remember_size && !settings.remember_position); +} + /// The 关键字高亮 rules, as the settings hold them. fn highlight_rules( workspace: &Entity, diff --git a/tests/ui/update.rs b/tests/ui/update.rs index 5c8e994..e251ac3 100644 --- a/tests/ui/update.rs +++ b/tests/ui/update.rs @@ -230,7 +230,7 @@ fn serve_updates( fn open_about_settings(cx: &mut TestAppContext, handle: WindowHandle) { in_frame(cx, handle, |window, cx| window.click("open-settings", cx)); in_frame(cx, handle, |window, cx| { - window.within("settings").click("0-4", cx) + window.within("settings").click("0-5", cx) }); }