设置新增「应用 › 窗口」:记住窗口大小和位置
- 设置左栏在「关于」上面新增「应用」,「窗口」组两个开关,默认都打开: 记住窗口大小(含最大化状态)、记住窗口位置(连同所在屏幕),下次启动 时生效。 - 窗口移动、缩放停下后记进数据目录的 window.json,退出时再记一次。 - 恢复时那块屏幕不在了、或窗口大半在屏幕外,就放在屏幕中间;比屏幕大 就缩到能放下。全屏不记,按退出全屏后的大小恢复。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1bab1bff50
commit
e9700d0e32
15 files changed
+541
-9
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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`。
|
||||
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
@@ -52,6 +52,7 @@ icon_assets!(
|
||||
RotateCcwClock,
|
||||
Activity,
|
||||
Monitor,
|
||||
AppWindow,
|
||||
Network,
|
||||
RadioTower,
|
||||
ArrowDownLeft,
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -50,6 +50,13 @@ pub fn settings_path() -> std::io::Result<PathBuf> {
|
||||
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 {
|
||||
|
||||
+5
-2
@@ -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));
|
||||
|
||||
@@ -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};
|
||||
@@ -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();
|
||||
|
||||
@@ -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<SettingGroup>;
|
||||
|
||||
/// 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<SettingGroup> {
|
||||
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<SettingGroup> {
|
||||
|
||||
@@ -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};
|
||||
@@ -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<Pixels> {
|
||||
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<String>,
|
||||
/// 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<Pixels>,
|
||||
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<Self> {
|
||||
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<Self>,
|
||||
) {
|
||||
let latest: Rc<RefCell<Option<WindowState>>> = 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<DisplayId>) {
|
||||
let screens: Vec<Screen> = 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<String>,
|
||||
bounds: Bounds<Pixels>,
|
||||
/// Without the menu bar, Dock or taskbar.
|
||||
visible: Bounds<Pixels>,
|
||||
}
|
||||
|
||||
/// [`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<DisplayId>,
|
||||
macos: bool,
|
||||
) -> (WindowBounds, Option<DisplayId>) {
|
||||
let primary = screens
|
||||
.iter()
|
||||
.find(|screen| Some(screen.id) == primary)
|
||||
.or(screens.first());
|
||||
let centered = |screen: Option<&Screen>, size: Size<Pixels>| 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<Pixels>, screen: Bounds<Pixels>) -> 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<Pixels> {
|
||||
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<Screen> {
|
||||
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<Pixels>, 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<DisplayId>) {
|
||||
placement(saved, settings, &screens(), Some(DisplayId::new(1)), macos)
|
||||
}
|
||||
|
||||
fn centered_on_laptop(size: gpui_kit::Size<Pixels>) -> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<String, std::time::Instant>,
|
||||
/// The pending write of the window's size and place, see
|
||||
/// `window_state.rs`.
|
||||
pub(super) window_state_save: Option<Task<()>>,
|
||||
pub(super) _subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Workspace>,
|
||||
|
||||
+1
-1
@@ -230,7 +230,7 @@ fn serve_updates(
|
||||
fn open_about_settings(cx: &mut TestAppContext, handle: WindowHandle<Root>) {
|
||||
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)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user