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
This commit is contained in:
2026-03-26 12:52:00 +01:00
parent 4ed9a9973a
commit 5be21aadc6
9 changed files with 491 additions and 207 deletions

26
Cargo.lock generated
View File

@@ -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",

View File

@@ -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<String>,
#[serde(default)]
pub terminal: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
}

View File

@@ -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,
}
}

View File

@@ -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);
}

View File

@@ -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<Box<dyn Provider>> = 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<Box<dyn Provider>> = 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<NativeProvider> {
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<NativeProvider> = match loader.discover() {
Ok(count) if count > 0 => {
info!("Discovered {} native plugin(s) for local fallback", count);
let plugins: Vec<Arc<owlry_core::plugins::native_loader::NativePlugin>> =
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<Arc<owlry_core::plugins::native_loader::NativePlugin>> =
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<Box<dyn Provider>> = 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,
}
}

262
crates/owlry/src/backend.rs Normal file
View File

@@ -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<LaunchItem> {
match self {
SearchBackend::Daemon(client) => {
let modes: Vec<String> = 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<LaunchItem> {
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<String> = 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<LaunchItem>)> {
match self {
SearchBackend::Daemon(client) => {
match client.submenu(plugin_id, data) {
Ok(items) if !items.is_empty() => {
let actions: Vec<LaunchItem> =
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<String> {
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,
}
}

View File

@@ -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<bool> {
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![],
}],
};

View File

@@ -1,4 +1,5 @@
mod app;
mod backend;
pub mod client;
mod cli;
mod plugin_commands;

View File

@@ -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<RefCell<Config>>,
providers: Rc<RefCell<ProviderManager>>,
frecency: Rc<RefCell<FrecencyStore>>,
backend: Rc<RefCell<SearchBackend>>,
current_results: Rc<RefCell<Vec<LaunchItem>>>,
filter: Rc<RefCell<ProviderFilter>>,
mode_label: Label,
@@ -81,8 +80,7 @@ impl MainWindow {
pub fn new(
app: &Application,
config: Rc<RefCell<Config>>,
providers: Rc<RefCell<ProviderManager>>,
frecency: Rc<RefCell<FrecencyStore>>,
backend: Rc<RefCell<SearchBackend>>,
filter: Rc<RefCell<ProviderFilter>>,
custom_prompt: Option<String>,
) -> 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<String> = 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<LaunchItem> = 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<LaunchItem> = 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<RefCell<FrecencyStore>>,
providers: &Rc<RefCell<ProviderManager>>,
backend: &Rc<RefCell<SearchBackend>>,
) -> 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<RefCell<FrecencyStore>>) {
fn launch_item(item: &LaunchItem, config: &Config, backend: &Rc<RefCell<SearchBackend>>) {
// 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);
}