From 8f7501038dcd4ee85eb4420ea611760b13c1dd47 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Sat, 28 Mar 2026 09:05:58 +0100 Subject: [PATCH] perf(ui): move search IPC off the GTK main thread Search queries in daemon mode now run on a background thread via DaemonHandle::query_async(). Results are posted back to the main thread via glib::spawn_future_local + futures_channel::oneshot. The GTK event loop is never blocked by IPC, eliminating perceived input lag. Local mode (dmenu) continues to use synchronous search since it has no IPC overhead. --- Cargo.lock | 1 + crates/owlry/Cargo.toml | 3 + crates/owlry/src/app.rs | 2 +- crates/owlry/src/backend.rs | 231 ++++++++++++++++++++++------- crates/owlry/src/ui/main_window.rs | 127 ++++++++++++---- 5 files changed, 277 insertions(+), 87 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7749d71..952b83e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2542,6 +2542,7 @@ dependencies = [ "clap", "dirs", "env_logger", + "futures-channel", "glib-build-tools", "gtk4", "gtk4-layer-shell", diff --git a/crates/owlry/Cargo.toml b/crates/owlry/Cargo.toml index d3bd421..35b5020 100644 --- a/crates/owlry/Cargo.toml +++ b/crates/owlry/Cargo.toml @@ -46,6 +46,9 @@ dirs = "5" # Semantic versioning (needed by plugin commands) semver = "1" +# Async oneshot channel (background thread -> main loop) +futures-channel = "0.3" + [build-dependencies] # GResource compilation for bundled icons glib-build-tools = "0.20" diff --git a/crates/owlry/src/app.rs b/crates/owlry/src/app.rs index 3433e56..ee6cc5f 100644 --- a/crates/owlry/src/app.rs +++ b/crates/owlry/src/app.rs @@ -69,7 +69,7 @@ impl OwlryApp { match CoreClient::connect_or_start() { Ok(client) => { info!("Connected to owlry-core daemon"); - SearchBackend::Daemon(client) + SearchBackend::Daemon(crate::backend::DaemonHandle::new(client)) } Err(e) => { warn!( diff --git a/crates/owlry/src/backend.rs b/crates/owlry/src/backend.rs index e73e0a4..8aa8975 100644 --- a/crates/owlry/src/backend.rs +++ b/crates/owlry/src/backend.rs @@ -10,12 +10,87 @@ use owlry_core::data::FrecencyStore; use owlry_core::filter::ProviderFilter; use owlry_core::ipc::ResultItem; use owlry_core::providers::{LaunchItem, ProviderManager, ProviderType}; +use std::sync::{Arc, Mutex}; + +/// Parameters needed to run a search query on a background thread. +pub struct QueryParams { + pub query: String, + #[allow(dead_code)] + pub max_results: usize, + pub modes: Option>, + pub tag_filter: Option, +} + +/// Result of an async search, sent back to the main thread. +pub struct QueryResult { + #[allow(dead_code)] + pub query: String, + pub items: Vec, +} + +/// Thread-safe handle to the daemon IPC connection. +pub struct DaemonHandle { + pub(crate) client: Arc>, +} + +impl DaemonHandle { + pub fn new(client: CoreClient) -> Self { + Self { + client: Arc::new(Mutex::new(client)), + } + } + + /// Dispatch an IPC query on a background thread. + /// + /// Returns a `futures_channel::oneshot::Receiver` that resolves with + /// the `QueryResult` once the background thread completes IPC. The + /// caller should `.await` it inside `glib::spawn_future_local` to + /// process results on the GTK main thread without `Send` constraints. + pub fn query_async( + &self, + params: QueryParams, + ) -> futures_channel::oneshot::Receiver { + let (tx, rx) = futures_channel::oneshot::channel(); + let client = Arc::clone(&self.client); + let query_for_result = params.query.clone(); + + std::thread::spawn(move || { + let items = match client.lock() { + Ok(mut c) => { + let effective_query = if let Some(ref tag) = params.tag_filter { + format!(":tag:{} {}", tag, params.query) + } else { + params.query + }; + match c.query(&effective_query, params.modes) { + Ok(items) => items.into_iter().map(result_to_launch_item).collect(), + Err(e) => { + warn!("IPC query failed: {}", e); + Vec::new() + } + } + } + Err(e) => { + warn!("Failed to lock daemon client: {}", e); + Vec::new() + } + }; + + let _ = tx.send(QueryResult { + query: query_for_result, + items, + }); + }); + + rx + } +} /// Backend for search operations. Wraps either an IPC client (daemon mode) /// or a local ProviderManager (dmenu mode). pub enum SearchBackend { /// IPC client connected to owlry-core daemon - Daemon(CoreClient), + Daemon(DaemonHandle), /// Direct local provider manager (dmenu mode only) Local { providers: Box, @@ -24,6 +99,22 @@ pub enum SearchBackend { } impl SearchBackend { + /// Build the modes parameter from a ProviderFilter. + /// When accept_all, returns None so the daemon doesn't restrict to a specific set + /// (otherwise dynamically loaded plugin types would be filtered out). + fn build_modes_param(filter: &ProviderFilter) -> Option> { + if filter.is_accept_all() { + None + } else { + let modes: Vec = filter + .enabled_providers() + .iter() + .map(|p| p.to_string()) + .collect(); + if modes.is_empty() { None } else { Some(modes) } + } + } + /// Search for items matching the query. /// /// In daemon mode, sends query over IPC. The modes list is derived from @@ -38,24 +129,18 @@ impl SearchBackend { config: &Config, ) -> Vec { match self { - SearchBackend::Daemon(client) => { - // When accept_all, send None so daemon doesn't restrict to a specific set - // (otherwise dynamically loaded plugin types would be filtered out) - let modes_param = if filter.is_accept_all() { - None - } else { - let modes: Vec = filter - .enabled_providers() - .iter() - .map(|p| p.to_string()) - .collect(); - if modes.is_empty() { None } else { Some(modes) } - }; - - match client.query(query, modes_param) { - Ok(items) => items.into_iter().map(result_to_launch_item).collect(), + SearchBackend::Daemon(handle) => { + let modes_param = Self::build_modes_param(filter); + match handle.client.lock() { + Ok(mut client) => match client.query(query, modes_param) { + Ok(items) => items.into_iter().map(result_to_launch_item).collect(), + Err(e) => { + warn!("IPC query failed: {}", e); + Vec::new() + } + }, Err(e) => { - warn!("IPC query failed: {}", e); + warn!("Failed to lock daemon client: {}", e); Vec::new() } } @@ -101,32 +186,24 @@ impl SearchBackend { tag_filter: Option<&str>, ) -> Vec { match self { - SearchBackend::Daemon(client) => { - // Daemon doesn't support tag filtering in IPC yet — pass query as-is. - // If there's a tag filter, prepend it so the daemon can handle it. + SearchBackend::Daemon(handle) => { let effective_query = if let Some(tag) = tag_filter { format!(":tag:{} {}", tag, query) } else { query.to_string() }; - // When accept_all, send None so daemon doesn't restrict to a specific set - // (otherwise dynamically loaded plugin types would be filtered out) - let modes_param = if filter.is_accept_all() { - None - } else { - let modes: Vec = filter - .enabled_providers() - .iter() - .map(|p| p.to_string()) - .collect(); - if modes.is_empty() { None } else { Some(modes) } - }; - - match client.query(&effective_query, modes_param) { - Ok(items) => items.into_iter().map(result_to_launch_item).collect(), + let modes_param = Self::build_modes_param(filter); + match handle.client.lock() { + Ok(mut client) => match client.query(&effective_query, modes_param) { + Ok(items) => items.into_iter().map(result_to_launch_item).collect(), + Err(e) => { + warn!("IPC query failed: {}", e); + Vec::new() + } + }, Err(e) => { - warn!("IPC query failed: {}", e); + warn!("Failed to lock daemon client: {}", e); Vec::new() } } @@ -162,13 +239,43 @@ impl SearchBackend { } } + /// Dispatch async search (daemon mode only). + /// Returns `Some(Receiver)` if dispatched, `None` for local mode. + pub fn query_async( + &self, + query: &str, + max_results: usize, + filter: &ProviderFilter, + _config: &Config, + tag_filter: Option<&str>, + ) -> Option> { + match self { + SearchBackend::Daemon(handle) => { + let params = QueryParams { + query: query.to_string(), + max_results, + modes: Self::build_modes_param(filter), + tag_filter: tag_filter.map(|s| s.to_string()), + }; + Some(handle.query_async(params)) + } + SearchBackend::Local { .. } => None, + } + } + /// Execute a plugin action command. Returns true if handled. pub fn execute_plugin_action(&mut self, command: &str) -> bool { match self { - SearchBackend::Daemon(client) => match client.plugin_action(command) { - Ok(handled) => handled, + SearchBackend::Daemon(handle) => match handle.client.lock() { + Ok(mut client) => match client.plugin_action(command) { + Ok(handled) => handled, + Err(e) => { + warn!("IPC plugin_action failed: {}", e); + false + } + }, Err(e) => { - warn!("IPC plugin_action failed: {}", e); + warn!("Failed to lock daemon client: {}", e); false } }, @@ -185,15 +292,21 @@ impl SearchBackend { display_name: &str, ) -> Option<(String, Vec)> { match self { - SearchBackend::Daemon(client) => match client.submenu(plugin_id, data) { - Ok(items) if !items.is_empty() => { - let actions: Vec = - items.into_iter().map(result_to_launch_item).collect(); - Some((display_name.to_string(), actions)) - } - Ok(_) => None, + SearchBackend::Daemon(handle) => match handle.client.lock() { + Ok(mut client) => match client.submenu(plugin_id, data) { + Ok(items) if !items.is_empty() => { + let actions: Vec = + items.into_iter().map(result_to_launch_item).collect(); + Some((display_name.to_string(), actions)) + } + Ok(_) => None, + Err(e) => { + warn!("IPC submenu query failed: {}", e); + None + } + }, Err(e) => { - warn!("IPC submenu query failed: {}", e); + warn!("Failed to lock daemon client: {}", e); None } }, @@ -206,9 +319,13 @@ impl SearchBackend { /// Record a launch event for frecency tracking. pub fn record_launch(&mut self, item_id: &str, provider: &str) { match self { - SearchBackend::Daemon(client) => { - if let Err(e) = client.launch(item_id, provider) { - warn!("IPC launch notification failed: {}", e); + SearchBackend::Daemon(handle) => { + if let Ok(mut client) = handle.client.lock() { + if let Err(e) = client.launch(item_id, provider) { + warn!("IPC launch notification failed: {}", e); + } + } else { + warn!("Failed to lock daemon client for launch"); } } SearchBackend::Local { frecency, .. } => { @@ -236,10 +353,16 @@ impl SearchBackend { #[allow(dead_code)] pub fn available_provider_ids(&mut self) -> Vec { match self { - SearchBackend::Daemon(client) => match client.providers() { - Ok(descs) => descs.into_iter().map(|d| d.id).collect(), + SearchBackend::Daemon(handle) => match handle.client.lock() { + Ok(mut client) => match client.providers() { + Ok(descs) => descs.into_iter().map(|d| d.id).collect(), + Err(e) => { + warn!("IPC providers query failed: {}", e); + Vec::new() + } + }, Err(e) => { - warn!("IPC providers query failed: {}", e); + warn!("Failed to lock daemon client: {}", e); Vec::new() } }, diff --git a/crates/owlry/src/ui/main_window.rs b/crates/owlry/src/ui/main_window.rs index b0bd759..7255519 100644 --- a/crates/owlry/src/ui/main_window.rs +++ b/crates/owlry/src/ui/main_window.rs @@ -671,6 +671,11 @@ impl MainWindow { let filter = filter.clone(); let lazy_state = lazy_state.clone(); let debounce_source_for_closure = debounce_source.clone(); + let query_str = parsed.query.clone(); + let tag = parsed.tag_filter.clone(); + // Capture the raw entry text at dispatch time for staleness detection. + let raw_text_at_dispatch = entry.text().to_string(); + let search_entry_for_stale = search_entry_for_change.clone(); // Schedule debounced search let source_id = gtk4::glib::timeout_add_local_once( @@ -683,40 +688,98 @@ impl MainWindow { let max_results = cfg.general.max_results; drop(cfg); - let results = backend.borrow_mut().search_with_tag( - &parsed.query, - max_results, - &filter.borrow(), - &config.borrow(), - parsed.tag_filter.as_deref(), - ); + // Try async path (daemon mode) + let receiver = { + let be = backend.borrow(); + let f = filter.borrow(); + let c = config.borrow(); + be.query_async( + &query_str, + max_results, + &f, + &c, + tag.as_deref(), + ) + }; - // Clear existing results - while let Some(child) = results_list.first_child() { - results_list.remove(&child); + if let Some(rx) = receiver { + // Daemon mode: results arrive asynchronously on the main loop. + // spawn_future_local runs the async block on the GTK main + // thread, so non-Send types (Rc, GTK widgets) are fine. + let results_list_cb = results_list.clone(); + let current_results_cb = current_results.clone(); + let lazy_state_cb = lazy_state.clone(); + + gtk4::glib::spawn_future_local(async move { + if let Ok(result) = rx.await { + // Discard stale results: the user has typed something new + // since this query was dispatched. + if search_entry_for_stale.text().as_str() != raw_text_at_dispatch { + return; + } + while let Some(child) = results_list_cb.first_child() { + results_list_cb.remove(&child); + } + + let initial_count = + INITIAL_RESULTS.min(result.items.len()); + { + let mut lazy = lazy_state_cb.borrow_mut(); + lazy.all_results = result.items.clone(); + lazy.displayed_count = initial_count; + } + + for item in result.items.iter().take(initial_count) { + let row = ResultRow::new(item); + results_list_cb.append(&row); + } + + if let Some(first_row) = + results_list_cb.row_at_index(0) + { + results_list_cb.select_row(Some(&first_row)); + } + + *current_results_cb.borrow_mut() = result + .items + .into_iter() + .take(initial_count) + .collect(); + } + }); + } else { + // Local mode (dmenu): synchronous search + let results = backend.borrow_mut().search_with_tag( + &query_str, + max_results, + &filter.borrow(), + &config.borrow(), + tag.as_deref(), + ); + + while let Some(child) = results_list.first_child() { + results_list.remove(&child); + } + + let initial_count = INITIAL_RESULTS.min(results.len()); + { + let mut lazy = lazy_state.borrow_mut(); + lazy.all_results = results.clone(); + lazy.displayed_count = initial_count; + } + + for item in results.iter().take(initial_count) { + let row = ResultRow::new(item); + results_list.append(&row); + } + + if let Some(first_row) = results_list.row_at_index(0) { + results_list.select_row(Some(&first_row)); + } + + *current_results.borrow_mut() = + results.into_iter().take(initial_count).collect(); } - - // Lazy loading: store all results but only display initial batch - let initial_count = INITIAL_RESULTS.min(results.len()); - { - let mut lazy = lazy_state.borrow_mut(); - lazy.all_results = results.clone(); - lazy.displayed_count = initial_count; - } - - // Display only initial batch - for item in results.iter().take(initial_count) { - let row = ResultRow::new(item); - results_list.append(&row); - } - - if let Some(first_row) = results_list.row_at_index(0) { - results_list.select_row(Some(&first_row)); - } - - // current_results holds only what's displayed (for selection/activation) - *current_results.borrow_mut() = - results.into_iter().take(initial_count).collect(); }, );