From 8e0ae68bcf1be7639a9af4c0b65e30ba965d26b4 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:32:33 +0800 Subject: [PATCH] Settings: interface size (90-150%) with Cmd/Ctrl +/-/0 The whole interface scales through the webview zoom (WKWebView pageZoom, WebView2 ZoomFactor, WebKitGTK zoom-level via Tauri's set_zoom), so layout, icons and spacing grow together without touching CSS. - Settings > General gets an "Interface size" row under Appearance: 90% / 100% / 110% / 125% / 150%, default 100%, applied on click. - Cmd/Ctrl+= (also +) zooms in one step, Cmd/Ctrl+- out, Cmd/Ctrl+0 resets to 100%. Listed in the shortcut sheet and searchable in the command palette. Tauri's zoom_hotkeys_enabled stays off. - Stored as "zoom" in app.json (Prefs::zoom, read field by field like the others). Not part of the synced general prefs: the right size depends on each computer's screen. - The main window is zoomed right after it is built, so a cold start does not paint a 100% frame first. Its default and minimum size scale with the zoom, so the layout keeps the same CSS width as at 100% and does not drop below the responsive breakpoints; both are clamped to the screen's work area (150% on a 1080p screen at 125% system scaling does not fit vertically otherwise). Changing the zoom resets the minimum size. - The update window and the connection picker follow the zoom too: window::fit converts their measured CSS size to points. Closes #355 Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/client_prefs.rs | 14 ++ src-tauri/src/connection/mod.rs | 9 +- src-tauri/src/lib.rs | 6 + src-tauri/src/prefs.rs | 11 +- src-tauri/src/settings.rs | 49 +++++- src-tauri/src/updater.rs | 11 +- src-tauri/src/window.rs | 33 +++- src-tauri/src/zoom.rs | 233 ++++++++++++++++++++++++++++ src/palette/ShortcutSheet.tsx | 3 + src/palette/items.tsx | 23 +++ src/palette/keys.tsx | 16 ++ src/palette/palette.i18n.ts | 16 ++ src/palette/sections.ts | 4 +- src/palette/zoom.test.ts | 25 +++ src/settings/GeneralSection.i18n.ts | 11 +- src/settings/GeneralSection.tsx | 39 +++++ src/settings/api.ts | 11 ++ src/settings/zoom.ts | 12 ++ src/shell/Workspace.tsx | 12 +- 19 files changed, 525 insertions(+), 13 deletions(-) create mode 100644 src-tauri/src/zoom.rs create mode 100644 src/palette/zoom.test.ts create mode 100644 src/settings/zoom.ts diff --git a/src-tauri/src/client_prefs.rs b/src-tauri/src/client_prefs.rs index 5bc28074..a1247e45 100644 --- a/src-tauri/src/client_prefs.rs +++ b/src-tauri/src/client_prefs.rs @@ -341,6 +341,20 @@ mod tests { assert_eq!(merged(&p, None)["check_updates"], true); } + /// 界面大小跟着这台电脑的屏幕走:不交上去,别处交下来的也不盖 + #[test] + fn the_interface_size_stays_on_this_computer() { + let big = crate::zoom::Zoom::try_from(150).unwrap(); + let p = Prefs { + zoom: big, + ..Prefs::default() + }; + assert!(!general(&p).contains_key("zoom")); + assert!(same(&Prefs::default(), &p)); + let got = overlay(&Prefs::default(), &serde_json::json!({ "zoom": 150 })); + assert_eq!(got.zoom, crate::zoom::Zoom::default()); + } + #[test] fn sameness_only_looks_at_the_shared_settings() { let a = Prefs::default(); diff --git a/src-tauri/src/connection/mod.rs b/src-tauri/src/connection/mod.rs index 261ac3d8..4db73b16 100644 --- a/src-tauri/src/connection/mod.rs +++ b/src-tauri/src/connection/mod.rs @@ -828,7 +828,11 @@ pub fn show_picker(app: &tauri::AppHandle, why: launch::Why) -> tauri::Result<() crate::i18n::init_script(), reason )) - .inner_size(PICKER_WIDTH, 300.0) + // 按界面大小放大,高度由 `picker_fit` 按内容定(见 `window::fit`) + .inner_size( + PICKER_WIDTH * crate::zoom::current().factor(), + 300.0 * crate::zoom::current().factor(), + ) .resizable(false) .minimizable(false) .maximizable(false) @@ -839,7 +843,8 @@ pub fn show_picker(app: &tauri::AppHandle, why: launch::Why) -> tauri::Result<() Some(dir) => b.data_directory(dir), None => b, }; - b.build()?; + let w = b.build()?; + crate::zoom::zoom_new(&w); Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index faf68d1a..acde7fba 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -76,6 +76,7 @@ pub mod window; pub mod winreg; pub mod wire; pub mod zai; +pub mod zoom; use control::ControlClient; use gateway::{ @@ -259,6 +260,9 @@ pub fn run() { settings::set_language, settings::app_theme, settings::set_theme, + settings::ui_zoom, + settings::set_ui_zoom, + settings::step_ui_zoom, updater::update_check, updater::update_show, updater::update_pending, @@ -315,6 +319,8 @@ pub fn run() { i18n::set(i18n::effective(saved.language)); // 外观在窗口出现之前就设好,不然会先画一帧系统那一档的颜色 theme::init(&handle, saved.theme); + // 界面大小也是:窗口一建出来就按它缩放(见 `window::show_main_window`) + zoom::remember(saved.zoom); // 链接、开机自启、通知登记改成指向正在运行的这个 exe(见 `winreg`)。 // 要在下面判断系统通知能不能用之前 winreg::claim(&handle); diff --git a/src-tauri/src/prefs.rs b/src-tauri/src/prefs.rs index 4f28dc9f..30f7e34e 100644 --- a/src-tauri/src/prefs.rs +++ b/src-tauri/src/prefs.rs @@ -29,6 +29,10 @@ pub struct Prefs { pub language: Option, /// 界面外观。`None` 是跟随系统,理由同上。 pub theme: Option, + /// 界面大小(整个界面等比缩放,见 `zoom`)。**每台电脑各自的**:合适的大小取决于这台 + /// 电脑的屏幕,不同步(不在 `client_prefs` 的列表里)。没写就是 100% + #[serde(default)] + pub zoom: crate::zoom::Zoom, /// 提醒:系统通知 / 仅在应用内 / 关闭。**只有这一个,不分类。** pub notices: Mode, /// 菜单栏上显示什么:标识和数值(出厂)/ 仅标识 / 仅数值 / 不显示。 @@ -54,6 +58,7 @@ impl Default for Prefs { check_updates: true, language: None, theme: None, + zoom: crate::zoom::Zoom::default(), notices: Mode::System, menubar: crate::menubar::Style::Full, client_locations: BTreeMap::new(), @@ -102,6 +107,7 @@ fn from_fields(fields: &Fields) -> Prefs { check_updates: field(fields, "check_updates").unwrap_or(d.check_updates), language: field(fields, "language").unwrap_or(d.language), theme: field(fields, "theme").unwrap_or(d.theme), + zoom: field(fields, "zoom").unwrap_or(d.zoom), notices: field(fields, "notices").unwrap_or(d.notices), menubar: field(fields, "menubar").unwrap_or(d.menubar), // 一个客户端一个客户端地读:读不懂的只丢那一个 @@ -286,6 +292,7 @@ mod tests { check_updates: false, language: Some(Lang::Zh), theme: Some(Theme::Dark), + zoom: crate::zoom::Zoom::try_from(125).unwrap(), notices: Mode::App, menubar: crate::menubar::Style::Numbers, client_locations: BTreeMap::from([( @@ -326,6 +333,7 @@ mod tests { assert_eq!(p.language, Some(Lang::En)); assert!(!p.check_updates); assert_eq!(p.menubar, crate::menubar::Style::Full); + assert_eq!(p.zoom, crate::zoom::Zoom::default()); assert!(p.client_locations.is_empty()); } @@ -394,13 +402,14 @@ mod tests { let (_tmp, dir) = tmp(); std::fs::write( prefs_path(&dir), - br#"{"check_updates":"no","language":"ja","theme":1,"notices":"off","menubar":"icon"}"#, + br#"{"check_updates":"no","language":"ja","theme":1,"zoom":120,"notices":"off","menubar":"icon"}"#, ) .unwrap(); let p = load(&dir); assert!(p.check_updates); assert_eq!(p.language, None); assert_eq!(p.theme, None); + assert_eq!(p.zoom, crate::zoom::Zoom::default(), "不是那几档的按 100%"); assert_eq!(p.notices, Mode::Off); assert_eq!(p.menubar, crate::menubar::Style::Icon); } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 3fc8792c..8a291af5 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -1,4 +1,4 @@ -//! 设置页:语言、外观、开机启动、提醒方式、菜单栏样式。 +//! 设置页:语言、外观、界面大小、开机启动、提醒方式、菜单栏样式。 use std::sync::Arc; @@ -6,7 +6,7 @@ use tauri::{Emitter, Manager}; use crate::{ AppState, autostart, data_dir, error::Out, gateway::locate_core, i18n, menubar, notices, prefs, - theme, + theme, zoom, }; /// 这个应用自己的信息。 @@ -104,6 +104,51 @@ pub async fn set_theme(app: tauri::AppHandle, setting: Option) -> Ok(theme_view()) } +/// 界面大小,百分比(见 `zoom`) +#[tauri::command] +pub fn ui_zoom() -> zoom::Zoom { + zoom::current() +} + +/// 设置页选一档 +#[tauri::command] +pub async fn set_ui_zoom(app: tauri::AppHandle, zoom: zoom::Zoom) -> Out { + change_zoom(app, move |_| zoom).await +} + +/// 快捷键:放大一档、缩小一档、回到 100%。**新的一档在这边算**:按住 ⌘= 不放时一连串 +/// 的按键各自读、改、写,在界面那边算的话几次读到的是同一档 +#[tauri::command] +pub async fn step_ui_zoom(app: tauri::AppHandle, step: zoom::Step) -> Out { + change_zoom(app, move |z| z.step(step)).await +} + +/// 换一档:存下来、开着的窗口当场换,设置页那一行听 `ui-zoom-changed` 跟着动。 +/// +/// **不通知同步**(不调 `client_prefs::changed`):界面大小是这台电脑自己的。 +/// +/// **记下的那一档在存设置的锁里定**(`prefs::update` 一次只改一处):接连几次换档各自 +/// 在阻塞线程上存、各自回来应用,回来的先后不一定是存的先后;应用时一律取最后存下的那 +/// 一档,先回来的那次应用到的也已经是它 +async fn change_zoom( + app: tauri::AppHandle, + f: impl FnOnce(zoom::Zoom) -> zoom::Zoom + Send + 'static, +) -> Out { + let saved = prefs::change(move |p| { + p.zoom = f(p.zoom); + zoom::remember(p.zoom); + }) + .await; + if saved.is_err() { + // 没存上:记下的退回存着的那一档 + zoom::remember(prefs::load(&data_dir()).zoom); + } + let now = zoom::current(); + zoom::apply(&app, now); + let _ = app.emit("ui-zoom-changed", now); + saved.map(|_| now) +} + /// 开机自启现在是开着的吗。 /// /// **默认是关的,而且这不是「还没实现」,是产品决定。**一个装完就自己 diff --git a/src-tauri/src/updater.rs b/src-tauri/src/updater.rs index 45532456..98b9c4d0 100644 --- a/src-tauri/src/updater.rs +++ b/src-tauri/src/updater.rs @@ -184,8 +184,12 @@ pub(crate) fn show_update_window(app: &tauri::AppHandle) -> tauri::Result<()> { let b = WebviewWindowBuilder::new(app, UPDATE_WINDOW, WebviewUrl::default()) .title(tr!("软件更新", "Software Update")) .initialization_script(i18n::init_script()) - // 高度先随便给一个:网页画完量出内容有多高,再由 `update_fit` 定下来 - .inner_size(UPDATE_WIDTH, 220.0) + // 高度先随便给一个:网页画完量出内容有多高,再由 `update_fit` 定下来。 + // 宽度按界面大小放大(见 `window::fit`) + .inner_size( + UPDATE_WIDTH * crate::zoom::current().factor(), + 220.0 * crate::zoom::current().factor(), + ) .resizable(false) .minimizable(false) .maximizable(false) @@ -196,7 +200,8 @@ pub(crate) fn show_update_window(app: &tauri::AppHandle) -> tauri::Result<()> { Some(dir) => b.data_directory(dir), None => b, }; - b.build()?; + let w = b.build()?; + crate::zoom::zoom_new(&w); Ok(()) } diff --git a/src-tauri/src/window.rs b/src-tauri/src/window.rs index d6d7ca5c..b52d3712 100644 --- a/src-tauri/src/window.rs +++ b/src-tauri/src/window.rs @@ -117,15 +117,32 @@ pub(crate) fn show_main_window(app: &tauri::AppHandle) -> tauri::Result<()> { } state => matches!(state, crate::connection::LinkState::Connected { .. }), }); + // **界面大小**(见 `zoom`):窗口的大小和最小尺寸跟着它放大,放不下的夹在屏幕里。 + // 窗口还没建出来,不知道会落在哪块屏幕上,先按主屏算;建好之后按它实际所在的那块 + // 再定一次最小尺寸 + let zoom = crate::zoom::current(); + let room = app + .primary_monitor() + .ok() + .flatten() + .and_then(|m| crate::zoom::room_on(&m)); + let (width, height) = crate::zoom::scaled(crate::zoom::MAIN_SIZE, zoom, room); + let (min_width, min_height) = crate::zoom::scaled(crate::zoom::MAIN_MIN, zoom, room); let b = WebviewWindowBuilder::new(app, "main", WebviewUrl::default()) .title("ThinkWatch Lite") .initialization_script(format!( "{} window.__TW_WARM__ = {warm}; window.__TW_VIBRANT__ = {VIBRANT};", i18n::init_script() )) - .inner_size(1100.0, 720.0) - .min_inner_size(820.0, 560.0) + .inner_size(width, height) + .min_inner_size(min_width, min_height) .visible(!warm); + // 放大后的窗口占满了屏幕的一边:放在正中,不让系统按层叠的位置摆、一截落到屏幕外 + let b = if room.is_some_and(|(rw, rh)| width >= rw || height >= rh) { + b.center() + } else { + b + }; // 绿色版的 WebView2 数据放在它自己的文件夹里(见 `portable::webview_data_dir`)。 // 三扇窗(这里、更新窗口、连接选择)要一致:同一个进程里的 WebView2 共用一个数据目录 let b = match crate::portable::webview_data_dir() { @@ -154,6 +171,12 @@ pub(crate) fn show_main_window(app: &tauri::AppHandle) -> tauri::Result<()> { .build(), ); let w = b.build()?; + // **建好立刻缩放**,不等网页读设置:冷启动的窗口这时已经在屏幕上了,等网页来设的话, + // 先画出来的是 100% 的那一帧。缩放排在主线程的队列上,页面这时还没开始载入 + if zoom != crate::zoom::Zoom::default() { + let _ = w.set_zoom(zoom.factor()); + crate::zoom::fit_main_min(&w, zoom); + } if !warm { return reveal(app, &w); } @@ -214,7 +237,13 @@ pub(crate) fn reveal(app: &tauri::AppHandle, w: &tauri::WebviewWindow) -> tauri: /// 标题栏多高不写死 —— 各版本不一样(Tahoe 上是 32 点),没有标题栏的窗 /// 是 0。`contentLayoutRect` 给的正是没被标题栏盖住的那一块,和整扇窗户 /// 一减就是要补上的数。 +/// +/// **宽高是 CSS 像素**(`width` 是 100% 时的宽度,`height` 是网页量出来的),按界面 +/// 大小(见 `zoom`)换成窗口的点:两扇小窗跟着主窗口一起缩放,150% 时 CSS 的 1px 是 +/// 1.5 个点。标题栏是系统画的,不跟着缩放,所以只乘网页那一块 pub(crate) fn fit(window: &tauri::Window, width: f64, height: f64) -> tauri::Result<()> { + let z = crate::zoom::current().factor(); + let (width, height) = (width * z, height * z); #[cfg(target_os = "macos")] { let w = window.clone(); diff --git a/src-tauri/src/zoom.rs b/src-tauri/src/zoom.rs new file mode 100644 index 00000000..8ec189e1 --- /dev/null +++ b/src-tauri/src/zoom.rs @@ -0,0 +1,233 @@ +//! 界面大小:整个界面等比放大、缩小。 +//! +//! **缩放的是 webview,不是字号。**macOS 上是 WKWebView 的 `pageZoom`,Windows 上是 +//! WebView2 的 `ZoomFactor`,Linux 上是 WebKitGTK 的 `zoom-level`(都经 Tauri 的 +//! `set_zoom`)。和浏览器的「缩放」一样:CSS 的 1px 变成 `factor` 个点,布局、图标、 +//! 间距一起放大,界面一行 CSS 都不用改;改根字号做不到这一点 —— 写死的 px 不跟着变。 +//! +//! **不开 Tauri 的 `zoom_hotkeys_enabled`。**它在 macOS、Linux 上是一段注入的脚本, +//! 按 20% 一档、不存盘;在 Windows 上打开的是 WebView2 自己的缩放,连 Ctrl+滚轮也 +//! 一起开了。快捷键由界面自己接(`src/shell/Workspace.tsx`),走这里的档位、存进 +//! `app.json`。 +//! +//! **每台电脑各自的,不同步。**合适的大小取决于这台电脑的屏幕(1080p 的外接显示器和 +//! 视网膜屏的笔记本不是一回事),所以不在 `client_prefs` 的同步列表里。 +//! +//! 三扇窗都跟着缩放:主窗口、更新窗口、连接选择。两扇小窗按内容定高,网页量出来的是 +//! CSS 像素,[`crate::window::fit`] 乘上 [`current`] 再交给窗口。 + +use std::sync::atomic::{AtomicU16, Ordering}; + +use tauri::Manager; + +/// 设置里的几档,百分比 +pub const STEPS: [u16; 5] = [90, 100, 110, 125, 150]; + +/// 出厂那一档 +const DEFAULT: u16 = 100; + +/// 一档界面大小。**存成百分比的数字**(`"zoom": 125`),界面发来的也是这个数; +/// 不在 [`STEPS`] 里的数读不进来(手改坏了的那一项按出厂值,见 `prefs::from_fields`) +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(try_from = "u16", into = "u16")] +pub struct Zoom(u16); + +impl Default for Zoom { + fn default() -> Self { + Self(DEFAULT) + } +} + +impl TryFrom for Zoom { + type Error = String; + fn try_from(pct: u16) -> Result { + if STEPS.contains(&pct) { + Ok(Self(pct)) + } else { + Err(format!( + "{pct}% is not one of the interface sizes {STEPS:?}" + )) + } + } +} + +impl From for u16 { + fn from(z: Zoom) -> u16 { + z.0 + } +} + +/// 快捷键:放大一档、缩小一档、回到 100% +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Step { + In, + Out, + Reset, +} + +impl Zoom { + /// 交给 webview 的倍数 + pub fn factor(self) -> f64 { + f64::from(self.0) / 100.0 + } + + /// 按一下快捷键之后的那一档。**到头了就停在头上**,不绕回去 + pub fn step(self, step: Step) -> Zoom { + let i = STEPS.iter().position(|&s| s == self.0).unwrap_or(1); + match step { + Step::In => Zoom(STEPS[(i + 1).min(STEPS.len() - 1)]), + Step::Out => Zoom(STEPS[i.saturating_sub(1)]), + Step::Reset => Zoom::default(), + } + } +} + +/// 现在是哪一档。0 是还没读过设置 +static CURRENT: AtomicU16 = AtomicU16::new(0); + +/// 现在是哪一档。**没记下过就读一次设置**:主窗口可能在 `setup` 之前就要开 +/// (单实例转交过来的那一次) +pub fn current() -> Zoom { + match Zoom::try_from(CURRENT.load(Ordering::Relaxed)) { + Ok(z) => z, + Err(_) => { + let z = crate::prefs::load(&crate::data_dir()).zoom; + remember(z); + z + } + } +} + +/// 记下这一档(之后建的窗口、小窗按内容定尺寸都按它) +pub fn remember(z: Zoom) { + CURRENT.store(z.0, Ordering::Relaxed); +} + +/// 主窗口 100% 时的大小和最小尺寸(逻辑点) +pub const MAIN_SIZE: (f64, f64) = (1100.0, 720.0); +pub const MAIN_MIN: (f64, f64) = (820.0, 560.0); + +/// 屏幕上给标题栏和窗口边框留的高度。最小尺寸说的是内容区,整扇窗户比它高一条标题栏 +const FRAME_ROOM: f64 = 40.0; + +/// 100% 时的一个尺寸放大到这一档,**放不下就夹在屏幕的可用范围里**。 +/// +/// 主窗口的最小尺寸要跟着放大:界面按 CSS 像素排版,150% 下 820 点宽的窗口只剩 +/// 547 个 CSS 像素,掉到 640 / 768 的响应式断点下面,布局就换成窄屏的那一套。按比例 +/// 放大之后,最小的窗口在每一档里都还是 820×560 个 CSS 像素。 +/// +/// 但 150% 的最小尺寸是 1230×840 点,Windows 上 1080p 屏、系统缩放 125%(笔记本的出厂 +/// 值)的可用高度只有八百多点 —— 最小尺寸比屏幕还大,窗口就有一截永远在屏幕外面。所以 +/// 夹住:放不下的那一边退到屏幕的可用范围,窄一点的布局总比够不着的窗口好。 +/// `room` 是 `None`(问不到屏幕,比如某些 Wayland 合成器)时不夹 +pub fn scaled(base: (f64, f64), z: Zoom, room: Option<(f64, f64)>) -> (f64, f64) { + let (w, h) = (base.0 * z.factor(), base.1 * z.factor()); + match room { + // 夹也不夹到 100% 的尺寸以下:屏幕小到那个份上,夹了也没用 + Some((rw, rh)) => (w.min(rw).max(base.0.min(w)), h.min(rh).max(base.1.min(h))), + None => (w, h), + } +} + +/// 一块屏幕能放下多大的窗口内容(逻辑点)。工作区不算菜单栏、程序坞、任务栏 +pub fn room_on(m: &tauri::Monitor) -> Option<(f64, f64)> { + let s = m.scale_factor(); + let area = m.work_area().size; + let (w, h) = ( + f64::from(area.width) / s, + f64::from(area.height) / s - FRAME_ROOM, + ); + // 报不出工作区的(报成 0)当问不到 + (w >= 100.0 && h >= 100.0).then_some((w, h)) +} + +/// 这扇窗所在的屏幕能放下多大。不知道在哪块屏幕上时用主屏 +fn room_for(w: &tauri::WebviewWindow) -> Option<(f64, f64)> { + let m = w + .current_monitor() + .ok() + .flatten() + .or_else(|| w.primary_monitor().ok().flatten())?; + room_on(&m) +} + +/// 主窗口按这一档定最小尺寸。窗口比新的最小尺寸小的话,系统把它撑大 +pub fn fit_main_min(w: &tauri::WebviewWindow, z: Zoom) { + let (mw, mh) = scaled(MAIN_MIN, z, room_for(w)); + let _ = w.set_min_size(Some(tauri::LogicalSize::new(mw, mh))); +} + +/// 刚建好的小窗(更新窗口、连接选择)按现在这一档缩放。它们建出来先藏着,量完内容 +/// 才亮出来,所以建好再设不会先闪一下 100% +pub fn zoom_new(w: &tauri::WebviewWindow) { + let z = current(); + if z != Zoom::default() { + let _ = w.set_zoom(z.factor()); + } +} + +/// 换一档:记下来,开着的每扇窗当场换。 +/// +/// 两扇小窗不用在这里改尺寸:缩放一变,网页的可用宽度跟着变,它们量内容的 +/// `ResizeObserver` 会再报一次,[`crate::window::fit`] 按新的一档重新定 +pub fn apply(app: &tauri::AppHandle, z: Zoom) { + remember(z); + for (label, w) in app.webview_windows() { + let _ = w.set_zoom(z.factor()); + if label == "main" { + fit_main_min(&w, z); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn z(pct: u16) -> Zoom { + Zoom::try_from(pct).unwrap() + } + + /// 存成百分比的数字,界面发来的也是这个数;不是那几档的读不进来 + #[test] + fn it_is_written_as_a_percentage_and_only_the_steps_are_read() { + assert_eq!(serde_json::to_string(&z(125)).unwrap(), "125"); + assert_eq!(serde_json::from_str::("150").unwrap(), z(150)); + assert!(serde_json::from_str::("120").is_err()); + assert!(serde_json::from_str::("\"125\"").is_err()); + assert_eq!(Zoom::default(), z(100)); + } + + /// 一档一档地走,到头停住;回到 100% + #[test] + fn steps_move_one_at_a_time_and_stop_at_the_ends() { + assert_eq!(z(100).step(Step::In), z(110)); + assert_eq!(z(110).step(Step::In), z(125)); + assert_eq!(z(150).step(Step::In), z(150)); + assert_eq!(z(100).step(Step::Out), z(90)); + assert_eq!(z(90).step(Step::Out), z(90)); + assert_eq!(z(150).step(Step::Reset), z(100)); + assert_eq!(serde_json::from_str::("\"in\"").unwrap(), Step::In); + } + + /// 最小尺寸跟着放大;放不下的那一边夹在屏幕里,但不比 100% 的还小 + #[test] + fn the_minimum_size_scales_and_is_kept_on_the_screen() { + assert_eq!(scaled(MAIN_MIN, z(100), None), MAIN_MIN); + assert_eq!(scaled(MAIN_MIN, z(150), None), (1230.0, 840.0)); + // macOS,1080p 屏:放得下 + assert_eq!( + scaled(MAIN_MIN, z(150), Some((1920.0, 1000.0))), + (1230.0, 840.0) + ); + // Windows,1080p 屏、系统缩放 125%:高度夹住,宽度放得下 + assert_eq!( + scaled(MAIN_MIN, z(150), Some((1536.0, 792.0))), + (1230.0, 792.0) + ); + // 屏幕比 100% 的最小尺寸还小:不往下夹 + assert_eq!(scaled(MAIN_MIN, z(125), Some((800.0, 500.0))), MAIN_MIN); + assert_eq!(scaled(MAIN_MIN, z(90), Some((1920.0, 1000.0))).0, 738.0); + } +} diff --git a/src/palette/ShortcutSheet.tsx b/src/palette/ShortcutSheet.tsx index 6478b4ec..8352d149 100644 --- a/src/palette/ShortcutSheet.tsx +++ b/src/palette/ShortcutSheet.tsx @@ -52,6 +52,9 @@ export function ShortcutSheet({ open, onOpenChange }: { open: boolean; onOpenCha + + +
diff --git a/src/palette/items.tsx b/src/palette/items.tsx index b3a6db11..47e348ed 100644 --- a/src/palette/items.tsx +++ b/src/palette/items.tsx @@ -10,8 +10,11 @@ import { HistoryIcon, PlusIcon, RotateCwIcon, + ScanIcon, SearchIcon, ZapIcon, + ZoomInIcon, + ZoomOutIcon, } from "lucide-react"; import { textOf, type Lang } from "@/i18n"; import { appText } from "@/App.i18n"; @@ -44,6 +47,7 @@ import { NotSentIcon } from "@/traffic/cells"; import type { ConnView } from "@/connection/api"; import { connText } from "@/connection/connection.i18n"; import { profileName } from "@/connection/describe"; +import { stepZoom } from "@/settings/zoom"; import { COMBOS, pageCombo, type Combo } from "./keys"; import { paletteText } from "./palette.i18n"; import { SETTINGS_SECTIONS } from "./sections"; @@ -235,6 +239,25 @@ export function buildItems(s: Sources): Item[] { keywords: [appText.zh.collapseRail, appText.zh.expandRail, appText.en.collapseRail, appText.en.expandRail], combo: COMBOS.rail, }); + // 界面大小:只在搜的时候出现(平常按快捷键),设置页那一行跟着动(`ui-zoom-changed`) + action("zoom-in", t.zoomIn, , () => stepZoom("in"), { + keywords: other((x) => x.zoomIn), + alias: "zoom", + combo: COMBOS.zoomIn, + searchOnly: true, + }); + action("zoom-out", t.zoomOut, , () => stepZoom("out"), { + keywords: other((x) => x.zoomOut), + alias: "zoom", + combo: COMBOS.zoomOut, + searchOnly: true, + }); + action("zoom-reset", t.zoomReset, , () => stepZoom("reset"), { + keywords: other((x) => x.zoomReset), + alias: "zoom", + combo: COMBOS.zoomReset, + searchOnly: true, + }); if (linked) { action("config-file", app.configFile, , s.shell.configFile, { keywords: [appText.zh.configFile, appText.en.configFile], diff --git a/src/palette/keys.tsx b/src/palette/keys.tsx index bbab31ec..e7f71cb5 100644 --- a/src/palette/keys.tsx +++ b/src/palette/keys.tsx @@ -67,8 +67,24 @@ export const COMBOS = { // macOS 上和访达、邮件一样是 ⌘⌥S;别的平台 Ctrl+Alt 常被输入法和桌面拿去,用 Ctrl+B rail: isMac ? ["mod", "alt", "S"] : ["mod", "B"], shortcuts: ["?"], + // 界面大小:和浏览器一样。键帽上写 `=`(不按 Shift 的那个键),`+` 也认(`zoomStep`) + zoomIn: ["mod", "="], + zoomOut: ["mod", "-"], + zoomReset: ["mod", "0"], } as const satisfies Record; +/** + * 主修饰键加上的这个键是不是在换界面大小:`=` 和 `+` 放大一档(按不按 Shift 都算, + * 小键盘的 + 也是),`-` 缩小一档,`0` 回到 100%。判 `key` 不判 `code`:键盘布局 + * 不同,这几个字符在的位置不同 + */ +export function zoomStep(key: string): "in" | "out" | "reset" | null { + if (key === "=" || key === "+") return "in"; + if (key === "-") return "out"; + if (key === "0") return "reset"; + return null; +} + /** * 占数字键的那几页:源列表从上往下,**设置除外**,最多九页。 * diff --git a/src/palette/palette.i18n.ts b/src/palette/palette.i18n.ts index 36e13453..3e35c54a 100644 --- a/src/palette/palette.i18n.ts +++ b/src/palette/palette.i18n.ts @@ -59,6 +59,9 @@ export const paletteText = messages( searchTraffic: "搜索流量", notices: "打开提醒", refresh: "刷新数据", + zoomIn: "放大界面", + zoomOut: "缩小界面", + zoomReset: "界面恢复为 100%", checkUpdates: "检查更新", shortcuts: "键盘快捷键", @@ -104,6 +107,7 @@ export const paletteText = messages( connections: "远程 本机 服务器", language: "中文 英文 界面语言", appearance: "深色 浅色 主题", + zoom: "缩放 字号 字体 文字 放大 缩小 显示比例", menubar: "状态栏 托盘", autostart: "开机 登录 自启", listen: "端口 地址 局域网 网卡", @@ -117,6 +121,7 @@ export const paletteText = messages( actionAliases: { refresh: "重新读取", rail: "侧栏 源列表 边栏", + zoom: "界面大小 缩放 字号 字体 文字 显示比例", shortcuts: "快捷键 按键 帮助 ?", checkUpdates: "版本 升级", configFile: "配置 yaml", @@ -141,6 +146,9 @@ export const paletteText = messages( openPalette: "打开命令面板", refresh: "刷新数据", rail: "收起或展开源列表", + zoomIn: "放大界面", + zoomOut: "缩小界面", + zoomReset: "界面恢复为 100%", shortcuts: "显示键盘快捷键", search: "搜索流量", rowMove: "上一条、下一条", @@ -200,6 +208,9 @@ export const paletteText = messages( searchTraffic: "Search traffic", notices: "Open notices", refresh: "Refresh data", + zoomIn: "Zoom in", + zoomOut: "Zoom out", + zoomReset: "Reset zoom to 100%", checkUpdates: "Check for updates", shortcuts: "Keyboard shortcuts", @@ -242,6 +253,7 @@ export const paletteText = messages( connections: "remote local server", language: "chinese english", appearance: "dark light theme mode", + zoom: "interface size zoom scale font text larger smaller", menubar: "menu bar status tray", autostart: "login startup launch", listen: "port address lan interface bind", @@ -255,6 +267,7 @@ export const paletteText = messages( actionAliases: { refresh: "reload", rail: "sidebar source list", + zoom: "interface size scale font text larger smaller", shortcuts: "hotkeys keys help ?", checkUpdates: "version upgrade", configFile: "config yaml", @@ -278,6 +291,9 @@ export const paletteText = messages( openPalette: "Open the command palette", refresh: "Refresh data", rail: "Collapse or expand the sidebar", + zoomIn: "Zoom in", + zoomOut: "Zoom out", + zoomReset: "Reset zoom to 100%", shortcuts: "Show keyboard shortcuts", search: "Search traffic", rowMove: "Previous / next request", diff --git a/src/palette/sections.ts b/src/palette/sections.ts index bfd7de76..09bcc3c6 100644 --- a/src/palette/sections.ts +++ b/src/palette/sections.ts @@ -23,13 +23,14 @@ import { syncBackupText } from "@/settings/SyncBackupSection.i18n"; * · `local`:只在连本机时有(诊断包生成在本机的数据目录里)。 * * 设置页上一节(`SettingsGroup`)或一行(`SettingsRow` 的 `anchor`)的锚点就是这里的 - * `id`:语言、外观、菜单栏、开机启动、提醒是「通用」里的几行,更新、诊断包是「关于」 + * `id`:语言、外观、界面大小、菜单栏、开机启动、提醒是「通用」里的几行,更新、诊断包是「关于」 * 里的几行,其余各是一节(见 settings/kit.tsx 的 `jump`)。 */ export type SettingsSection = | "connections" | "language" | "appearance" + | "zoom" | "menubar" | "autostart" | "listen" @@ -54,6 +55,7 @@ export const SETTINGS_SECTIONS: readonly SectionDef[] = [ { id: "connections", title: pick(connText, (t) => t.title) }, { id: "language", title: pick(generalText, (t) => t.language) }, { id: "appearance", title: pick(generalText, (t) => t.appearance) }, + { id: "zoom", title: pick(generalText, (t) => t.zoom) }, // 只有 macOS 有菜单栏这一行(别处的通知区放不下两行数字) ...(isMac ? [{ id: "menubar" as const, title: pick(generalText, (t) => t.menubar) }] : []), { id: "autostart", title: pick(generalText, (t) => t.autostart) }, diff --git a/src/palette/zoom.test.ts b/src/palette/zoom.test.ts new file mode 100644 index 00000000..f7e599b1 --- /dev/null +++ b/src/palette/zoom.test.ts @@ -0,0 +1,25 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { ZOOM_STEPS } from "@/settings/api"; +import { zoomStep } from "./keys"; + +describe("界面大小", () => { + it("= 和 + 放大,- 缩小,0 回到 100%,别的键不算", () => { + expect(zoomStep("=")).toBe("in"); + expect(zoomStep("+")).toBe("in"); + expect(zoomStep("-")).toBe("out"); + expect(zoomStep("0")).toBe("reset"); + expect(zoomStep("1")).toBeNull(); + expect(zoomStep("_")).toBeNull(); + expect(zoomStep("k")).toBeNull(); + }); + + /** 设置页的几档和 Rust 认的几档是同一组:多一档的话,选了存不进去 */ + it("设置页的几档和 src-tauri/src/zoom.rs 的 STEPS 一致", () => { + const src = readFileSync("src-tauri/src/zoom.rs", "utf8"); + const m = /pub const STEPS: \[u16; \d+\] = \[([^\]]+)\]/.exec(src); + expect(m).not.toBeNull(); + const rust = m![1]!.split(",").map((s) => Number(s.trim())); + expect([...ZOOM_STEPS]).toEqual(rust); + }); +}); diff --git a/src/settings/GeneralSection.i18n.ts b/src/settings/GeneralSection.i18n.ts index e3a76edb..c8624679 100644 --- a/src/settings/GeneralSection.i18n.ts +++ b/src/settings/GeneralSection.i18n.ts @@ -1,7 +1,7 @@ import { messages } from "@/i18n"; import { isLinux, isWindows } from "@/platform"; -/** 设置 → 通用:语言、外观、菜单栏、开机启动、提醒 */ +/** 设置 → 通用:语言、外观、界面大小、菜单栏、开机启动、提醒 */ export const generalText = messages( { title: "通用", @@ -17,6 +17,11 @@ export const generalText = messages( /** 选了跟随系统时,把系统现在是哪一档写出来 */ systemNow: (name: string) => `系统当前为${name}。`, + zoom: "界面大小", + /** 三个快捷键,按平台写好的键名(⌘= / Ctrl+=) */ + zoomKeys: (bigger: string, smaller: string, reset: string) => + `按 ${bigger} 放大、${smaller} 缩小,${reset} 恢复为 100%。`, + menubar: "菜单栏", menubarWhat: "数值为今日的 token 用量与费用。", menubarFull: "标识和数值", @@ -52,6 +57,10 @@ export const generalText = messages( dark: "Dark", systemNow: (name: string) => `The system is currently set to ${name.toLowerCase()}.`, + zoom: "Interface size", + zoomKeys: (bigger: string, smaller: string, reset: string) => + `Press ${bigger} to enlarge, ${smaller} to reduce, or ${reset} to reset to 100%.`, + menubar: "Menu bar", menubarWhat: "The numbers are today's token usage and cost.", menubarFull: "Icon and numbers", diff --git a/src/settings/GeneralSection.tsx b/src/settings/GeneralSection.tsx index c5fcaa50..f27565c1 100644 --- a/src/settings/GeneralSection.tsx +++ b/src/settings/GeneralSection.tsx @@ -12,14 +12,17 @@ import { invalidate, useResource } from "@/lib/resource"; import { useTauriEvent } from "@/lib/tauriEvent"; import { LANG_NAMES, setLang, useText, type Lang } from "@/i18n"; import { isMac } from "@/platform"; +import { COMBOS, comboText } from "@/palette/keys"; import { APP_KEYS, settingsApi, + ZOOM_STEPS, type LanguageView, type MenubarStyle, type NoticeMode, type ThemeChoice, type ThemeView, + type UiZoom, } from "./api"; import { generalText } from "./GeneralSection.i18n"; import { Loaded, SettingsCard, SettingsGroup, SettingsRow, useWrite } from "./kit"; @@ -39,6 +42,7 @@ export function GeneralSection() { + {/* **只有 macOS 有这几档。**别处的通知区只认一张正方形图标(100% DPI 下 16×16),塞不下两行数字 —— 那几行在右键菜单里给;「不显示」也不给,见 @@ -153,6 +157,41 @@ function AppearanceRow() { ); } +/** + * 界面大小:整个界面等比缩放(webview 的缩放,见 `src-tauri/src/zoom.rs`),存在这台 + * 电脑上、不同步。快捷键(`shell/Workspace.tsx`)和命令面板换了档,Rust 发 + * `ui-zoom-changed`,这一行跟着动。 + */ +function ZoomRow() { + const t = useText(generalText); + const r = useResource(APP_KEYS.zoom, settingsApi.zoom); + const [, set] = useWrite(r, settingsApi.setZoom); + const { mutate } = r; + useTauriEvent("ui-zoom-changed", (e) => void mutate(e.payload)); + return ( + + {(zoom) => ( + + label={t.zoom} + value={String(zoom)} + options={ZOOM_STEPS.map((s) => ({ id: String(s), label: `${s}%` }))} + onChange={(s) => { + const next = ZOOM_STEPS.find((z) => String(z) === s); + if (next !== undefined) void set(next); + }} + /> + )} + + } + /> + ); +} + /** * 菜单栏:标识和数值 / 仅标识 / 仅数值 / 不显示。数值是今日的 token 和费用。 * diff --git a/src/settings/api.ts b/src/settings/api.ts index 395e8d42..8fcdde42 100644 --- a/src/settings/api.ts +++ b/src/settings/api.ts @@ -28,6 +28,12 @@ export interface LanguageView { system: Lang; } +/** 界面大小的几档,百分比(和 `src-tauri/src/zoom.rs` 的 `STEPS` 一致) */ +export const ZOOM_STEPS = [90, 100, 110, 125, 150] as const; +export type UiZoom = (typeof ZOOM_STEPS)[number]; +/** 快捷键:放大一档、缩小一档、回到 100% */ +export type ZoomStep = "in" | "out" | "reset"; + /** 菜单栏:标识和数值 / 仅标识 / 仅数值 / 不显示(只有 macOS 有) */ export type MenubarStyle = "full" | "icon" | "numbers" | "hidden"; @@ -48,6 +54,10 @@ export interface AppInfo { export const settingsApi = { theme: () => invoke("app_theme"), setTheme: (setting: Theme | null) => invoke("set_theme", { setting }), + zoom: () => invoke("ui_zoom"), + setZoom: (zoom: UiZoom) => invoke("set_ui_zoom", { zoom }), + /** 新的一档由 Rust 那边算(按住快捷键时一连串的按键不会读到同一档) */ + stepZoom: (step: ZoomStep) => invoke("step_ui_zoom", { step }), language: () => invoke("app_language"), setLanguage: (setting: Lang | null) => invoke("set_language", { setting }), menubar: () => invoke("menubar_style"), @@ -76,6 +86,7 @@ export const settingsApi = { */ export const APP_KEYS = { theme: "app:theme", + zoom: "app:zoom", language: "app:language", menubar: "app:menubar", noticeMode: "app:notice-mode", diff --git a/src/settings/zoom.ts b/src/settings/zoom.ts new file mode 100644 index 00000000..dc286eff --- /dev/null +++ b/src/settings/zoom.ts @@ -0,0 +1,12 @@ +import { notify } from "@/ui/notify"; +import { settingsApi, type ZoomStep } from "./api"; + +/** + * 快捷键和命令面板换界面大小:放大一档、缩小一档、回到 100%。 + * + * 缩放由 Rust 那边做(`src-tauri/src/zoom.rs`),存进 `app.json`;设置页那一行听 + * `ui-zoom-changed` 跟着动。存不进去就报错,界面大小不变 + */ +export function stepZoom(step: ZoomStep): void { + settingsApi.stepZoom(step).catch((e: unknown) => notify.error(e)); +} diff --git a/src/shell/Workspace.tsx b/src/shell/Workspace.tsx index fe47bba2..92555bb0 100644 --- a/src/shell/Workspace.tsx +++ b/src/shell/Workspace.tsx @@ -23,7 +23,8 @@ import { useRepair } from "@/repair"; import { DropImport } from "@/backup/DropImport"; import { NavContext, SURFACES, type Nav, type NavDelivery, type NavParams, type Surface } from "@/nav"; import { paletteText } from "@/palette/palette.i18n"; -import { COMBOS, DIGIT_PAGES, Keys, isTyping, modalOpen } from "@/palette/keys"; +import { COMBOS, DIGIT_PAGES, Keys, isTyping, modalOpen, zoomStep } from "@/palette/keys"; +import { stepZoom } from "@/settings/zoom"; import { SidebarProvider, SidebarTrigger } from "@/ui/sidebar"; import { Banners } from "./Banners"; import { CONFIG_PAGES, describeCore, surfaceOf } from "./describe"; @@ -259,6 +260,8 @@ export default memo(function Workspace({ * * · ⌘K 命令面板 · ⌘1…⌘9 按源列表的顺序换页(设置之外的前九页,`DIGIT_PAGES`) * · ⌘F 流量搜索 · ⌘, 设置 · ⌘R 刷新 + * · ⌘= 放大界面(`+` 也认)· ⌘- 缩小 · ⌘0 回到 100%(`zoomStep`;Tauri 自带的缩放快捷键 + * 没开,见 `src-tauri/src/zoom.rs`) * · `?` 快捷键一览(在打字时不接管) * · ⌘⌥S 收起/展开源列表(访达、邮件、备忘录都是这个键;判 `code` 不判 `key`: * ⌥ 会把 s 变成 ß)。**只在 macOS 上有**:Windows 上 Ctrl+Alt 常是 AltGr, @@ -285,6 +288,13 @@ export default memo(function Workspace({ return; } if (!isMod(e) || e.altKey) return; + // 界面大小(⌘= / ⌘- / ⌘0)。开着对话框、正在输入时照样换:不换页,什么都不会丢 + const zoom = zoomStep(e.key); + if (zoom) { + e.preventDefault(); + stepZoom(zoom); + return; + } const k = e.key.toLowerCase(); const busy = modalOpen(); const go = (s: S, params?: NavParams[S]) => {