From a13c0cb6fb5c0cbc8527ef34bbc0655ef26ad242 Mon Sep 17 00:00:00 2001 From: eatradish Date: Tue, 6 Jan 2026 18:56:07 +0800 Subject: [PATCH 1/9] feat: add linux platform support --- Cargo.lock | 1 + Cargo.toml | 3 +++ src/config/settings.rs | 2 ++ src/gui/app.rs | 7 +++++++ 4 files changed, 13 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 6afd406..d0f47a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5516,6 +5516,7 @@ dependencies = [ "global-hotkey", "gpui", "gpui-component", + "gtk", "image", "objc2 0.6.3", "raw-window-handle", diff --git a/Cargo.toml b/Cargo.toml index c7f4497..e4c0e0c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,9 @@ objc2 = "0.6.3" [target.'cfg(target_os = "windows")'.dependencies] windows-sys = { version = "0.61.2", features = ["Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse"] } +[target.'cfg(target_os = "linux")'.dependencies] +gtk = "0.18.2" + [dev-dependencies] tempfile = "3.20" diff --git a/src/config/settings.rs b/src/config/settings.rs index d6593f3..4196d0d 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -65,6 +65,8 @@ impl Default for Settings { activation_key: "control+shift+d".to_string(), #[cfg(target_os = "windows")] activation_key: "ctrl+shift+d".to_string(), + #[cfg(target_os = "linux")] + activation_key: "ctrl+shift+d".to_string(), }, storage: StorageSettings { max_history_records: 100, diff --git a/src/gui/app.rs b/src/gui/app.rs index e4a5521..b74193f 100644 --- a/src/gui/app.rs +++ b/src/gui/app.rs @@ -194,6 +194,11 @@ pub fn launch_app() { let is_silent = args.iter().any(|arg| arg == "--silent"); Application::new().with_assets(Assets).run(move |cx| { + // Fix panic on message: + // GTK has not been initialized. Call `gtk::init` first. + #[cfg(target_os = "linux")] + gtk::init().expect("Failed to init gtk modules"); + // Set activation policy on macOS #[cfg(target_os = "macos")] set_activation_policy_accessory(); @@ -258,6 +263,8 @@ fn bind_application_keys(cx: &mut App) { KeyBinding::new("cmd-q", crate::gui::board::Quit, None), #[cfg(target_os = "windows")] KeyBinding::new("alt-f4", crate::gui::board::Quit, None), + #[cfg(target_os = "linux")] + KeyBinding::new("alt-f4", crate::gui::board::Quit, None), KeyBinding::new("up", crate::gui::board::SelectPrev, None), KeyBinding::new("down", crate::gui::board::SelectNext, None), KeyBinding::new("enter", crate::gui::board::ConfirmSelection, None), From 5de150b56fa3c9bb4f839d545802486776a94593 Mon Sep 17 00:00:00 2001 From: eatradish Date: Wed, 7 Jan 2026 15:51:17 +0800 Subject: [PATCH 2/9] feat: impl `set_always_on_top_x11` and set linux `WindowKind` as `Normal` It seems that in Linux/KDE/X11, ropy will put the window on top by default, but I've found that it seems to be possible not to do so by setting the window mode to `Normal`, so I'll use that as a workaround for now. --- Cargo.lock | 1 + Cargo.toml | 1 + src/gui/app.rs | 16 ++++++- src/gui/mod.rs | 2 + src/gui/utils.rs | 16 +++++-- src/gui/x11.rs | 108 +++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 src/gui/x11.rs diff --git a/Cargo.lock b/Cargo.lock index d0f47a1..741c9d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5532,6 +5532,7 @@ dependencies = [ "tray-icon", "windows-sys 0.61.2", "winres", + "x11rb", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index e4c0e0c..60c3ea7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ windows-sys = { version = "0.61.2", features = ["Win32_UI_WindowsAndMessaging", [target.'cfg(target_os = "linux")'.dependencies] gtk = "0.18.2" +x11rb = "0.13" [dev-dependencies] tempfile = "3.20" diff --git a/src/gui/app.rs b/src/gui/app.rs index b74193f..3428164 100644 --- a/src/gui/app.rs +++ b/src/gui/app.rs @@ -2,6 +2,7 @@ use crate::clipboard::{self, ClipboardEvent, LastCopyState}; use crate::config::{AppTheme, AutoStartManager, Settings}; use crate::gui::board::RopyBoard; use crate::gui::tray::start_tray_handler; +use crate::gui::x11::X11; use crate::repository::{ClipboardRecord, ClipboardRepository}; use gpui::{ App, AppContext, Application, AssetSource, AsyncApp, Bounds, KeyBinding, WindowBounds, @@ -11,7 +12,11 @@ use gpui_component::theme::Theme; use gpui_component::{Root, ThemeMode}; use rust_embed::RustEmbed; use std::borrow::Cow; -use std::sync::{Arc, Mutex, RwLock}; +#[cfg(target_os = "linux")] +use std::env; +use std::sync::{Arc, Mutex, OnceLock, RwLock}; + +pub static X11: OnceLock = OnceLock::new(); #[derive(RustEmbed)] #[folder = "assets"] @@ -131,6 +136,9 @@ fn create_window( cx.open_window( WindowOptions { window_bounds: Some(WindowBounds::Windowed(bounds)), + #[cfg(target_os = "linux")] + kind: WindowKind::Normal, + #[cfg(not(target_os = "linux"))] kind: WindowKind::PopUp, titlebar: None, show: !is_silent, // When silent mode, do not show the window initially @@ -253,6 +261,12 @@ pub fn launch_app() { if !is_silent { cx.activate(true); } + + // Initialize X11 control + #[cfg(target_os = "linux")] + if env::var("DISPLAY").is_ok() { + X11.get_or_init(|| X11::new().expect("Failed to connect x11rb")); + } }); } diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 5c63e17..cb953c9 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -3,6 +3,8 @@ mod board; mod hotkey; mod tray; mod utils; +#[cfg(target_os = "linux")] +mod x11; pub use app::launch_app; pub use utils::{active_window, hide_window}; diff --git a/src/gui/utils.rs b/src/gui/utils.rs index 9fee644..d1ed521 100644 --- a/src/gui/utils.rs +++ b/src/gui/utils.rs @@ -1,4 +1,6 @@ use gpui::{Context, Window}; + +#[cfg(not(target_os = "linux"))] use raw_window_handle::{HasWindowHandle, RawWindowHandle}; #[cfg(target_os = "windows")] @@ -43,9 +45,9 @@ pub fn active_window(_window: &mut Window, _cx: &mut Context) { } /// Set the window to be always on top -pub fn set_always_on_top(window: &mut Window, _cx: &mut Context, always_on_top: bool) { +pub fn set_always_on_top(_window: &mut Window, _cx: &mut Context, always_on_top: bool) { #[cfg(target_os = "windows")] - if let Ok(handle) = window.window_handle() { + if let Ok(handle) = _window.window_handle() { if let RawWindowHandle::Win32(handle) = handle.as_raw() { let hwnd = handle.hwnd.get() as *mut std::ffi::c_void; unsafe { @@ -62,7 +64,7 @@ pub fn set_always_on_top(window: &mut Window, _cx: &mut Context, always_on } } #[cfg(target_os = "macos")] - if let Ok(handle) = window.window_handle() + if let Ok(handle) = _window.window_handle() && let RawWindowHandle::AppKit(handle) = handle.as_raw() { // NSFloatingWindowLevel = 3, NSNormalWindowLevel = 0 @@ -75,6 +77,14 @@ pub fn set_always_on_top(window: &mut Window, _cx: &mut Context, always_on } } } + #[cfg(target_os = "linux")] + { + if let Some(x11) = crate::gui::app::X11.get() { + if let Err(e) = x11.set_always_on_top(always_on_top) { + eprintln!("[ropy] Failed to set always on top: {e}") + } + } + } } /// Start dragging the window diff --git a/src/gui/x11.rs b/src/gui/x11.rs new file mode 100644 index 0000000..fa029ad --- /dev/null +++ b/src/gui/x11.rs @@ -0,0 +1,108 @@ +use std::{error::Error, io}; + +use x11rb::{ + connection::Connection, + protocol::xproto::{AtomEnum, ClientMessageEvent, ConnectionExt, EventMask}, + rust_connection::RustConnection, + wrapper::ConnectionExt as _, +}; + +#[allow(dead_code)] +pub struct X11 { + connection: RustConnection, + root_id: u32, + net_client_list: u32, + net_wm_pid: u32, + net_wm_state_above: u32, + net_wm_state: u32, + window_id: u32, +} + +impl X11 { + pub fn new() -> Result> { + let (conn, screen_num) = x11rb::connect(None)?; + + let screen = &conn.setup().roots[screen_num]; + let root_id = screen.root; + let net_client_list = Self::get_atom(&conn, b"_NET_CLIENT_LIST")?; + let net_wm_pid = Self::get_atom(&conn, b"_NET_WM_PID")?; + let net_wm_state_above = Self::get_atom(&conn, b"_NET_WM_STATE_ABOVE")?; + let net_wm_state = Self::get_atom(&conn, b"_NET_WM_STATE")?; + + let windows = Self::get_value32(&conn, root_id, net_client_list)?; + + let mut window_id = None; + + for window in windows { + let pids = Self::get_value32(&conn, window, net_wm_pid)?; + + if pids.contains(&std::process::id()) { + window_id = Some(window); + break; + } + } + + Ok(Self { + connection: conn, + root_id, + net_client_list, + net_wm_pid, + net_wm_state_above, + net_wm_state, + window_id: window_id.ok_or_else(|| io::Error::other("Failed to get window id"))?, + }) + } + + fn get_atom(conn: &RustConnection, cmd: &[u8]) -> Result> { + let atom = conn.intern_atom(false, cmd)?; + let atom = atom.reply()?.atom; + + Ok(atom) + } + + fn get_value32( + conn: &RustConnection, + window: u32, + atom: u32, + ) -> Result, Box> { + let reply = conn + .get_property(false, window, atom, AtomEnum::ANY, 0, u32::MAX)? + .reply()?; + + let res = reply + .value32() + .ok_or_else(|| io::Error::other("Failed to get reply"))? + .collect(); + + Ok(res) + } + + fn send_wm_state_and_sync( + &self, + status: u32, + enable: bool, + window: u32, + ) -> Result<(), Box> { + let event = ClientMessageEvent::new( + 32, + self.window_id, + self.net_wm_state, + [if enable { 1 } else { 0 }, status, 0, 0, 0], + ); + + self.connection.send_event( + false, + window, + EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY, + event, + )?; + + self.connection.sync()?; + + Ok(()) + } + + pub fn set_always_on_top(&self, always_on_top: bool) -> Result<(), Box> { + self.send_wm_state_and_sync(self.net_wm_state_above, always_on_top, self.root_id) + } +} From b76d2b36b3caf184936b0dc87d57b35b7d6196be Mon Sep 17 00:00:00 2001 From: eatradish Date: Wed, 7 Jan 2026 17:52:12 +0800 Subject: [PATCH 3/9] feat: use `minimize_window()` for `hide_window()` on linux --- src/gui/board/actions.rs | 2 +- src/gui/board/mod.rs | 4 ++-- src/gui/utils.rs | 7 ++++++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/gui/board/actions.rs b/src/gui/board/actions.rs index a42b0a0..aee8012 100644 --- a/src/gui/board/actions.rs +++ b/src/gui/board/actions.rs @@ -65,7 +65,7 @@ impl RopyBoard { window.focus(&self.focus_handle); return; } - hide_window(window, cx); + hide_window(window, cx, true); self.pinned = false; } diff --git a/src/gui/board/mod.rs b/src/gui/board/mod.rs index dd56db6..24bc7f0 100644 --- a/src/gui/board/mod.rs +++ b/src/gui/board/mod.rs @@ -76,7 +76,7 @@ impl RopyBoard { cx.on_focus_out(&focus_handle, window, move |this, _event, window, cx| { // When the window loses focus, hide the window if !this.pinned { - hide_window(window, cx); + hide_window(window, cx, false); } }); @@ -213,7 +213,7 @@ impl RopyBoard { }; self.copy_to_clipboard(&content, &content_type); if !self.pinned { - hide_window(window, cx); + hide_window(window, cx, false); } if index != 0 { self.delete_record(id); diff --git a/src/gui/utils.rs b/src/gui/utils.rs index d1ed521..59594eb 100644 --- a/src/gui/utils.rs +++ b/src/gui/utils.rs @@ -14,7 +14,7 @@ use windows_sys::Win32::UI::WindowsAndMessaging::{ use objc2::{msg_send, runtime::AnyObject}; /// Hide the window based on the platform -pub fn hide_window(_window: &mut Window, _cx: &mut Context) { +pub fn hide_window(_window: &mut Window, _cx: &mut Context, _is_keybinding: bool) { #[cfg(target_os = "windows")] if let Ok(handle) = _window.window_handle() { if let RawWindowHandle::Win32(handle) = handle.as_raw() { @@ -26,6 +26,11 @@ pub fn hide_window(_window: &mut Window, _cx: &mut Context) { } #[cfg(target_os = "macos")] _cx.hide(); + + #[cfg(target_os = "linux")] + if _is_keybinding { + _window.minimize_window(); + } } /// Activate the window based on the platform From dca1adae805d6d1dd632ae49688956c4582ee15b Mon Sep 17 00:00:00 2001 From: eatradish Date: Wed, 7 Jan 2026 18:32:13 +0800 Subject: [PATCH 4/9] feat: impl `X11:activate_window` --- src/gui/utils.rs | 9 +++++++++ src/gui/x11.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/gui/utils.rs b/src/gui/utils.rs index 59594eb..fb6dd17 100644 --- a/src/gui/utils.rs +++ b/src/gui/utils.rs @@ -47,6 +47,15 @@ pub fn active_window(_window: &mut Window, _cx: &mut Context) { } #[cfg(target_os = "macos")] _cx.activate(true); + #[cfg(target_os = "linux")] + { + if let Some(x11) = crate::gui::app::X11.get() { + if let Err(e) = x11.activate_window() { + eprintln!("[ropy] Failed to activate window: {e}") + } + } + } + } /// Set the window to be always on top diff --git a/src/gui/x11.rs b/src/gui/x11.rs index fa029ad..86ea3a6 100644 --- a/src/gui/x11.rs +++ b/src/gui/x11.rs @@ -16,6 +16,7 @@ pub struct X11 { net_wm_state_above: u32, net_wm_state: u32, window_id: u32, + net_active_window: u32, } impl X11 { @@ -28,6 +29,7 @@ impl X11 { let net_wm_pid = Self::get_atom(&conn, b"_NET_WM_PID")?; let net_wm_state_above = Self::get_atom(&conn, b"_NET_WM_STATE_ABOVE")?; let net_wm_state = Self::get_atom(&conn, b"_NET_WM_STATE")?; + let net_active_window = Self::get_atom(&conn, b"_NET_ACTIVE_WINDOW")?; let windows = Self::get_value32(&conn, root_id, net_client_list)?; @@ -50,6 +52,7 @@ impl X11 { net_wm_state_above, net_wm_state, window_id: window_id.ok_or_else(|| io::Error::other("Failed to get window id"))?, + net_active_window, }) } @@ -105,4 +108,27 @@ impl X11 { pub fn set_always_on_top(&self, always_on_top: bool) -> Result<(), Box> { self.send_wm_state_and_sync(self.net_wm_state_above, always_on_top, self.root_id) } + + pub fn activate_window(&self) -> Result<(), Box> { + self.connection.map_window(self.window_id)?; + self.connection.sync()?; + + let event = ClientMessageEvent::new( + 32, + self.window_id, + self.net_active_window, + [2, x11rb::CURRENT_TIME, 0, 0, 0], + ); + + self.connection.send_event( + false, + self.root_id, + EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY, + event, + )?; + + self.connection.sync()?; + + Ok(()) + } } From 663abd0737bb62ad730ac03b6ba5ece135002fd2 Mon Sep 17 00:00:00 2001 From: eatradish Date: Thu, 8 Jan 2026 15:44:05 +0800 Subject: [PATCH 5/9] feat: support linux/x11 tray icon --- src/gui/app.rs | 60 +++++++++++++++++++++++++++++++++++++------ src/gui/tray.rs | 67 +++++++++++++++++++++++-------------------------- 2 files changed, 84 insertions(+), 43 deletions(-) diff --git a/src/gui/app.rs b/src/gui/app.rs index 3428164..945ceca 100644 --- a/src/gui/app.rs +++ b/src/gui/app.rs @@ -1,7 +1,7 @@ use crate::clipboard::{self, ClipboardEvent, LastCopyState}; use crate::config::{AppTheme, AutoStartManager, Settings}; use crate::gui::board::RopyBoard; -use crate::gui::tray::start_tray_handler; +use crate::gui::tray::start_tray_handler_inner; use crate::gui::x11::X11; use crate::repository::{ClipboardRecord, ClipboardRepository}; use gpui::{ @@ -14,7 +14,9 @@ use rust_embed::RustEmbed; use std::borrow::Cow; #[cfg(target_os = "linux")] use std::env; -use std::sync::{Arc, Mutex, OnceLock, RwLock}; +use std::sync::{Arc, Mutex, OnceLock, RwLock, mpsc}; +use std::thread; +use std::time::Duration; pub static X11: OnceLock = OnceLock::new(); @@ -37,6 +39,8 @@ impl AssetSource for Assets { #[cfg(target_os = "macos")] use objc2::{class, msg_send, runtime::AnyObject}; +use super::tray::TrayEvent; + #[cfg(target_os = "macos")] fn set_activation_policy_accessory() { unsafe { @@ -202,11 +206,6 @@ pub fn launch_app() { let is_silent = args.iter().any(|arg| arg == "--silent"); Application::new().with_assets(Assets).run(move |cx| { - // Fix panic on message: - // GTK has not been initialized. Call `gtk::init` first. - #[cfg(target_os = "linux")] - gtk::init().expect("Failed to init gtk modules"); - // Set activation policy on macOS #[cfg(target_os = "macos")] set_activation_policy_accessory(); @@ -256,7 +255,8 @@ pub fn launch_app() { board.set_hotkey_tx(hotkey_tx); }); }); - start_tray_handler(window_handle, async_app.clone(), settings.clone()); + + start_tray_handler(settings, async_app, window_handle); if !is_silent { cx.activate(true); @@ -270,6 +270,50 @@ pub fn launch_app() { }); } +fn start_tray_handler( + settings: Arc>, + async_app: AsyncApp, + window_handle: WindowHandle, +) { + let (tx, rx) = mpsc::channel(); + + thread::spawn(move || { + #[cfg(target_os = "linux")] + gtk::init().expect("Failed to init gtk modules"); + + start_tray_handler_inner(settings, tx); + + #[cfg(target_os = "linux")] + gtk::main(); + }); + + let fg_executor = async_app.foreground_executor().clone(); + let bg_executor = async_app.background_executor().clone(); + + fg_executor + .spawn(async move { + loop { + while let Ok(event) = rx.try_recv() { + match event { + TrayEvent::Show => { + let _ = async_app.update(move |cx| { + crate::gui::tray::send_active_action(window_handle, cx); + }); + } + TrayEvent::Quit => { + let _ = async_app.update(move |cx| { + cx.quit(); + }); + } + } + } + + bg_executor.timer(Duration::from_millis(100)).await; + } + }) + .detach(); +} + fn bind_application_keys(cx: &mut App) { cx.bind_keys([ KeyBinding::new("escape", crate::gui::board::Hide, None), diff --git a/src/gui/tray.rs b/src/gui/tray.rs index 80dfeae..a6e74ae 100644 --- a/src/gui/tray.rs +++ b/src/gui/tray.rs @@ -2,9 +2,11 @@ use crate::config::Settings; use crate::i18n::I18n; use std::sync::Arc; use std::sync::RwLock; +use std::sync::mpsc::Sender; +use std::thread; use std::time::Duration; -use gpui::{AsyncApp, WindowHandle}; +use gpui::WindowHandle; use gpui_component::Root; use tray_icon::{ Icon, TrayIcon, TrayIconBuilder, TrayIconEvent, @@ -51,48 +53,43 @@ fn create_icon() -> Result> { Icon::from_rgba(rgba, width, height).map_err(|e| format!("Failed to create icon: {e:?}").into()) } +pub enum TrayEvent { + Show, + Quit, +} + /// Start the system tray handler -pub fn start_tray_handler( - window_handle: WindowHandle, - async_app: AsyncApp, - settings: Arc>, -) { - let fg_executor = async_app.foreground_executor().clone(); - let bg_executor = async_app.background_executor().clone(); +pub fn start_tray_handler_inner(settings: Arc>, tx: Sender) { match init_tray(settings) { Ok((tray, show_id, quit_id)) => { println!("[ropy] Tray icon initialized successfully"); // Keep tray icon alive for the lifetime of the application Box::leak(Box::new(tray)); - fg_executor - .spawn(async move { - let menu_channel = tray_icon::menu::MenuEvent::receiver(); - let tray_channel = TrayIconEvent::receiver(); - loop { - while let Ok(event) = menu_channel.try_recv() { - if event.id == show_id { - let _ = async_app.update(move |cx| { - send_active_action(window_handle, cx); - }); - } else if event.id == quit_id { - let _ = async_app.update(move |cx| { - cx.quit(); - }); - } + + thread::spawn(move || { + let menu_channel = tray_icon::menu::MenuEvent::receiver(); + let tray_channel = TrayIconEvent::receiver(); + + loop { + while let Ok(event) = menu_channel.try_recv() { + if event.id == show_id { + let _ = tx.send(TrayEvent::Show); + } else if event.id == quit_id { + let _ = tx.send(TrayEvent::Quit); } - while let Ok(event) = tray_channel.try_recv() { - if let TrayIconEvent::Click { button, .. } = event - && button == tray_icon::MouseButton::Left - { - let _ = async_app.update(move |cx| { - send_active_action(window_handle, cx); - }); - } + } + + while let Ok(event) = tray_channel.try_recv() { + if let TrayIconEvent::Click { button, .. } = event + && button == tray_icon::MouseButton::Left + { + let _ = tx.send(TrayEvent::Show); } - bg_executor.timer(Duration::from_millis(100)).await; } - }) - .detach(); + + thread::sleep(Duration::from_millis(100)); + } + }); } Err(e) => { eprintln!("[ropy] Failed to initialize tray icon: {e}"); @@ -101,7 +98,7 @@ pub fn start_tray_handler( } /// Send the active action to the main window -fn send_active_action(window_handle: WindowHandle, cx: &mut gpui::App) { +pub fn send_active_action(window_handle: WindowHandle, cx: &mut gpui::App) { window_handle .update(cx, |_, window, cx| { window.dispatch_action(Box::new(crate::gui::board::Active), cx) From e913fe34aacba9118881375ffafe9f8826f8c963 Mon Sep 17 00:00:00 2001 From: eatradish Date: Thu, 8 Jan 2026 16:44:22 +0800 Subject: [PATCH 6/9] feat: impl `X11::hide_window` --- src/gui/app.rs | 3 --- src/gui/board/actions.rs | 2 +- src/gui/board/mod.rs | 6 +++--- src/gui/utils.rs | 9 +++++---- src/gui/x11.rs | 7 +++++++ 5 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/gui/app.rs b/src/gui/app.rs index 945ceca..d87fe3c 100644 --- a/src/gui/app.rs +++ b/src/gui/app.rs @@ -140,9 +140,6 @@ fn create_window( cx.open_window( WindowOptions { window_bounds: Some(WindowBounds::Windowed(bounds)), - #[cfg(target_os = "linux")] - kind: WindowKind::Normal, - #[cfg(not(target_os = "linux"))] kind: WindowKind::PopUp, titlebar: None, show: !is_silent, // When silent mode, do not show the window initially diff --git a/src/gui/board/actions.rs b/src/gui/board/actions.rs index aee8012..a42b0a0 100644 --- a/src/gui/board/actions.rs +++ b/src/gui/board/actions.rs @@ -65,7 +65,7 @@ impl RopyBoard { window.focus(&self.focus_handle); return; } - hide_window(window, cx, true); + hide_window(window, cx); self.pinned = false; } diff --git a/src/gui/board/mod.rs b/src/gui/board/mod.rs index 24bc7f0..a11c901 100644 --- a/src/gui/board/mod.rs +++ b/src/gui/board/mod.rs @@ -76,7 +76,7 @@ impl RopyBoard { cx.on_focus_out(&focus_handle, window, move |this, _event, window, cx| { // When the window loses focus, hide the window if !this.pinned { - hide_window(window, cx, false); + hide_window(window, cx); } }); @@ -213,7 +213,7 @@ impl RopyBoard { }; self.copy_to_clipboard(&content, &content_type); if !self.pinned { - hide_window(window, cx, false); + hide_window(window, cx); } if index != 0 { self.delete_record(id); @@ -241,7 +241,7 @@ impl RopyBoard { // Get current max_history_records from settings as fallback let current_max_history = self.settings.read().unwrap().storage.max_history_records; - + let max_history = self .settings_max_history_input .read(cx) diff --git a/src/gui/utils.rs b/src/gui/utils.rs index fb6dd17..35e3510 100644 --- a/src/gui/utils.rs +++ b/src/gui/utils.rs @@ -14,7 +14,7 @@ use windows_sys::Win32::UI::WindowsAndMessaging::{ use objc2::{msg_send, runtime::AnyObject}; /// Hide the window based on the platform -pub fn hide_window(_window: &mut Window, _cx: &mut Context, _is_keybinding: bool) { +pub fn hide_window(_window: &mut Window, _cx: &mut Context) { #[cfg(target_os = "windows")] if let Ok(handle) = _window.window_handle() { if let RawWindowHandle::Win32(handle) = handle.as_raw() { @@ -28,8 +28,10 @@ pub fn hide_window(_window: &mut Window, _cx: &mut Context, _is_keybinding _cx.hide(); #[cfg(target_os = "linux")] - if _is_keybinding { - _window.minimize_window(); + if let Some(x11) = crate::gui::app::X11.get() { + if let Err(e) = x11.hide_window() { + eprintln!("[ropy] Failed to hide window: {e}") + } } } @@ -55,7 +57,6 @@ pub fn active_window(_window: &mut Window, _cx: &mut Context) { } } } - } /// Set the window to be always on top diff --git a/src/gui/x11.rs b/src/gui/x11.rs index 86ea3a6..41ed5bf 100644 --- a/src/gui/x11.rs +++ b/src/gui/x11.rs @@ -131,4 +131,11 @@ impl X11 { Ok(()) } + + pub fn hide_window(&self) -> Result<(), Box> { + self.connection.unmap_window(self.window_id)?; + self.connection.sync()?; + + Ok(()) + } } From 4acb7e18516767679455a6c66fbae06d64b5325d Mon Sep 17 00:00:00 2001 From: eatradish Date: Thu, 8 Jan 2026 17:01:26 +0800 Subject: [PATCH 7/9] feat: ensure run ropy on X11 active window --- src/gui/app.rs | 3 ++- src/gui/utils.rs | 2 +- src/gui/x11.rs | 13 ++++++++++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/gui/app.rs b/src/gui/app.rs index d87fe3c..79cecdf 100644 --- a/src/gui/app.rs +++ b/src/gui/app.rs @@ -262,7 +262,8 @@ pub fn launch_app() { // Initialize X11 control #[cfg(target_os = "linux")] if env::var("DISPLAY").is_ok() { - X11.get_or_init(|| X11::new().expect("Failed to connect x11rb")); + let x11 = X11.get_or_init(|| X11::new().expect("Failed to connect x11rb")); + let _ = x11.active_window(); } }); } diff --git a/src/gui/utils.rs b/src/gui/utils.rs index 35e3510..2fc89af 100644 --- a/src/gui/utils.rs +++ b/src/gui/utils.rs @@ -52,7 +52,7 @@ pub fn active_window(_window: &mut Window, _cx: &mut Context) { #[cfg(target_os = "linux")] { if let Some(x11) = crate::gui::app::X11.get() { - if let Err(e) = x11.activate_window() { + if let Err(e) = x11.display_and_activate_window() { eprintln!("[ropy] Failed to activate window: {e}") } } diff --git a/src/gui/x11.rs b/src/gui/x11.rs index 41ed5bf..1a3a01d 100644 --- a/src/gui/x11.rs +++ b/src/gui/x11.rs @@ -109,10 +109,21 @@ impl X11 { self.send_wm_state_and_sync(self.net_wm_state_above, always_on_top, self.root_id) } - pub fn activate_window(&self) -> Result<(), Box> { + pub fn display_and_activate_window(&self) -> Result<(), Box> { + self.display_window()?; + self.active_window()?; + + Ok(()) + } + + pub fn display_window(&self) -> Result<(), Box> { self.connection.map_window(self.window_id)?; self.connection.sync()?; + Ok(()) + } + + pub fn active_window(&self) -> Result<(), Box> { let event = ClientMessageEvent::new( 32, self.window_id, From 2ba49f787c9325812e9fc0cbef21a796451fce65 Mon Sep 17 00:00:00 2001 From: eatradish Date: Fri, 9 Jan 2026 15:04:15 +0800 Subject: [PATCH 8/9] fix: wait x11 activate request to done --- src/gui/x11.rs | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/gui/x11.rs b/src/gui/x11.rs index 1a3a01d..dbf47ad 100644 --- a/src/gui/x11.rs +++ b/src/gui/x11.rs @@ -1,4 +1,4 @@ -use std::{error::Error, io}; +use std::{error::Error, io, thread, time::Duration}; use x11rb::{ connection::Connection, @@ -139,6 +139,7 @@ impl X11 { )?; self.connection.sync()?; + self.wait_actvate_window()?; Ok(()) } @@ -149,4 +150,26 @@ impl X11 { Ok(()) } + + fn wait_actvate_window(&self) -> Result<(), Box> { + loop { + let prop = self + .connection + .get_property( + false, + self.root_id, + self.net_active_window, + AtomEnum::WINDOW, + 0, + 1, + )? + .reply()?; + + if u32::from_ne_bytes(prop.value[0..4].try_into()?) == self.window_id { + return Ok(()); + } + + thread::sleep(Duration::from_millis(10)); + } + } } From a9851d14eceeb958cddae83d022ba49452f24e17 Mon Sep 17 00:00:00 2001 From: eatradish Date: Fri, 9 Jan 2026 18:26:12 +0800 Subject: [PATCH 9/9] refactor: use gpui background executor to handle tray icon event --- src/gui/app.rs | 22 ++++++++++++---------- src/gui/tray.rs | 50 ++++++++++++++++++++++++++++--------------------- 2 files changed, 41 insertions(+), 31 deletions(-) diff --git a/src/gui/app.rs b/src/gui/app.rs index 79cecdf..225ee35 100644 --- a/src/gui/app.rs +++ b/src/gui/app.rs @@ -15,7 +15,6 @@ use std::borrow::Cow; #[cfg(target_os = "linux")] use std::env; use std::sync::{Arc, Mutex, OnceLock, RwLock, mpsc}; -use std::thread; use std::time::Duration; pub static X11: OnceLock = OnceLock::new(); @@ -275,18 +274,21 @@ fn start_tray_handler( ) { let (tx, rx) = mpsc::channel(); - thread::spawn(move || { - #[cfg(target_os = "linux")] - gtk::init().expect("Failed to init gtk modules"); + let fg_executor = async_app.foreground_executor().clone(); + let bg_executor = async_app.background_executor().clone(); + let bg_executor_clone = bg_executor.clone(); - start_tray_handler_inner(settings, tx); + bg_executor + .spawn(async move { + #[cfg(target_os = "linux")] + gtk::init().expect("Failed to init gtk modules"); - #[cfg(target_os = "linux")] - gtk::main(); - }); + start_tray_handler_inner(settings, tx, bg_executor_clone); - let fg_executor = async_app.foreground_executor().clone(); - let bg_executor = async_app.background_executor().clone(); + #[cfg(target_os = "linux")] + gtk::main(); + }) + .detach(); fg_executor .spawn(async move { diff --git a/src/gui/tray.rs b/src/gui/tray.rs index a6e74ae..ded5a04 100644 --- a/src/gui/tray.rs +++ b/src/gui/tray.rs @@ -3,9 +3,9 @@ use crate::i18n::I18n; use std::sync::Arc; use std::sync::RwLock; use std::sync::mpsc::Sender; -use std::thread; use std::time::Duration; +use gpui::BackgroundExecutor; use gpui::WindowHandle; use gpui_component::Root; use tray_icon::{ @@ -59,37 +59,45 @@ pub enum TrayEvent { } /// Start the system tray handler -pub fn start_tray_handler_inner(settings: Arc>, tx: Sender) { +pub fn start_tray_handler_inner( + settings: Arc>, + tx: Sender, + bg_executor: BackgroundExecutor, +) { match init_tray(settings) { Ok((tray, show_id, quit_id)) => { println!("[ropy] Tray icon initialized successfully"); // Keep tray icon alive for the lifetime of the application Box::leak(Box::new(tray)); - thread::spawn(move || { - let menu_channel = tray_icon::menu::MenuEvent::receiver(); - let tray_channel = TrayIconEvent::receiver(); + let bg_executor_clone = bg_executor.clone(); + + bg_executor + .spawn(async move { + let menu_channel = tray_icon::menu::MenuEvent::receiver(); + let tray_channel = TrayIconEvent::receiver(); - loop { - while let Ok(event) = menu_channel.try_recv() { - if event.id == show_id { - let _ = tx.send(TrayEvent::Show); - } else if event.id == quit_id { - let _ = tx.send(TrayEvent::Quit); + loop { + while let Ok(event) = menu_channel.try_recv() { + if event.id == show_id { + let _ = tx.send(TrayEvent::Show); + } else if event.id == quit_id { + let _ = tx.send(TrayEvent::Quit); + } } - } - while let Ok(event) = tray_channel.try_recv() { - if let TrayIconEvent::Click { button, .. } = event - && button == tray_icon::MouseButton::Left - { - let _ = tx.send(TrayEvent::Show); + while let Ok(event) = tray_channel.try_recv() { + if let TrayIconEvent::Click { button, .. } = event + && button == tray_icon::MouseButton::Left + { + let _ = tx.send(TrayEvent::Show); + } } - } - thread::sleep(Duration::from_millis(100)); - } - }); + bg_executor_clone.timer(Duration::from_millis(100)).await; + } + }) + .detach(); } Err(e) => { eprintln!("[ropy] Failed to initialize tray icon: {e}");