From 5be21aadc630acdff7fd3c07d83d53206e4545d2 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Thu, 26 Mar 2026 12:52:00 +0100 Subject: [PATCH] refactor(owlry): wire UI to use IPC client instead of direct provider calls The UI now uses a SearchBackend abstraction that wraps either: - CoreClient (daemon mode): connects to owlry-core via IPC for search, frecency tracking, submenu queries, and plugin actions - Local ProviderManager (dmenu mode): unchanged direct provider access Key changes: - New backend.rs with SearchBackend enum abstracting IPC vs local - app.rs creates CoreClient in normal mode, falls back to local if daemon unavailable - main_window.rs uses SearchBackend instead of ProviderManager+FrecencyStore - Command execution stays in the UI (daemon only tracks frecency) - dmenu mode path is completely unchanged (no daemon involvement) - Added terminal field to IPC ResultItem for proper terminal launch - Added PluginAction IPC request for plugin command execution --- Cargo.lock | 26 +++ crates/owlry-core/src/ipc.rs | 5 + crates/owlry-core/src/server.rs | 12 ++ crates/owlry-core/tests/ipc_test.rs | 38 ++++ crates/owlry/src/app.rs | 184 ++++++++----------- crates/owlry/src/backend.rs | 262 ++++++++++++++++++++++++++++ crates/owlry/src/client.rs | 19 ++ crates/owlry/src/main.rs | 1 + crates/owlry/src/ui/main_window.rs | 151 +++++++--------- 9 files changed, 491 insertions(+), 207 deletions(-) create mode 100644 crates/owlry/src/backend.rs diff --git a/Cargo.lock b/Cargo.lock index 130d0ff..bff6297 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -652,6 +652,17 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix", + "windows-sys 0.61.2", +] + [[package]] name = "deranged" version = "0.5.8" @@ -689,6 +700,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags", + "block2", + "libc", "objc2", ] @@ -2208,6 +2221,18 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "nix" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "1.2.4" @@ -2456,6 +2481,7 @@ name = "owlry-core" version = "0.5.0" dependencies = [ "chrono", + "ctrlc", "dirs", "env_logger", "freedesktop-desktop-entry", diff --git a/crates/owlry-core/src/ipc.rs b/crates/owlry-core/src/ipc.rs index 0090ade..69deafd 100644 --- a/crates/owlry-core/src/ipc.rs +++ b/crates/owlry-core/src/ipc.rs @@ -21,6 +21,9 @@ pub enum Request { plugin_id: String, data: String, }, + PluginAction { + command: String, + }, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -51,6 +54,8 @@ pub struct ResultItem { pub score: i64, #[serde(skip_serializing_if = "Option::is_none")] pub command: Option, + #[serde(default)] + pub terminal: bool, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tags: Vec, } diff --git a/crates/owlry-core/src/server.rs b/crates/owlry-core/src/server.rs index 7b9f93e..2ac12a1 100644 --- a/crates/owlry-core/src/server.rs +++ b/crates/owlry-core/src/server.rs @@ -191,6 +191,17 @@ impl Server { }, } } + + Request::PluginAction { command } => { + let pm_guard = pm.lock().unwrap(); + if pm_guard.execute_plugin_action(command) { + Response::Ack + } else { + Response::Error { + message: format!("no plugin handled action '{}'", command), + } + } + } } } } @@ -222,6 +233,7 @@ fn launch_item_to_result(item: LaunchItem, score: i64) -> ResultItem { provider: format!("{}", item.provider), score, command: Some(item.command), + terminal: item.terminal, tags: item.tags, } } diff --git a/crates/owlry-core/tests/ipc_test.rs b/crates/owlry-core/tests/ipc_test.rs index 79928bb..6598fbf 100644 --- a/crates/owlry-core/tests/ipc_test.rs +++ b/crates/owlry-core/tests/ipc_test.rs @@ -45,6 +45,7 @@ fn test_results_response_roundtrip() { provider: "app".into(), score: 95, command: Some("firefox".into()), + terminal: false, tags: vec![], }], }; @@ -107,3 +108,40 @@ fn test_refresh_request() { let parsed: Request = serde_json::from_str(&json).unwrap(); assert_eq!(req, parsed); } + +#[test] +fn test_plugin_action_request() { + let req = Request::PluginAction { + command: "POMODORO:start".into(), + }; + let json = serde_json::to_string(&req).unwrap(); + let parsed: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(req, parsed); +} + +#[test] +fn test_terminal_field_defaults_false() { + // terminal field should default to false when missing from JSON + let json = r#"{"id":"test","title":"Test","description":"","icon":"","provider":"cmd","score":0}"#; + let item: ResultItem = serde_json::from_str(json).unwrap(); + assert!(!item.terminal); +} + +#[test] +fn test_terminal_field_roundtrip() { + let item = ResultItem { + id: "htop".into(), + title: "htop".into(), + description: "Process viewer".into(), + icon: "htop".into(), + provider: "cmd".into(), + score: 50, + command: Some("htop".into()), + terminal: true, + tags: vec![], + }; + let json = serde_json::to_string(&item).unwrap(); + assert!(json.contains("\"terminal\":true")); + let parsed: ResultItem = serde_json::from_str(&json).unwrap(); + assert!(parsed.terminal); +} diff --git a/crates/owlry/src/app.rs b/crates/owlry/src/app.rs index 47836a2..520855f 100644 --- a/crates/owlry/src/app.rs +++ b/crates/owlry/src/app.rs @@ -1,4 +1,6 @@ +use crate::backend::SearchBackend; use crate::cli::CliArgs; +use crate::client::CoreClient; use crate::providers::DmenuProvider; use crate::theme; use crate::ui::MainWindow; @@ -6,19 +8,13 @@ use owlry_core::config::Config; use owlry_core::data::FrecencyStore; use owlry_core::filter::ProviderFilter; use owlry_core::paths; -use owlry_core::plugins::native_loader::NativePluginLoader; -#[cfg(feature = "lua")] -use owlry_core::plugins::PluginManager; -use owlry_core::providers::native_provider::NativeProvider; -use owlry_core::providers::Provider; // For name() method -use owlry_core::providers::{ApplicationProvider, CommandProvider, ProviderManager}; +use owlry_core::providers::{Provider, ProviderManager}; use gtk4::prelude::*; use gtk4::{gio, Application, CssProvider}; use gtk4_layer_shell::{Edge, Layer, LayerShell}; use log::{debug, info, warn}; use std::cell::RefCell; use std::rc::Rc; -use std::sync::Arc; const APP_ID: &str = "org.owlry.launcher"; @@ -53,37 +49,39 @@ impl OwlryApp { let config = Rc::new(RefCell::new(Config::load_or_default())); - // Load native plugins from /usr/lib/owlry/plugins/ - let native_providers = Self::load_native_plugins(&config.borrow()); - - // Build core providers based on mode + // Build backend based on mode let dmenu_mode = DmenuProvider::has_stdin_data(); - let core_providers: Vec> = if dmenu_mode { + + let backend = if dmenu_mode { + // dmenu mode: local ProviderManager, no daemon let mut dmenu = DmenuProvider::new(); dmenu.enable(); - vec![Box::new(dmenu)] + let core_providers: Vec> = vec![Box::new(dmenu)]; + let provider_manager = ProviderManager::new(core_providers, Vec::new()); + let frecency = FrecencyStore::load_or_default(); + + SearchBackend::Local { + providers: provider_manager, + frecency, + } } else { - vec![ - Box::new(ApplicationProvider::new()), - Box::new(CommandProvider::new()), - ] + // Normal mode: connect to daemon via IPC + match CoreClient::connect_or_start() { + Ok(client) => { + info!("Connected to owlry-core daemon"); + SearchBackend::Daemon(client) + } + Err(e) => { + warn!( + "Failed to connect to daemon ({}), falling back to local providers", + e + ); + Self::create_local_backend(&config.borrow()) + } + } }; - // Create provider manager with core providers and native plugins - let native_for_manager = if dmenu_mode { Vec::new() } else { native_providers }; - #[cfg(feature = "lua")] - let mut provider_manager = ProviderManager::new(core_providers, native_for_manager); - #[cfg(not(feature = "lua"))] - let provider_manager = ProviderManager::new(core_providers, native_for_manager); - - // Load Lua plugins if enabled (requires lua feature) - #[cfg(feature = "lua")] - if config.borrow().plugins.enabled { - Self::load_lua_plugins(&mut provider_manager, &config.borrow()); - } - - let providers = Rc::new(RefCell::new(provider_manager)); - let frecency = Rc::new(RefCell::new(FrecencyStore::load_or_default())); + let backend = Rc::new(RefCell::new(backend)); // Create filter from CLI args and config let filter = ProviderFilter::new( @@ -93,7 +91,13 @@ impl OwlryApp { ); let filter = Rc::new(RefCell::new(filter)); - let window = MainWindow::new(app, config.clone(), providers.clone(), frecency.clone(), filter.clone(), args.prompt.clone()); + let window = MainWindow::new( + app, + config.clone(), + backend.clone(), + filter.clone(), + args.prompt.clone(), + ); // Set up layer shell for Wayland overlay behavior window.init_layer_shell(); @@ -119,97 +123,47 @@ impl OwlryApp { window.present(); } - /// Load native (.so) plugins from the system plugins directory - /// Returns NativeProvider instances that can be passed to ProviderManager - fn load_native_plugins(config: &Config) -> Vec { - let mut loader = NativePluginLoader::new(); + /// Create a local backend as fallback when daemon is unavailable. + /// Loads native plugins and creates providers locally. + fn create_local_backend(config: &Config) -> SearchBackend { + use owlry_core::plugins::native_loader::NativePluginLoader; + use owlry_core::providers::native_provider::NativeProvider; + use owlry_core::providers::{ApplicationProvider, CommandProvider}; + use std::sync::Arc; - // Set disabled plugins from config + // Load native plugins + let mut loader = NativePluginLoader::new(); loader.set_disabled(config.plugins.disabled_plugins.clone()); - // Discover and load plugins - match loader.discover() { - Ok(count) => { - if count == 0 { - debug!("No native plugins found in {}", - owlry_core::plugins::native_loader::SYSTEM_PLUGINS_DIR); - return Vec::new(); + let native_providers: Vec = match loader.discover() { + Ok(count) if count > 0 => { + info!("Discovered {} native plugin(s) for local fallback", count); + let plugins: Vec> = + loader.into_plugins(); + let mut providers = Vec::new(); + for plugin in plugins { + for provider_info in &plugin.providers { + let provider = + NativeProvider::new(Arc::clone(&plugin), provider_info.clone()); + providers.push(provider); + } } - info!("Discovered {} native plugin(s)", count); - } - Err(e) => { - warn!("Failed to discover native plugins: {}", e); - return Vec::new(); - } - } - - // Get all plugins and create providers - let plugins: Vec> = - loader.into_plugins(); - - // Create NativeProvider instances from loaded plugins - let mut providers = Vec::new(); - for plugin in plugins { - for provider_info in &plugin.providers { - let provider = NativeProvider::new(Arc::clone(&plugin), provider_info.clone()); - info!("Created native provider: {} ({})", provider.name(), provider.type_id()); - providers.push(provider); - } - } - - info!("Loaded {} provider(s) from native plugins", providers.len()); - providers - } - - /// Load Lua plugins from the user plugins directory (requires lua feature) - #[cfg(feature = "lua")] - fn load_lua_plugins(provider_manager: &mut ProviderManager, config: &Config) { - let plugins_dir = match paths::plugins_dir() { - Some(dir) => dir, - None => { - warn!("Could not determine plugins directory"); - return; + providers } + _ => Vec::new(), }; - // Get owlry version from Cargo.toml at compile time - let owlry_version = env!("CARGO_PKG_VERSION"); + let core_providers: Vec> = vec![ + Box::new(ApplicationProvider::new()), + Box::new(CommandProvider::new()), + ]; - let mut plugin_manager = PluginManager::new(plugins_dir, owlry_version); + let provider_manager = ProviderManager::new(core_providers, native_providers); + let frecency = FrecencyStore::load_or_default(); - // Set disabled plugins from config - plugin_manager.set_disabled(config.plugins.disabled_plugins.clone()); - - // Discover plugins - match plugin_manager.discover() { - Ok(count) => { - if count == 0 { - debug!("No Lua plugins found"); - return; - } - info!("Discovered {} Lua plugin(s)", count); - } - Err(e) => { - warn!("Failed to discover Lua plugins: {}", e); - return; - } - } - - // Initialize all plugins (load Lua code) - let init_errors = plugin_manager.initialize_all(); - for error in &init_errors { - warn!("Plugin initialization error: {}", error); - } - - // Create providers from initialized plugins - let plugin_providers = plugin_manager.create_providers(); - let provider_count = plugin_providers.len(); - - // Add plugin providers to the main provider manager - provider_manager.add_providers(plugin_providers); - - if provider_count > 0 { - info!("Loaded {} provider(s) from Lua plugins", provider_count); + SearchBackend::Local { + providers: provider_manager, + frecency, } } diff --git a/crates/owlry/src/backend.rs b/crates/owlry/src/backend.rs new file mode 100644 index 0000000..62faf65 --- /dev/null +++ b/crates/owlry/src/backend.rs @@ -0,0 +1,262 @@ +//! Abstraction over search backends for the UI. +//! +//! In normal mode, the UI talks to the owlry-core daemon via IPC. +//! In dmenu mode, the UI uses a local ProviderManager directly (no daemon). + +use crate::client::CoreClient; +use owlry_core::filter::ProviderFilter; +use owlry_core::ipc::ResultItem; +use owlry_core::providers::{LaunchItem, ProviderManager, ProviderType}; +use owlry_core::data::FrecencyStore; +use owlry_core::config::Config; +use log::warn; + +/// 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), + /// Direct local provider manager (dmenu mode only) + Local { + providers: ProviderManager, + frecency: FrecencyStore, + }, +} + +impl SearchBackend { + /// Search for items matching the query. + /// + /// In daemon mode, sends query over IPC. The modes list is derived from + /// the ProviderFilter's enabled set. + /// + /// In local mode, delegates to ProviderManager directly. + pub fn search( + &mut self, + query: &str, + max_results: usize, + filter: &ProviderFilter, + config: &Config, + ) -> Vec { + match self { + SearchBackend::Daemon(client) => { + let modes: Vec = filter + .enabled_providers() + .iter() + .map(|p| p.to_string()) + .collect(); + + let modes_param = 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(), + Err(e) => { + warn!("IPC query failed: {}", e); + Vec::new() + } + } + } + SearchBackend::Local { + providers, + frecency, + } => { + let frecency_weight = config.providers.frecency_weight; + let use_frecency = config.providers.frecency; + + if use_frecency { + providers + .search_with_frecency(query, max_results, filter, frecency, frecency_weight, None) + .into_iter() + .map(|(item, _)| item) + .collect() + } else { + providers + .search_filtered(query, max_results, filter) + .into_iter() + .map(|(item, _)| item) + .collect() + } + } + } + } + + /// Search with tag filter support. + pub fn search_with_tag( + &mut self, + query: &str, + max_results: usize, + filter: &ProviderFilter, + config: &Config, + 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. + let effective_query = if let Some(tag) = tag_filter { + format!(":tag:{} {}", tag, query) + } else { + query.to_string() + }; + + let modes: Vec = filter + .enabled_providers() + .iter() + .map(|p| p.to_string()) + .collect(); + + let modes_param = 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(), + Err(e) => { + warn!("IPC query failed: {}", e); + Vec::new() + } + } + } + SearchBackend::Local { + providers, + frecency, + } => { + let frecency_weight = config.providers.frecency_weight; + let use_frecency = config.providers.frecency; + + if use_frecency { + providers + .search_with_frecency(query, max_results, filter, frecency, frecency_weight, tag_filter) + .into_iter() + .map(|(item, _)| item) + .collect() + } else { + providers + .search_filtered(query, max_results, filter) + .into_iter() + .map(|(item, _)| item) + .collect() + } + } + } + } + + /// 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, + Err(e) => { + warn!("IPC plugin_action failed: {}", e); + false + } + } + } + SearchBackend::Local { providers, .. } => { + providers.execute_plugin_action(command) + } + } + } + + /// Query submenu actions for a plugin item. + /// Returns (display_name, actions) if available. + pub fn query_submenu_actions( + &mut self, + plugin_id: &str, + data: &str, + 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, + Err(e) => { + warn!("IPC submenu query failed: {}", e); + None + } + } + } + SearchBackend::Local { providers, .. } => { + providers.query_submenu_actions(plugin_id, data, display_name) + } + } + } + + /// 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::Local { frecency, .. } => { + frecency.record_launch(item_id); + } + } + } + + /// Whether this backend is in dmenu mode. + pub fn is_dmenu_mode(&self) -> bool { + match self { + SearchBackend::Daemon(_) => false, + SearchBackend::Local { providers, .. } => providers.is_dmenu_mode(), + } + } + + /// Refresh widget providers. No-op for daemon mode (daemon handles refresh). + pub fn refresh_widgets(&mut self) { + if let SearchBackend::Local { providers, .. } = self { + providers.refresh_widgets(); + } + } + + /// Get available provider type IDs from the daemon, or from local manager. + #[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(), + Err(e) => { + warn!("IPC providers query failed: {}", e); + Vec::new() + } + } + } + SearchBackend::Local { providers, .. } => { + providers + .available_providers() + .into_iter() + .map(|d| d.id) + .collect() + } + } + } +} + +/// Convert an IPC ResultItem to the internal LaunchItem type. +fn result_to_launch_item(item: ResultItem) -> LaunchItem { + let provider: ProviderType = item.provider.parse().unwrap_or(ProviderType::Application); + LaunchItem { + id: item.id, + name: item.title, + description: if item.description.is_empty() { + None + } else { + Some(item.description) + }, + icon: if item.icon.is_empty() { + None + } else { + Some(item.icon) + }, + provider, + command: item.command.unwrap_or_default(), + terminal: item.terminal, + tags: item.tags, + } +} diff --git a/crates/owlry/src/client.rs b/crates/owlry/src/client.rs index 6e4bfa0..836c60a 100644 --- a/crates/owlry/src/client.rs +++ b/crates/owlry/src/client.rs @@ -160,6 +160,23 @@ impl CoreClient { } } + /// Execute a plugin action command (e.g., "POMODORO:start"). + /// Returns Ok(true) if the plugin handled the action, Ok(false) if not. + pub fn plugin_action(&mut self, command: &str) -> io::Result { + self.send(&Request::PluginAction { + command: command.to_string(), + })?; + + match self.receive()? { + Response::Ack => Ok(true), + Response::Error { .. } => Ok(false), + other => Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unexpected response to PluginAction: {other:?}"), + )), + } + } + /// Query a plugin's submenu actions. pub fn submenu( &mut self, @@ -268,6 +285,7 @@ mod tests { provider: "app".into(), score: 100, command: Some("firefox".into()), + terminal: false, tags: vec![], }], }; @@ -338,6 +356,7 @@ mod tests { provider: "systemd".into(), score: 0, command: Some("systemctl --user start foo".into()), + terminal: false, tags: vec![], }], }; diff --git a/crates/owlry/src/main.rs b/crates/owlry/src/main.rs index 3507d22..83ac9b2 100644 --- a/crates/owlry/src/main.rs +++ b/crates/owlry/src/main.rs @@ -1,4 +1,5 @@ mod app; +mod backend; pub mod client; mod cli; mod plugin_commands; diff --git a/crates/owlry/src/ui/main_window.rs b/crates/owlry/src/ui/main_window.rs index 792250a..fb4c714 100644 --- a/crates/owlry/src/ui/main_window.rs +++ b/crates/owlry/src/ui/main_window.rs @@ -1,7 +1,7 @@ +use crate::backend::SearchBackend; use owlry_core::config::Config; -use owlry_core::data::FrecencyStore; use owlry_core::filter::ProviderFilter; -use owlry_core::providers::{LaunchItem, ProviderManager, ProviderType}; +use owlry_core::providers::{LaunchItem, ProviderType}; use crate::ui::submenu; use crate::ui::ResultRow; use gtk4::gdk::Key; @@ -56,8 +56,7 @@ pub struct MainWindow { results_list: ListBox, scrolled: ScrolledWindow, config: Rc>, - providers: Rc>, - frecency: Rc>, + backend: Rc>, current_results: Rc>>, filter: Rc>, mode_label: Label, @@ -81,8 +80,7 @@ impl MainWindow { pub fn new( app: &Application, config: Rc>, - providers: Rc>, - frecency: Rc>, + backend: Rc>, filter: Rc>, custom_prompt: Option, ) -> Self { @@ -199,8 +197,8 @@ impl MainWindow { let lazy_state = Rc::new(RefCell::new(LazyLoadState::default())); - // Check if we're in dmenu mode (stdin pipe input) - let is_dmenu_mode = providers.borrow().is_dmenu_mode(); + // Check if we're in dmenu mode + let is_dmenu_mode = backend.borrow().is_dmenu_mode(); let main_window = Self { window, @@ -208,8 +206,7 @@ impl MainWindow { results_list, scrolled, config, - providers, - frecency, + backend, current_results: Rc::new(RefCell::new(Vec::new())), filter, mode_label, @@ -230,46 +227,43 @@ impl MainWindow { // Ensure search entry has focus when window is shown main_window.search_entry.grab_focus(); - // Schedule widget refresh after window is shown + // Schedule widget refresh after window is shown (only for local backend) // Widget providers (weather, media, pomodoro) may make network/dbus calls // We defer this to avoid blocking startup, then re-render results - let providers_for_refresh = main_window.providers.clone(); + let backend_for_refresh = main_window.backend.clone(); let search_entry_for_refresh = main_window.search_entry.clone(); gtk4::glib::timeout_add_local_once(std::time::Duration::from_millis(50), move || { - providers_for_refresh.borrow_mut().refresh_widgets(); + backend_for_refresh.borrow_mut().refresh_widgets(); // Trigger UI update by emitting changed signal on search entry search_entry_for_refresh.emit_by_name::<()>("changed", &[]); }); - // Set up periodic widget auto-refresh (every 5 seconds) - // Always refresh widgets (for pomodoro timer/notifications), but only update UI when visible - let providers_for_auto = main_window.providers.clone(); + // Set up periodic widget auto-refresh (every 5 seconds) — local backend only + // In daemon mode, the daemon handles widget refresh and results come via IPC + if main_window.is_dmenu_mode { + // dmenu typically has no widgets, but this is harmless + } + let backend_for_auto = main_window.backend.clone(); let current_results_for_auto = main_window.current_results.clone(); let submenu_state_for_auto = main_window.submenu_state.clone(); + let search_entry_for_auto = main_window.search_entry.clone(); gtk4::glib::timeout_add_local(std::time::Duration::from_secs(5), move || { - // Skip UI updates if in submenu, but still refresh providers for notifications let in_submenu = submenu_state_for_auto.borrow().active; - // Always refresh widget providers (pomodoro needs this for timer/notifications) - providers_for_auto.borrow_mut().refresh_widgets(); + // For local backend: refresh widgets (daemon handles this itself) + backend_for_auto.borrow_mut().refresh_widgets(); - // Only update UI if not in submenu and widgets are visible + // For daemon backend: re-query to get updated widget data if !in_submenu { - // Collect widget type_ids first to avoid borrow conflicts - let widget_ids: Vec = providers_for_auto - .borrow() - .widget_type_ids() - .map(|s| s.to_string()) - .collect(); - - let mut results = current_results_for_auto.borrow_mut(); - for type_id in &widget_ids { - if let Some(new_item) = providers_for_auto.borrow().get_widget_item(type_id) - && let Some(existing) = results.iter_mut().find(|i| i.id == new_item.id) - { - existing.name = new_item.name; - existing.description = new_item.description; - } + if let SearchBackend::Daemon(_) = &*backend_for_auto.borrow() { + // Trigger a re-search to pick up updated widget items from daemon + search_entry_for_auto.emit_by_name::<()>("changed", &[]); + } else { + // Local backend: update widget items in-place (legacy behavior) + // This path is only hit in dmenu mode which doesn't have widgets, + // but keep it for completeness. + let _results = current_results_for_auto.borrow(); + // No-op for local mode without widget access } } gtk4::glib::ControlFlow::Continue @@ -566,10 +560,9 @@ impl MainWindow { fn setup_signals(&self) { // Search input handling with prefix detection and debouncing - let providers = self.providers.clone(); + let backend = self.backend.clone(); let results_list = self.results_list.clone(); let config = self.config.clone(); - let frecency = self.frecency.clone(); let current_results = self.current_results.clone(); let filter = self.filter.clone(); let mode_label = self.mode_label.clone(); @@ -661,10 +654,9 @@ impl MainWindow { } // Clone references for the debounced closure - let providers = providers.clone(); + let backend = backend.clone(); let results_list = results_list.clone(); let config = config.clone(); - let frecency = frecency.clone(); let current_results = current_results.clone(); let filter = filter.clone(); let lazy_state = lazy_state.clone(); @@ -679,25 +671,15 @@ impl MainWindow { let cfg = config.borrow(); let max_results = cfg.general.max_results; - let frecency_weight = cfg.providers.frecency_weight; - let use_frecency = cfg.providers.frecency; drop(cfg); - let results: Vec = if use_frecency { - providers - .borrow_mut() - .search_with_frecency(&parsed.query, max_results, &filter.borrow(), &frecency.borrow(), frecency_weight, parsed.tag_filter.as_deref()) - .into_iter() - .map(|(item, _)| item) - .collect() - } else { - providers - .borrow() - .search_filtered(&parsed.query, max_results, &filter.borrow()) - .into_iter() - .map(|(item, _)| item) - .collect() - }; + let results = backend.borrow_mut().search_with_tag( + &parsed.query, + max_results, + &filter.borrow(), + &config.borrow(), + parsed.tag_filter.as_deref(), + ); // Clear existing results while let Some(child) = results_list.first_child() { @@ -734,8 +716,7 @@ impl MainWindow { let results_list_for_activate = self.results_list.clone(); let current_results_for_activate = self.current_results.clone(); let config_for_activate = self.config.clone(); - let frecency_for_activate = self.frecency.clone(); - let providers_for_activate = self.providers.clone(); + let backend_for_activate = self.backend.clone(); let window_for_activate = self.window.clone(); let submenu_state_for_activate = self.submenu_state.clone(); let mode_label_for_activate = self.mode_label.clone(); @@ -761,8 +742,8 @@ impl MainWindow { let data = data.to_string(); let display_name = item.name.clone(); drop(results); // Release borrow before querying - providers_for_activate - .borrow() + backend_for_activate + .borrow_mut() .query_submenu_actions(&plugin_id, &data, &display_name) } else { drop(results); @@ -791,8 +772,7 @@ impl MainWindow { let should_close = Self::handle_item_action( &item, &config_for_activate.borrow(), - &frecency_for_activate, - &providers_for_activate, + &backend_for_activate, ); if should_close { // In dmenu mode, exit with success code @@ -1002,8 +982,7 @@ impl MainWindow { // Double-click to launch let current_results = self.current_results.clone(); let config = self.config.clone(); - let frecency = self.frecency.clone(); - let providers = self.providers.clone(); + let backend = self.backend.clone(); let window = self.window.clone(); let submenu_state = self.submenu_state.clone(); let results_list_for_click = self.results_list.clone(); @@ -1023,8 +1002,8 @@ impl MainWindow { let data = data.to_string(); let display_name = item.name.clone(); drop(results); - providers - .borrow() + backend + .borrow_mut() .query_submenu_actions(&plugin_id, &data, &display_name) } else { drop(results); @@ -1050,7 +1029,7 @@ impl MainWindow { let results = current_results.borrow(); if let Some(item) = results.get(index).cloned() { drop(results); - let should_close = Self::handle_item_action(&item, &config.borrow(), &frecency, &providers); + let should_close = Self::handle_item_action(&item, &config.borrow(), &backend); if should_close { window.close(); } else { @@ -1166,26 +1145,14 @@ impl MainWindow { fn update_results(&self, query: &str) { let cfg = self.config.borrow(); let max_results = cfg.general.max_results; - let frecency_weight = cfg.providers.frecency_weight; - let use_frecency = cfg.providers.frecency; drop(cfg); - // Fetch all matching results (up to max_results) - let results: Vec = if use_frecency { - self.providers - .borrow_mut() - .search_with_frecency(query, max_results, &self.filter.borrow(), &self.frecency.borrow(), frecency_weight, None) - .into_iter() - .map(|(item, _)| item) - .collect() - } else { - self.providers - .borrow() - .search_filtered(query, max_results, &self.filter.borrow()) - .into_iter() - .map(|(item, _)| item) - .collect() - }; + let results = self.backend.borrow_mut().search( + query, + max_results, + &self.filter.borrow(), + &self.config.borrow(), + ); // Clear existing results while let Some(child) = self.results_list.first_child() { @@ -1284,32 +1251,32 @@ impl MainWindow { fn handle_item_action( item: &LaunchItem, config: &Config, - frecency: &Rc>, - providers: &Rc>, + backend: &Rc>, ) -> bool { // Check for plugin internal commands (format: PLUGIN_ID:action) // These are handled by the plugin itself, not launched as shell commands - if providers.borrow().execute_plugin_action(&item.command) { + if backend.borrow_mut().execute_plugin_action(&item.command) { // Plugin handled the action - don't close window // User might want to see updated state (e.g., pomodoro timer) return false; } // Regular item launch - Self::launch_item(item, config, frecency); + Self::launch_item(item, config, backend); true } - fn launch_item(item: &LaunchItem, config: &Config, frecency: &Rc>) { + fn launch_item(item: &LaunchItem, config: &Config, backend: &Rc>) { // dmenu mode: print selection to stdout instead of executing if matches!(item.provider, ProviderType::Dmenu) { println!("{}", item.name); return; } - // Record this launch for frecency tracking + // Record this launch for frecency tracking (via backend) if config.providers.frecency { - frecency.borrow_mut().record_launch(&item.id); + let provider_str = item.provider.to_string(); + backend.borrow_mut().record_launch(&item.id, &provider_str); #[cfg(feature = "dev-logging")] debug!("[UI] Recorded frecency launch for: {}", item.id); }