diff --git a/gpui/Cargo.lock b/gpui/Cargo.lock index 8b147f2..0db19b3 100644 --- a/gpui/Cargo.lock +++ b/gpui/Cargo.lock @@ -583,6 +583,9 @@ name = "bitflags" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] [[package]] name = "bitstream-io" @@ -1124,6 +1127,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.8" @@ -1349,6 +1361,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + [[package]] name = "dunce" version = "1.0.5" @@ -3337,6 +3355,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.2", + "serde", + "unicode-segmentation", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -3772,6 +3801,25 @@ dependencies = [ "pxfm", ] +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "keyboard-types", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "png 0.18.1", + "thiserror 2.0.21", + "windows-sys 0.61.2", +] + [[package]] name = "multiversion_no_op" version = "1.0.0" @@ -4435,6 +4483,7 @@ dependencies = [ "serde", "serde_json", "sysinfo 0.39.6", + "tray-icon", "winreg 0.52.0", "winres", ] @@ -6673,6 +6722,26 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tray-icon" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" +dependencies = [ + "crossbeam-channel", + "dirs 6.0.0", + "muda", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "once_cell", + "png 0.18.1", + "thiserror 2.0.21", + "windows-sys 0.61.2", +] + [[package]] name = "triomphe" version = "0.1.17" diff --git a/gpui/Cargo.toml b/gpui/Cargo.toml index 258de41..1fdedae 100644 --- a/gpui/Cargo.toml +++ b/gpui/Cargo.toml @@ -37,6 +37,7 @@ gpui-pre-apple = { path = "vendor/gpui-pre-apple" } [target.'cfg(windows)'.dependencies] winreg = "0.52" +tray-icon = { version = "0.24", default-features = false } raw-window-handle = "0.6" # GPUI's layout and text work is very slow unoptimized (a plain scrolling page diff --git a/gpui/src/bin/main.rs b/gpui/src/bin/main.rs index 49520c6..1fe35b6 100644 --- a/gpui/src/bin/main.rs +++ b/gpui/src/bin/main.rs @@ -46,10 +46,19 @@ fn main() { }) { view.update(cx, |view, cx| view.set_page(page, cx)); } - // Started by the OS at login (see autostart.rs): stay out of the way. - // There is no tray yet, so minimized is the quiet option. + // Closing the window hides it to the tray icon (where there is one). + let has_tray = openmouse_gpui::tray::install(cx, handle.into()); + // Started by the OS at login (see autostart.rs): stay out of the way, + // in the tray if there is one, else minimized. if openmouse_gpui::autostart::launched_by_os() { - let _ = cx.update_window(handle, |_, window, _| window.minimize_window()); + let _ = cx.update_window(handle, |_, window, _| { + if has_tray { + #[cfg(windows)] + openmouse_gpui::tray::hide(window); + } else { + window.minimize_window(); + } + }); // A window that never became active reports no deactivation, so // say so here or the background scans would stay at full rate. openmouse_gpui::activity::ACTIVITY.set_active(false); diff --git a/gpui/src/lib.rs b/gpui/src/lib.rs index 8b347d6..53f5d77 100644 --- a/gpui/src/lib.rs +++ b/gpui/src/lib.rs @@ -10,6 +10,7 @@ pub mod native_window; pub mod overlay; pub mod presence_text; pub mod proc; +pub mod tray; pub mod profiles; pub mod settings; pub mod theme; diff --git a/gpui/src/tray.rs b/gpui/src/tray.rs new file mode 100644 index 0000000..ba6e4e8 --- /dev/null +++ b/gpui/src/tray.rs @@ -0,0 +1,141 @@ +//! System tray: closing the main window hides it to a tray icon instead of +//! quitting, and the icon brings it back. Quitting is the tray menu's job. +//! +//! Windows only for now; elsewhere [`install`] does nothing and closing the +//! window quits as before. + +#[cfg(windows)] +mod imp { + use futures::channel::mpsc; + use futures::StreamExt as _; + use gpui_kit::{AnyWindowHandle, App, AppContext as _, Window}; + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + use tray_icon::menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem}; + use tray_icon::{Icon, MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; + + use crate::activity::ACTIVITY; + + #[link(name = "user32")] + extern "system" { + fn ShowWindow(hwnd: isize, cmd: i32) -> i32; + fn SetForegroundWindow(hwnd: isize) -> i32; + } + + const SW_HIDE: i32 = 0; + /// Shows a hidden window and un-minimizes a minimized one. + const SW_RESTORE: i32 = 9; + /// The app icon `build.rs` embeds as the exe's first icon resource. + const APP_ICON_RESOURCE: u16 = 1; + + enum Command { + Show, + Quit, + } + + fn hwnd_of(window: &Window) -> Option { + match HasWindowHandle::window_handle(window).map(|handle| handle.as_raw()) { + Ok(RawWindowHandle::Win32(handle)) => Some(handle.hwnd.get()), + _ => None, + } + } + + /// Hides the window (no taskbar button either) and tells the background + /// loops nobody is looking. + pub fn hide(window: &Window) { + if let Some(hwnd) = hwnd_of(window) { + unsafe { + ShowWindow(hwnd, SW_HIDE); + } + } + ACTIVITY.set_active(false); + } + + fn show(window: &mut Window) { + if let Some(hwnd) = hwnd_of(window) { + unsafe { + ShowWindow(hwnd, SW_RESTORE); + SetForegroundWindow(hwnd); + } + } + window.activate_window(); + ACTIVITY.set_active(true); + } + + /// Creates the tray icon and makes closing `window` hide it. Returns + /// whether the tray exists; without one, closing still quits. + pub fn install(cx: &mut App, window: AnyWindowHandle) -> bool { + let Ok(icon) = Icon::from_resource(APP_ICON_RESOURCE, Some((32, 32))) else { + return false; + }; + let open = MenuItem::with_id("open", "Open OpenMouse", true, None); + let quit = MenuItem::with_id("quit", "Quit", true, None); + let menu = Menu::new(); + if menu.append_items(&[&open, &PredefinedMenuItem::separator(), &quit]).is_err() { + return false; + } + let Ok(tray) = TrayIconBuilder::new() + .with_icon(icon) + .with_tooltip("OpenMouse Desktop") + .with_menu(Box::new(menu)) + .with_menu_on_left_click(false) + .build() + else { + return false; + }; + + // The handlers run inside the tray's window procedure on the main + // thread; they only queue a command for the task below. + let (commands, mut queue) = mpsc::unbounded(); + let menu_commands = commands.clone(); + MenuEvent::set_event_handler(Some(move |event: MenuEvent| { + let command = match event.id.as_ref() { + "open" => Command::Show, + "quit" => Command::Quit, + _ => return, + }; + let _ = menu_commands.unbounded_send(command); + })); + TrayIconEvent::set_event_handler(Some(move |event: TrayIconEvent| { + let clicked = matches!( + event, + TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } + | TrayIconEvent::DoubleClick { button: MouseButton::Left, .. } + ); + if clicked { + let _ = commands.unbounded_send(Command::Show); + } + })); + + // Owns the icon, so it lives as long as the app does. + cx.spawn(async move |cx| { + let _tray = tray; + while let Some(command) = queue.next().await { + match command { + Command::Show => { + let _ = cx.update_window(window, |_, window, _| show(window)); + } + Command::Quit => { + let _ = cx.update(|cx| cx.quit()); + } + } + } + }) + .detach(); + + let _ = cx.update_window(window, |_, window, cx| { + window.on_window_should_close(cx, |window, _| { + hide(window); + false + }); + }); + true + } +} + +#[cfg(windows)] +pub use imp::{hide, install}; + +#[cfg(not(windows))] +pub fn install(_cx: &mut gpui_kit::App, _window: gpui_kit::AnyWindowHandle) -> bool { + false +}