diff --git a/crates/openlogi-core/src/config.rs b/crates/openlogi-core/src/config.rs index 72c50aa..640ae18 100644 --- a/crates/openlogi-core/src/config.rs +++ b/crates/openlogi-core/src/config.rs @@ -55,7 +55,11 @@ impl Default for Config { /// /// All fields are `#[serde(default)]` so adding a new one is backward /// compatible — old config files just keep the default for the new field. -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[allow( + clippy::struct_excessive_bools, + reason = "independent on/off user preferences, not a state machine" +)] pub struct AppSettings { /// When true, a macOS `LaunchAgent` plist at /// `~/Library/LaunchAgents/org.openlogi.openlogi.plist` is installed @@ -77,6 +81,12 @@ pub struct AppSettings { /// user opt in on first launch. #[serde(default)] pub update_prompt_seen: bool, + /// Whether OpenLogi shows a macOS menu-bar (status item) icon. `true` + /// (default) → it lives in the menu bar, dropping the Dock icon while no + /// window is open; `false` → it stays an ordinary Dock app with no status + /// item. macOS-only; ignored on other platforms. + #[serde(default = "default_true")] + pub show_in_menu_bar: bool, /// UI language as a BCP-47-ish locale code matching the GUI's bundled /// locales (`"en"`, `"zh-CN"`, `"zh-HK"`). `None` means "follow the /// system locale", which the GUI resolves at startup. Stored here so a @@ -94,6 +104,24 @@ impl AppSettings { } } +impl Default for AppSettings { + fn default() -> Self { + Self { + launch_at_login: false, + check_for_updates: false, + update_prompt_seen: false, + show_in_menu_bar: true, + language: None, + } + } +} + +/// serde default for [`AppSettings::show_in_menu_bar`]: `true`, so the menu-bar +/// icon is on out of the box and configs predating the field keep that behavior. +fn default_true() -> bool { + true +} + /// Settings scoped to a single physical device (keyed by HID++ model+ext). #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct DeviceConfig { diff --git a/crates/openlogi-gui/locales/app.yml b/crates/openlogi-gui/locales/app.yml index 997f5f3..9837047 100644 --- a/crates/openlogi-gui/locales/app.yml +++ b/crates/openlogi-gui/locales/app.yml @@ -92,10 +92,6 @@ _version: 2 ja: すべてを表示 zh-CN: 全部显示 zh-HK: 全部顯示 -"Quit OpenLogi": - ja: OpenLogi を終了 - zh-CN: 退出 OpenLogi - zh-HK: 結束 OpenLogi "Window": ja: ウインドウ zh-CN: 窗口 @@ -108,6 +104,10 @@ _version: 2 ja: ズーム zh-CN: 缩放 zh-HK: 縮放 +"Close Window": + ja: ウインドウを閉じる + zh-CN: 关闭窗口 + zh-HK: 關閉視窗 # ── About window (about.rs) ───────────────────────────────────────────────── "Open-source Logitech mouse configuration — DPI, SmartShift, button bindings, and gestures.": @@ -136,6 +136,14 @@ _version: 2 ja: 起動ごとに新しいバージョンを一度だけ確認します(確認のみ — 自動ダウンロードはしません)。 zh-CN: 每次启动检查一次新版本(仅查询,不自动下载)。 zh-HK: 每次啟動檢查一次新版本(僅查詢,不自動下載)。 +"Show in menu bar": + ja: メニューバーに表示 + zh-CN: 显示在菜单栏 + zh-HK: 顯示在選單列 +"Keep OpenLogi's icon in the menu bar. When off, it stays in the Dock instead.": + ja: OpenLogi のアイコンをメニューバーに表示します。オフにすると Dock に残ります。 + zh-CN: 在菜单栏保留 OpenLogi 图标;关闭后改为停留在程序坞中。 + zh-HK: 在選單列保留 OpenLogi 圖示;關閉後改為停留在 Dock 中。 "Language": ja: 言語 zh-CN: 语言 diff --git a/crates/openlogi-gui/src/app_menu.rs b/crates/openlogi-gui/src/app_menu.rs index 0717c2d..669c354 100644 --- a/crates/openlogi-gui/src/app_menu.rs +++ b/crates/openlogi-gui/src/app_menu.rs @@ -14,6 +14,8 @@ use gpui::{App, KeyBinding, Menu, MenuItem, actions}; actions!( openlogi, [ + /// Close the focused window. + CloseWindow, /// Hide the OpenLogi window (macOS). Hide, /// Hide every other application (macOS). @@ -44,6 +46,14 @@ pub fn install(cx: &mut App) { cx.on_action(|_: &ShowAll, cx| cx.unhide_other_apps()); } cx.on_action(|_: &Quit, cx| cx.quit()); + // App-level so it closes whichever window is focused. Settings / About / + // Add Device each have their own view root, so a view-level handler (like + // Minimize / Zoom) would only fire for the main window. + cx.on_action(|_: &CloseWindow, cx| { + if let Some(handle) = cx.active_window() { + let _ = handle.update(cx, |_, window, _| window.remove_window()); + } + }); cx.on_action(|_: &OpenSettings, cx| crate::windows::settings::open(cx)); cx.on_action(|_: &OpenAbout, cx| crate::windows::about::open(cx)); cx.on_action(|_: &OpenAddDevice, cx| crate::windows::add_device::open(cx)); @@ -55,6 +65,7 @@ pub fn install(cx: &mut App) { #[cfg(target_os = "macos")] KeyBinding::new("cmd-alt-h", HideOthers, None), KeyBinding::new("cmd-m", Minimize, None), + KeyBinding::new("cmd-w", CloseWindow, None), KeyBinding::new("cmd-,", OpenSettings, None), ]); @@ -100,6 +111,8 @@ fn menus() -> Vec { name: tr!("Window"), disabled: false, items: vec![ + MenuItem::action(tr!("Close Window"), CloseWindow), + MenuItem::separator(), MenuItem::action(tr!("Minimize"), Minimize), MenuItem::action(tr!("Zoom"), Zoom), ], diff --git a/crates/openlogi-gui/src/main.rs b/crates/openlogi-gui/src/main.rs index b1d52c7..43965eb 100644 --- a/crates/openlogi-gui/src/main.rs +++ b/crates/openlogi-gui/src/main.rs @@ -122,10 +122,16 @@ fn main() -> Result<()> { let (tray_tx, mut tray_rx) = tokio::sync::mpsc::unbounded_channel::(); + // Whether the menu-bar (status item) icon is shown. Read once here for the + // initial install/visibility; live toggles go through `set_show_in_menu_bar`. + #[cfg(target_os = "macos")] + let show_in_menu_bar = initial_config.app_settings.show_in_menu_bar; + // macOS autostart passes `--minimized` (see launch_agent.rs) to come up in - // the tray with no window. No tray elsewhere, so the window always opens. + // the tray with no window — only meaningful when the tray is on. No tray + // elsewhere (or with it off), so the window always opens. #[cfg(target_os = "macos")] - let start_minimized = std::env::args().any(|a| a == "--minimized"); + let start_minimized = show_in_menu_bar && std::env::args().any(|a| a == "--minimized"); #[cfg(not(target_os = "macos"))] let start_minimized = false; @@ -155,17 +161,27 @@ fn main() -> Result<()> { // window-opening task below. platform::updater::install(cx, &initial_config.app_settings); - // Status-item / tray (macOS only): also hides the Dock icon. The window - // opens at launch and again on demand from the status-item menu. + // Status-item / tray (macOS only). Always created so the "Show in menu + // bar" setting can show / hide it live; its initial visibility follows + // the stored setting. The window opens at launch and on demand from its + // menu. #[cfg(target_os = "macos")] - platform::tray::install(tray_tx); + { + platform::tray::install(tray_tx); + platform::tray::set_visible(show_in_menu_bar); + } - // Keep the activation policy in step with window presence: when the last - // window closes, drop back to the tray (accessory, no Dock/menu bar); - // `open_main_window` restores Regular whenever a window opens. + // Keep the activation policy in step with window presence — but only + // while the menu-bar icon is on. Last window closed + tray on → drop to + // accessory (no Dock/menu bar); tray off → stay a regular Dock app so + // there's still a way back in. `open_main_window` restores Regular + // whenever a window opens. #[cfg(target_os = "macos")] cx.on_window_closed(|cx, _| { - if cx.windows().is_empty() { + let tray_on = cx + .try_global::() + .is_some_and(|s| s.app_settings().show_in_menu_bar); + if tray_on && cx.windows().is_empty() { platform::tray::hide_from_dock(); } }) diff --git a/crates/openlogi-gui/src/platform/tray.rs b/crates/openlogi-gui/src/platform/tray.rs index 1c7fee0..f39af8f 100644 --- a/crates/openlogi-gui/src/platform/tray.rs +++ b/crates/openlogi-gui/src/platform/tray.rs @@ -13,7 +13,7 @@ #[cfg(target_os = "macos")] pub use macos::{ TrayEvent, hide_from_dock, install, refresh_labels, request_refresh, set_device_status, - show_in_dock, + set_visible, show_in_dock, }; #[cfg(target_os = "macos")] @@ -58,6 +58,10 @@ mod macos { /// `usize` (a raw `id` is not `Sync`); only ever touched on the main thread. static DEVICE_ITEM: OnceLock = OnceLock::new(); + /// The `NSStatusItem` itself, so [`set_visible`] can show / hide the icon + /// without tearing it down. `usize` (a raw `id` isn't `Sync`); main thread. + static STATUS_ITEM: OnceLock = OnceLock::new(); + struct MenuRefs { open: usize, quit: usize, @@ -79,6 +83,7 @@ mod macos { let status_bar: id = msg_send![class!(NSStatusBar), systemStatusBar]; let status_item: id = msg_send![status_bar, statusItemWithLength: VARIABLE_LENGTH]; let _: id = msg_send![status_item, retain]; + let _ = STATUS_ITEM.set(status_item as usize); let button: id = msg_send![status_item, button]; set_button_icon(button); @@ -131,6 +136,18 @@ mod macos { set_activation_policy(ACTIVATION_POLICY_ACCESSORY); } + /// Show or hide the status-item icon without tearing it down — backs the + /// "Show in menu bar" setting. A no-op until [`install`] has run. + pub fn set_visible(visible: bool) { + let Some(item) = STATUS_ITEM.get() else { + return; + }; + let flag = if visible { YES } else { NO }; + unsafe { + let _: () = msg_send![*item as id, setVisible: flag]; + } + } + fn set_activation_policy(policy: i64) { unsafe { let app: id = msg_send![class!(NSApplication), sharedApplication]; diff --git a/crates/openlogi-gui/src/state.rs b/crates/openlogi-gui/src/state.rs index 279e445..7fea1b9 100644 --- a/crates/openlogi-gui/src/state.rs +++ b/crates/openlogi-gui/src/state.rs @@ -322,6 +322,31 @@ impl AppState { crate::platform::launch_agent::reconcile(enabled); } + /// Toggle the macOS menu-bar (status item) icon, persist it, and apply it + /// live. Turning it off hides the item *and* pins the app to Regular + /// activation, so it stays an ordinary Dock app rather than being left with + /// neither a window, a Dock icon, nor a menu-bar icon. No-op when unchanged. + /// + /// macOS-only: the toggle that calls it exists only there, so gating avoids + /// an unused-method warning on other platforms. + #[cfg(target_os = "macos")] + pub fn set_show_in_menu_bar(&mut self, enabled: bool) { + if self.config.app_settings.show_in_menu_bar == enabled { + return; + } + self.config.app_settings.show_in_menu_bar = enabled; + if let Err(e) = self.config.save_atomic() { + warn!(error = %e, "could not persist show-in-menu-bar setting"); + } + #[cfg(target_os = "macos")] + { + crate::platform::tray::set_visible(enabled); + if !enabled { + crate::platform::tray::show_in_dock(); + } + } + } + /// Toggle the opt-in update check and persist it. No immediate side /// effect beyond the next launch reading the new value. No-op when /// unchanged. diff --git a/crates/openlogi-gui/src/windows/settings.rs b/crates/openlogi-gui/src/windows/settings.rs index 9581423..4788e6f 100644 --- a/crates/openlogi-gui/src/windows/settings.rs +++ b/crates/openlogi-gui/src/windows/settings.rs @@ -58,6 +58,63 @@ impl Render for SettingsView { (a.launch_at_login, a.check_for_updates, a.language.clone()) }); + let general = GroupBox::new() + .title(group_title(IconName::Settings, tr!("General"))) + .child(setting_row( + Switch::new("launch-at-login") + .checked(launch) + .on_click(cx.listener(|_, checked: &bool, _, cx| { + let enabled = *checked; + cx.update_global::(move |s, _| { + s.set_launch_at_login(enabled); + }); + cx.notify(); + })), + tr!("Launch at login"), + tr!("Automatically start OpenLogi when you log in to macOS."), + pal, + )) + .child(setting_row( + Switch::new("check-for-updates") + .checked(updates) + .on_click(cx.listener(|_, checked: &bool, _, cx| { + let enabled = *checked; + cx.update_global::(move |s, _| { + s.set_check_for_updates(enabled); + }); + cx.notify(); + })), + tr!("Check for updates"), + tr!( + "Check once per launch for a new version (query only — no automatic download)." + ), + pal, + )); + + // The menu-bar (status item) is macOS-only, so its toggle is too. + #[cfg(target_os = "macos")] + let general = { + let in_menu_bar = cx + .try_global::() + .is_some_and(|s| s.app_settings().show_in_menu_bar); + general.child(setting_row( + Switch::new("show-in-menu-bar") + .checked(in_menu_bar) + .on_click(cx.listener(|_, checked: &bool, _, cx| { + let enabled = *checked; + cx.update_global::(move |s, _| { + s.set_show_in_menu_bar(enabled); + }); + cx.notify(); + })), + tr!("Show in menu bar"), + tr!( + "Keep OpenLogi's icon in the menu bar. When off, it stays in the Dock instead." + ), + pal, + )) + }; + v_flex() .size_full() .bg(pal.bg) @@ -70,38 +127,7 @@ impl Render for SettingsView { .font_weight(FontWeight::SEMIBOLD) .child(tr!("Settings")), ) - .child( - GroupBox::new() - .title(group_title(IconName::Settings, tr!("General"))) - .child(setting_row( - Switch::new("launch-at-login") - .checked(launch) - .on_click(cx.listener(|_, checked: &bool, _, cx| { - let enabled = *checked; - cx.update_global::(move |s, _| { - s.set_launch_at_login(enabled); - }); - cx.notify(); - })), - tr!("Launch at login"), - tr!("Automatically start OpenLogi when you log in to macOS."), - pal, - )) - .child(setting_row( - Switch::new("check-for-updates") - .checked(updates) - .on_click(cx.listener(|_, checked: &bool, _, cx| { - let enabled = *checked; - cx.update_global::(move |s, _| { - s.set_check_for_updates(enabled); - }); - cx.notify(); - })), - tr!("Check for updates"), - tr!("Check once per launch for a new version (query only — no automatic download)."), - pal, - )), - ) + .child(general) .child( GroupBox::new() .title(group_title(IconName::Globe, tr!("Language"))) diff --git a/devenv.nix b/devenv.nix index f1e5147..0193d4f 100644 --- a/devenv.nix +++ b/devenv.nix @@ -1,12 +1,23 @@ { pkgs, ... }: +let + # gpui's build compiles Metal shaders against the REAL Xcode toolchain. + # devenv's Nix apple-sdk setup hook sets DEVELOPER_DIR/SDKROOT to an SDK + # that has no `metal`, so anything compiling the GUI must force Xcode. + # Non-GUI crates still compile fine under this (the Nix clang wrapper keeps + # its own isysroot via NIX_CFLAGS), so applying it broadly is safe. + xcodeEnv = '' + export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer + export SDKROOT="$(/usr/bin/xcrun --sdk macosx --show-sdk-path)" + ''; +in { env = { GREET = "devenv"; RUSTC_WRAPPER = "sccache"; - - DEVELOPER_DIR = "/Applications/Xcode.app/Contents/Developer"; - SDKROOT = "/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk"; + # DEVELOPER_DIR/SDKROOT are intentionally NOT set here: the Nix apple-sdk + # setup hook would override them anyway. Xcode is forced in enterShell and + # in the GUI tasks via xcodeEnv (above). }; packages = with pkgs; [ @@ -32,6 +43,7 @@ enterShell = '' export PATH=$(echo "$PATH" | tr ':' '\n' | grep -v xcbuild | paste -sd: -) + ${xcodeEnv} ''; tasks = { @@ -41,12 +53,13 @@ }; "openlogi:gui" = { description = "Run the desktop app."; - exec = "cargo run -p openlogi-gui"; + exec = xcodeEnv + "cargo run -p openlogi-gui"; }; "openlogi:check" = { description = "Run fmt, clippy, and tests."; exec = '' set -e + ${xcodeEnv} cargo fmt --all -- --check cargo clippy --workspace --all-targets -- -D warnings cargo test --workspace @@ -60,6 +73,7 @@ description = "Build OpenLogi.app."; exec = '' set -e + ${xcodeEnv} if ! command -v cargo-bundle >/dev/null; then CARGO_TARGET_AARCH64_APPLE_DARWIN_LINKER=/usr/bin/cc cargo install cargo-bundle --locked fi