Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion crates/openlogi-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 {
Expand Down
16 changes: 12 additions & 4 deletions crates/openlogi-gui/locales/app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: 窗口
Expand All @@ -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.":
Expand Down Expand Up @@ -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: 语言
Expand Down
13 changes: 13 additions & 0 deletions crates/openlogi-gui/src/app_menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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));
Expand All @@ -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),
]);

Expand Down Expand Up @@ -100,6 +111,8 @@ fn menus() -> Vec<Menu> {
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),
],
Expand Down
34 changes: 25 additions & 9 deletions crates/openlogi-gui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,16 @@ fn main() -> Result<()> {
let (tray_tx, mut tray_rx) =
tokio::sync::mpsc::unbounded_channel::<platform::tray::TrayEvent>();

// 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;

Expand Down Expand Up @@ -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::<AppState>()
.is_some_and(|s| s.app_settings().show_in_menu_bar);
Comment on lines +181 to +183
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

try_global::<AppState>() here means: if AppState ever isn't installed when a window closes, tray_on silently becomes false and the app stays in Regular — the opposite of the default behavior. AppState is set before any window opens in this run, so it's safe today, but cx.global::<AppState>() would panic loudly on a regression instead of degrading silently. Consider switching unless you have a specific reason to expect it can be absent.

if tray_on && cx.windows().is_empty() {
platform::tray::hide_from_dock();
}
})
Expand Down
19 changes: 18 additions & 1 deletion crates/openlogi-gui/src/platform/tray.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -58,6 +58,10 @@ mod macos {
/// `usize` (a raw `id` is not `Sync`); only ever touched on the main thread.
static DEVICE_ITEM: OnceLock<usize> = 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<usize> = OnceLock::new();

struct MenuRefs {
open: usize,
quit: usize,
Expand All @@ -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);
Expand Down Expand Up @@ -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];
Expand Down
25 changes: 25 additions & 0 deletions crates/openlogi-gui/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
Comment on lines +341 to +347
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant #[cfg(target_os = "macos")] — the enclosing fn (line 332) is already macOS-gated, so this inner block-level cfg can't fire on its own. Drop the inner #[cfg(target_os = "macos")] and the surrounding { } and inline the two calls.

}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth a short comment on the asymmetry: disabling restores show_in_dock so the user isn't stranded, but enabling deliberately does not immediately switch to accessory — the on_window_closed observer handles that the next time the last window closes. Without the note, a reader expects a hide_from_dock call on the enabled branch and wonders why it's missing.


/// 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.
Expand Down
90 changes: 58 additions & 32 deletions crates/openlogi-gui/src/windows/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<AppState, _>(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::<AppState, _>(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::<AppState>()
.is_some_and(|s| s.app_settings().show_in_menu_bar);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this re-reads AppState after the earlier try_global at line 55. One read covering all four fields (launch, updates, language, show_in_menu_bar) would be a touch tidier and avoids a second try_global per render. Not a correctness issue.

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::<AppState, _>(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)
Expand All @@ -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::<AppState, _>(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::<AppState, _>(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")))
Expand Down
Loading
Loading