From f6fcd0fb0df1fef7c803f44dd76a9bff57e3f651 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:08:30 +0800 Subject: [PATCH] Menu bar: a fourth style, Hidden, that takes the item off the menu bar Settings > General > Menu bar gets a fourth choice next to icon and numbers / icon only / numbers only. It takes effect at once: the NSStatusItem is removed with removeStatusItem and a new one is created, with the same menu, when another style is picked. setVisible(false) is not used because AppKit persists it under the autosave name, so the item would start hidden on the next launch and stay hidden in an older version that does not know this style. While hidden, the menu bar loop asks core for nothing until the style changes. With the item hidden and the window closed the app stays an accessory app (no Dock icon): opening it again from Finder, Spotlight or `open` reaches the running instance as RunEvent::Reopen, which already shows the main window. Choosing Hidden swaps the row's note for how to get back to the window, the launch-at-login note and the first-autostart notice say it runs in the background. macOS only, like the other styles: elsewhere the stored value still draws the tray icon. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/lib.rs | 4 +++ src-tauri/src/menubar/macos.rs | 49 +++++++++++++++++++++----- src-tauri/src/menubar/macos/preview.rs | 7 +++- src-tauri/src/menubar/mod.rs | 27 ++++++++++++-- src-tauri/src/menubar/model.rs | 5 ++- src-tauri/src/prefs.rs | 13 ++++++- src-tauri/src/settings.rs | 31 ++++++++++------ src/settings/GeneralSection.i18n.ts | 8 +++++ src/settings/GeneralSection.tsx | 22 ++++++++---- src/settings/api.ts | 4 +-- 10 files changed, 137 insertions(+), 33 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0286be25..1b6da092 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -601,6 +601,10 @@ pub fn run() { .run(|app, event| { // 点 Dock 图标 / 从 ⌘Tab 回来时把窗口叫回来。没有这条,一个 // 已经隐藏窗口的菜单栏应用在 Dock 上点了没反应。 + // + // **窗口关着(不在 Dock 里)时从访达、聚焦搜索、启动台再打开一次,系统发来的 + // 也是这一个**(实测:Accessory 的应用照样收到)。菜单栏设成不显示时,这就是 + // 回到窗口的路 —— 所以关窗之后照旧退回菜单栏应用,不为它留着 Dock 图标 #[cfg(target_os = "macos")] if let tauri::RunEvent::Reopen { .. } = event { let _ = show_main_window(app); diff --git a/src-tauri/src/menubar/macos.rs b/src-tauri/src/menubar/macos.rs index 8e847c3c..410f9974 100644 --- a/src-tauri/src/menubar/macos.rs +++ b/src-tauri/src/menubar/macos.rs @@ -63,7 +63,10 @@ type OnAction = Rc; type OnOpen = Rc; struct Ui { - item: Retained, + /// 菜单栏上的那一项。**设成不显示时整个拿掉**(`removeStatusItem`),换回来时再建一个、 + /// 挂上同一份菜单。不用 `setVisible(false)`:它按 autosaveName 记进系统的偏好里,下次 + /// 启动这一项先是藏着的 —— 换回不认识「不显示」的旧版本,它就再也出不来了 + item: Option>, menu: Retained, target: Retained, /// 现在菜单里每一行的样子。样子没变就就地改,变了才重建 @@ -86,7 +89,8 @@ thread_local! { static OPEN: Cell = const { Cell::new(false) }; } -/// 在菜单栏上挂出来。只调一次 +/// 备好菜单和回调。只调一次。**菜单栏上的那一项在第一次 [`apply`] 时才挂出来**: +/// 设成不显示的,启动时一帧都不出现 pub fn install( mtm: MainThreadMarker, on_action: impl Fn(Action) + 'static, @@ -95,18 +99,14 @@ pub fn install( ON_ACTION.with(|a| *a.borrow_mut() = Some(Rc::new(on_action))); ON_OPEN.with(|o| *o.borrow_mut() = Some(Rc::new(on_open))); - let item = NSStatusBar::systemStatusBar().statusItemWithLength(NSVariableStatusItemLength); - // 用户按住 ⌘ 拖过的位置,下次启动还在 - item.setAutosaveName(Some(&NSString::from_str("ThinkWatchLite"))); let target = Target::new(mtm); let menu = NSMenu::new(mtm); menu.setMinimumWidth(MENU_WIDTH); menu.setAutoenablesItems(false); menu.setDelegate(Some(ProtocolObject::from_ref(&*target))); - item.setMenu(Some(&menu)); UI.with(|ui| { *ui.borrow_mut() = Some(Ui { - item, + item: None, menu, target, shapes: Vec::new(), @@ -119,13 +119,18 @@ pub fn install( } /// 照着模型画一遍。**没变的不动**:菜单栏那一块文字和颜色都没变就不重画,菜单行的 -/// 样子没变就只改文字 +/// 样子没变就只改文字。设成不显示的,拿掉菜单栏上的那一项([`hide`]) pub fn apply(mtm: MainThreadMarker, bar: &Bar, rows: &[Row]) { + if bar.style == Style::Hidden { + hide(mtm); + return; + } UI.with(|cell| { let mut guard = cell.borrow_mut(); let Some(ui) = guard.as_mut() else { return }; + let item = ui.item.get_or_insert_with(|| status_item(&ui.menu)); if ui.bar.as_ref() != Some(bar) { - draw_bar(mtm, &ui.item, bar); + draw_bar(mtm, item, bar); ui.bar = Some(bar.clone()); } let shapes: Vec = rows.iter().map(Row::shape).collect(); @@ -139,6 +144,32 @@ pub fn apply(mtm: MainThreadMarker, bar: &Bar, rows: &[Row]) { }); } +/// 拿掉菜单栏上的那一项(设置里选了不显示)。**菜单留着**:换回来时挂到新建的那一项上, +/// 菜单行照旧就地改。已经拿掉了就什么都不做 +pub fn hide(_mtm: MainThreadMarker) { + UI.with(|cell| { + let mut guard = cell.borrow_mut(); + let Some(ui) = guard.as_mut() else { return }; + let Some(item) = ui.item.take() else { return }; + // 菜单开着的话先收起来,不留一份挂在空处的菜单 + if OPEN.with(Cell::get) { + ui.menu.cancelTrackingWithoutAnimation(); + } + NSStatusBar::systemStatusBar().removeStatusItem(&item); + // 再挂出来的是新的一项,那一块要整个重画 + ui.bar = None; + }); +} + +/// 挂一项到菜单栏上,点开是 `menu` +fn status_item(menu: &NSMenu) -> Retained { + let item = NSStatusBar::systemStatusBar().statusItemWithLength(NSVariableStatusItemLength); + // 用户按住 ⌘ 拖过的位置,下次启动、下次挂出来都还在 + item.setAutosaveName(Some(&NSString::from_str("ThinkWatchLite"))); + item.setMenu(Some(menu)); + item +} + // ------------------------------------------------------------------ 菜单栏那一块 /// 菜单栏那一块的高度。**按系统给的厚度画**,不写死 diff --git a/src-tauri/src/menubar/macos/preview.rs b/src-tauri/src/menubar/macos/preview.rs index dd6b286b..e2ce5053 100644 --- a/src-tauri/src/menubar/macos/preview.rs +++ b/src-tauri/src/menubar/macos/preview.rs @@ -29,7 +29,12 @@ pub fn is_open() -> bool { /// CFRunLoop 为了不让主队列重入,那期间不再处理主队列 —— 投递过来的更新全都要等 /// 菜单关上。用户真点菜单栏时跟踪循环是从事件分发开始的,没有这个问题 pub fn open_menu_later(mtm: MainThreadMarker, delay: f64) { - let button = UI.with(|ui| ui.borrow().as_ref().and_then(|ui| ui.item.button(mtm))); + let button = UI.with(|ui| { + ui.borrow() + .as_ref() + .and_then(|ui| ui.item.as_ref()) + .and_then(|item| item.button(mtm)) + }); if let Some(button) = button { let none: Option<&AnyObject> = None; let _: () = unsafe { diff --git a/src-tauri/src/menubar/mod.rs b/src-tauri/src/menubar/mod.rs index 9eaf51e2..faed352d 100644 --- a/src-tauri/src/menubar/mod.rs +++ b/src-tauri/src/menubar/mod.rs @@ -52,15 +52,18 @@ pub fn style() -> Style { match STYLE.load(Ordering::Relaxed) { 1 => Style::Icon, 2 => Style::Numbers, + 3 => Style::Hidden, _ => Style::Full, } } /// 实际画出来的那一档。 /// -/// **macOS 才有三档可选。**别处的通知区只认一张正方形图标(100% DPI 下 +/// **macOS 才有这几档可选。**别处的通知区只认一张正方形图标(100% DPI 下 /// 16×16),两行数字塞不进去 —— 那几行在右键菜单里给,所以无论存的是什么, -/// 画出来的都是图标。 +/// 画出来的都是图标。「不显示」也只有 macOS 有:那边藏掉托盘图标,窗口关着时 +/// 就只剩再启动一次这一条路,而这条路在各个桌面上走不走得通不一样(见 +/// `window::relaunch_token`)。 /// /// 和 [`style`] 分开,因为它们回答的是两个问题:设置页问「存了什么」, /// 渲染问「画得出什么」。把它们合成一个,那条「存进去什么就读回什么」的 @@ -82,6 +85,7 @@ pub fn set_style(style: Style) { Style::Full => 0, Style::Icon => 1, Style::Numbers => 2, + Style::Hidden => 3, }, Ordering::Relaxed, ); @@ -144,6 +148,14 @@ async fn run(app: tauri::AppHandle) { let Some(state) = app.try_state::() else { return; }; + // 设成不显示:菜单栏上没有这一项,也就不问 core 要数。换成别的样式时 + // `menubar_now` 叫醒这里,收一次、画出来 + if drawn_style() == Style::Hidden { + #[cfg(target_os = "macos")] + macos::on_main(macos::hide); + now.notified().await; + continue; + } let mut snap = collect(&app, &state, &mut credits).await; present(&snap); let day_end = snap @@ -942,12 +954,21 @@ mod tests { assert_eq!(why(CmdError::plain("剪贴板不可用")), "剪贴板不可用"); } + /// 存进去什么就读回什么;**画出来的「不显示」只有 macOS 有**,别处照样是图标 —— + /// 那边藏掉托盘图标,窗口关着时就够不着这个应用了。(一条测试:样式是全局的, + /// 拆成两条会在并行跑时互相改掉) #[test] fn the_style_survives_a_round_trip() { - for s in [Style::Full, Style::Icon, Style::Numbers] { + for s in [Style::Full, Style::Icon, Style::Numbers, Style::Hidden] { set_style(s); assert_eq!(style(), s); } + set_style(Style::Hidden); + assert_eq!( + drawn_style() == Style::Hidden, + cfg!(target_os = "macos"), + "只有 macOS 能不显示" + ); set_style(Style::Full); } diff --git a/src-tauri/src/menubar/model.rs b/src-tauri/src/menubar/model.rs index a6e3004c..931a8dc3 100644 --- a/src-tauri/src/menubar/model.rs +++ b/src-tauri/src/menubar/model.rs @@ -4,7 +4,7 @@ //! 菜单;什么时候出现哪一节、数字怎么写、什么时候变色,都在这里定、在这里测。 //! 画的那一层只管照着画。 -/// 菜单栏上显示什么。设置里的三档,出厂是标识和数值 +/// 菜单栏上显示什么。设置里的四档,出厂是标识和数值 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "lowercase")] pub enum Style { @@ -12,6 +12,9 @@ pub enum Style { Full, Icon, Numbers, + /// 不显示:菜单栏上没有这一项。窗口关着时再打开一次应用,主窗口就回来了 + /// (`RunEvent::Reopen`,见 `lib.rs`) + Hidden, } /// 网关此刻的状态。守护进程的几种状态按「用户该知道什么」归成这几类 diff --git a/src-tauri/src/prefs.rs b/src-tauri/src/prefs.rs index ec5b72f4..71e4077d 100644 --- a/src-tauri/src/prefs.rs +++ b/src-tauri/src/prefs.rs @@ -31,7 +31,7 @@ pub struct Prefs { pub theme: Option, /// 提醒:系统通知 / 仅在应用内 / 关闭。**只有这一个,不分类。** pub notices: Mode, - /// 菜单栏上显示什么:标识和数值(出厂)/ 仅标识 / 仅数值。 + /// 菜单栏上显示什么:标识和数值(出厂)/ 仅标识 / 仅数值 / 不显示。 /// /// **没写就是出厂那一档**,而不是整个设置文件读不出来、连语言和外观一起被冲回 /// 出厂值 @@ -250,6 +250,17 @@ mod tests { assert_eq!(load(&dir), want); } + /// 菜单栏不显示,存成 `"hidden"`(界面那边的 `MenubarStyle` 是同一个写法),读回来还是它 + #[test] + fn a_hidden_menubar_is_written_as_hidden_and_read_back() { + let (_tmp, dir) = tmp(); + update(&dir, |p| p.menubar = crate::menubar::Style::Hidden).unwrap(); + let raw: serde_json::Value = + serde_json::from_slice(&std::fs::read(prefs_path(&dir)).unwrap()).unwrap(); + assert_eq!(raw["menubar"], "hidden"); + assert_eq!(load(&dir).menubar, crate::menubar::Style::Hidden); + } + /// 旧的设置文件里没有菜单栏这一项:**别的设置照旧**,菜单栏按出厂那一档 #[test] fn a_file_without_the_menubar_setting_keeps_everything_else() { diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 8ff72941..4813aaf7 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -265,17 +265,28 @@ pub(crate) fn maybe_notify_first_autostart(app: &tauri::AppHandle) { "It started at login. When the window is closed, the app keeps running in the system tray." ), ); + // 菜单栏设成不显示时,那里没有它:说怎么回到窗口 #[cfg(target_os = "macos")] - let (title, body) = ( - tr!( - "ThinkWatch 已在菜单栏运行", - "ThinkWatch Is Running in the Menu Bar" - ), - tr!( - "开机时已自动启动。窗口关闭后,应用仍在菜单栏中运行。", - "It started at login. When the window is closed, the app keeps running in the menu bar." - ), - ); + let (title, body) = if menubar::style() == menubar::Style::Hidden { + ( + tr!("ThinkWatch 已在后台运行", "ThinkWatch Is Running"), + tr!( + "开机时已自动启动。再次打开 ThinkWatch Lite 即可显示主窗口。", + "It started at login. Open ThinkWatch Lite again to show the main window." + ), + ) + } else { + ( + tr!( + "ThinkWatch 已在菜单栏运行", + "ThinkWatch Is Running in the Menu Bar" + ), + tr!( + "开机时已自动启动。窗口关闭后,应用仍在菜单栏中运行。", + "It started at login. When the window is closed, the app keeps running in the menu bar." + ), + ) + }; n.announce("autostart", title, body); } tracing::info!("首次开机自启,已提示一次"); diff --git a/src/settings/GeneralSection.i18n.ts b/src/settings/GeneralSection.i18n.ts index e4ec8ac2..e3a76edb 100644 --- a/src/settings/GeneralSection.i18n.ts +++ b/src/settings/GeneralSection.i18n.ts @@ -22,6 +22,9 @@ export const generalText = messages( menubarFull: "标识和数值", menubarIcon: "仅标识", menubarNumbers: "仅数值", + menubarHidden: "不显示", + /** 选了不显示时替换上面那句:窗口关着时怎么回来 */ + menubarHiddenWhat: "从访达或聚焦搜索再次打开 ThinkWatch Lite,即可显示主窗口。", autostart: "开机启动", autostartWhat: isWindows @@ -29,6 +32,8 @@ export const generalText = messages( : isLinux ? "登录后仅在系统托盘显示图标,不打开窗口。" : "登录后仅在菜单栏显示图标,不打开窗口。", + /** 菜单栏设成不显示时(只有 macOS):登录后什么都不出现 */ + autostartHiddenWhat: "登录后在后台运行,不打开窗口。", notices: "提醒", noticeSystem: "系统通知", @@ -52,6 +57,8 @@ export const generalText = messages( menubarFull: "Icon and numbers", menubarIcon: "Icon only", menubarNumbers: "Numbers only", + menubarHidden: "Hidden", + menubarHiddenWhat: "To show the main window, open ThinkWatch Lite again from Finder or Spotlight.", autostart: "Launch at login", autostartWhat: isWindows @@ -59,6 +66,7 @@ export const generalText = messages( : isLinux ? "At login, only the icon appears in the system tray; no window opens." : "At login, only the icon appears in the menu bar; no window opens.", + autostartHiddenWhat: "At login, it runs in the background; no window opens.", notices: "Notices", noticeSystem: "System notification", diff --git a/src/settings/GeneralSection.tsx b/src/settings/GeneralSection.tsx index 3b272118..e339be9c 100644 --- a/src/settings/GeneralSection.tsx +++ b/src/settings/GeneralSection.tsx @@ -37,8 +37,9 @@ export function GeneralSection() { {/* - **只有 macOS 有这三档。**别处的通知区只认一张正方形图标(100% DPI 下 - 16×16),塞不下两行数字 —— 那几行在右键菜单里给 + **只有 macOS 有这几档。**别处的通知区只认一张正方形图标(100% DPI 下 + 16×16),塞不下两行数字 —— 那几行在右键菜单里给;「不显示」也不给,见 + `src-tauri/src/menubar/mod.rs` 的 `drawn_style` */} {isMac && } @@ -149,7 +150,13 @@ function AppearanceRow() { ); } -/** 菜单栏:标识和数值 / 仅标识 / 仅数值。数值是今日的 token 和费用 */ +/** + * 菜单栏:标识和数值 / 仅标识 / 仅数值 / 不显示。数值是今日的 token 和费用。 + * + * **选了不显示,说明那一句换成怎么回到窗口**:窗口关着时应用不在 Dock 里,菜单栏上也 + * 没有它,再打开一次应用是唯一的入口(Rust 那边接的是 `RunEvent::Reopen`)。一直写在 + * 这一行上,而不是弹一次就没了 —— 要找的时候还在这里 + */ function MenubarRow() { const t = useText(generalText); const r = useResource(APP_KEYS.menubar, settingsApi.menubar); @@ -160,9 +167,9 @@ function MenubarRow() { + {(style) => ( label={t.menubar} @@ -171,6 +178,7 @@ function MenubarRow() { { id: "full", label: t.menubarFull }, { id: "icon", label: t.menubarIcon }, { id: "numbers", label: t.menubarNumbers }, + { id: "hidden", label: t.menubarHidden }, ]} onChange={(s) => void set(s)} /> @@ -190,12 +198,14 @@ function AutostartRow() { const t = useText(generalText); const r = useResource(APP_KEYS.autostart, settingsApi.autostart); const [pending, set] = useWrite(r, settingsApi.setAutostart); + // 菜单栏设成不显示时,登录后菜单栏上也没有它(和上面那一行同一份数据,不多问一次) + const menubar = useResource(isMac ? APP_KEYS.menubar : null, settingsApi.menubar); return ( {(on) => ( diff --git a/src/settings/api.ts b/src/settings/api.ts index a88e2cdf..395e8d42 100644 --- a/src/settings/api.ts +++ b/src/settings/api.ts @@ -28,8 +28,8 @@ export interface LanguageView { system: Lang; } -/** 菜单栏:标识和数值 / 仅标识 / 仅数值(只有 macOS 有) */ -export type MenubarStyle = "full" | "icon" | "numbers"; +/** 菜单栏:标识和数值 / 仅标识 / 仅数值 / 不显示(只有 macOS 有) */ +export type MenubarStyle = "full" | "icon" | "numbers" | "hidden"; /** 提醒:系统通知 / 仅在应用内 / 关闭 */ export type NoticeMode = "system" | "app" | "off";