Compare commits
9 Commits
plugin-api
...
owlry-v1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 10a685c62f | |||
| 34db33c75f | |||
| 4bff83b5e6 | |||
| 8f7501038d | |||
| 4032205800 | |||
| 99985c7f3b | |||
| 6113217f7b | |||
| 558d415e12 | |||
| 6bde1504b1 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
/target
|
/target
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
.worktrees/
|
||||||
media.md
|
media.md
|
||||||
|
|
||||||
# AUR packages (each is its own git repo for aur.archlinux.org)
|
# AUR packages (each is its own git repo for aur.archlinux.org)
|
||||||
|
|||||||
5
Cargo.lock
generated
5
Cargo.lock
generated
@@ -2536,12 +2536,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "owlry"
|
name = "owlry"
|
||||||
version = "1.0.1"
|
version = "1.0.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"dirs",
|
"dirs",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
"futures-channel",
|
||||||
"glib-build-tools",
|
"glib-build-tools",
|
||||||
"gtk4",
|
"gtk4",
|
||||||
"gtk4-layer-shell",
|
"gtk4-layer-shell",
|
||||||
@@ -2556,7 +2557,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "owlry-core"
|
name = "owlry-core"
|
||||||
version = "1.1.0"
|
version = "1.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"ctrlc",
|
"ctrlc",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "owlry-core"
|
name = "owlry-core"
|
||||||
version = "1.1.0"
|
version = "1.1.1"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ use log::{debug, info, warn};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
use crate::paths;
|
use crate::paths;
|
||||||
|
|
||||||
@@ -522,12 +521,15 @@ fn detect_de_terminal() -> Option<String> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a command exists in PATH
|
/// Check if a command exists in PATH (in-process, no subprocess spawning)
|
||||||
fn command_exists(cmd: &str) -> bool {
|
fn command_exists(cmd: &str) -> bool {
|
||||||
Command::new("which")
|
std::env::var_os("PATH")
|
||||||
.arg(cmd)
|
.map(|paths| {
|
||||||
.output()
|
std::env::split_paths(&paths).any(|dir| {
|
||||||
.map(|o| o.status.success())
|
let full = dir.join(cmd);
|
||||||
|
full.is_file()
|
||||||
|
})
|
||||||
|
})
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -591,3 +593,17 @@ impl Config {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn command_exists_finds_sh() {
|
||||||
|
// /bin/sh exists on every Unix system
|
||||||
|
assert!(super::command_exists("sh"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_exists_rejects_nonexistent() {
|
||||||
|
assert!(!super::command_exists("owlry_nonexistent_binary_abc123"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -131,23 +131,36 @@ impl FrecencyStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Calculate frecency score using a pre-sampled timestamp.
|
||||||
|
/// Use this in hot loops to avoid repeated Utc::now() syscalls.
|
||||||
|
pub fn get_score_at(&self, item_id: &str, now: DateTime<Utc>) -> f64 {
|
||||||
|
match self.data.entries.get(item_id) {
|
||||||
|
Some(entry) => Self::calculate_frecency_at(entry.launch_count, entry.last_launch, now),
|
||||||
|
None => 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Calculate frecency using Firefox-style algorithm
|
/// Calculate frecency using Firefox-style algorithm
|
||||||
fn calculate_frecency(launch_count: u32, last_launch: DateTime<Utc>) -> f64 {
|
fn calculate_frecency(launch_count: u32, last_launch: DateTime<Utc>) -> f64 {
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
|
Self::calculate_frecency_at(launch_count, last_launch, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate frecency using a caller-provided timestamp.
|
||||||
|
fn calculate_frecency_at(launch_count: u32, last_launch: DateTime<Utc>, now: DateTime<Utc>) -> f64 {
|
||||||
let age = now.signed_duration_since(last_launch);
|
let age = now.signed_duration_since(last_launch);
|
||||||
let age_days = age.num_hours() as f64 / 24.0;
|
let age_days = age.num_hours() as f64 / 24.0;
|
||||||
|
|
||||||
// Recency weight based on how recently the item was used
|
|
||||||
let recency_weight = if age_days < 1.0 {
|
let recency_weight = if age_days < 1.0 {
|
||||||
100.0 // Today
|
100.0
|
||||||
} else if age_days < 7.0 {
|
} else if age_days < 7.0 {
|
||||||
70.0 // This week
|
70.0
|
||||||
} else if age_days < 30.0 {
|
} else if age_days < 30.0 {
|
||||||
50.0 // This month
|
50.0
|
||||||
} else if age_days < 90.0 {
|
} else if age_days < 90.0 {
|
||||||
30.0 // This quarter
|
30.0
|
||||||
} else {
|
} else {
|
||||||
10.0 // Older
|
10.0
|
||||||
};
|
};
|
||||||
|
|
||||||
launch_count as f64 * recency_weight
|
launch_count as f64 * recency_weight
|
||||||
@@ -206,6 +219,32 @@ mod tests {
|
|||||||
assert!(score_month < score_week);
|
assert!(score_month < score_week);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_score_at_matches_get_score() {
|
||||||
|
let mut store = FrecencyStore {
|
||||||
|
data: FrecencyData {
|
||||||
|
version: 1,
|
||||||
|
entries: HashMap::new(),
|
||||||
|
},
|
||||||
|
path: PathBuf::from("/dev/null"),
|
||||||
|
dirty: false,
|
||||||
|
};
|
||||||
|
store.data.entries.insert(
|
||||||
|
"test".to_string(),
|
||||||
|
FrecencyEntry {
|
||||||
|
launch_count: 5,
|
||||||
|
last_launch: Utc::now(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let score_at = store.get_score_at("test", now);
|
||||||
|
let score = store.get_score("test");
|
||||||
|
|
||||||
|
// Both should be very close (same timestamp, within rounding)
|
||||||
|
assert!((score_at - score).abs() < 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_launch_count_matters() {
|
fn test_launch_count_matters() {
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ pub use command::CommandProvider;
|
|||||||
// Re-export native provider for plugin loading
|
// Re-export native provider for plugin loading
|
||||||
pub use native_provider::NativeProvider;
|
pub use native_provider::NativeProvider;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
use fuzzy_matcher::FuzzyMatcher;
|
use fuzzy_matcher::FuzzyMatcher;
|
||||||
use fuzzy_matcher::skim::SkimMatcherV2;
|
use fuzzy_matcher::skim::SkimMatcherV2;
|
||||||
use log::info;
|
use log::info;
|
||||||
@@ -570,6 +571,7 @@ impl ProviderManager {
|
|||||||
query, max_results, frecency_weight
|
query, max_results, frecency_weight
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
let mut results: Vec<(LaunchItem, i64)> = Vec::new();
|
let mut results: Vec<(LaunchItem, i64)> = Vec::new();
|
||||||
|
|
||||||
// Add widget items first (highest priority) - only when:
|
// Add widget items first (highest priority) - only when:
|
||||||
@@ -633,7 +635,7 @@ impl ProviderManager {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.map(|item| {
|
.map(|item| {
|
||||||
let frecency_score = frecency.get_score(&item.id);
|
let frecency_score = frecency.get_score_at(&item.id, now);
|
||||||
let boosted = (frecency_score * frecency_weight * 100.0) as i64;
|
let boosted = (frecency_score * frecency_weight * 100.0) as i64;
|
||||||
(item, boosted)
|
(item, boosted)
|
||||||
})
|
})
|
||||||
@@ -682,7 +684,7 @@ impl ProviderManager {
|
|||||||
};
|
};
|
||||||
|
|
||||||
base_score.map(|s| {
|
base_score.map(|s| {
|
||||||
let frecency_score = frecency.get_score(&item.id);
|
let frecency_score = frecency.get_score_at(&item.id, now);
|
||||||
let frecency_boost = (frecency_score * frecency_weight * 10.0) as i64;
|
let frecency_boost = (frecency_score * frecency_weight * 10.0) as i64;
|
||||||
(item.clone(), s + frecency_boost)
|
(item.clone(), s + frecency_boost)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "owlry"
|
name = "owlry"
|
||||||
version = "1.0.1"
|
version = "1.0.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.90"
|
rust-version = "1.90"
|
||||||
description = "A lightweight, owl-themed application launcher for Wayland"
|
description = "A lightweight, owl-themed application launcher for Wayland"
|
||||||
@@ -46,6 +46,9 @@ dirs = "5"
|
|||||||
# Semantic versioning (needed by plugin commands)
|
# Semantic versioning (needed by plugin commands)
|
||||||
semver = "1"
|
semver = "1"
|
||||||
|
|
||||||
|
# Async oneshot channel (background thread -> main loop)
|
||||||
|
futures-channel = "0.3"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
# GResource compilation for bundled icons
|
# GResource compilation for bundled icons
|
||||||
glib-build-tools = "0.20"
|
glib-build-tools = "0.20"
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ impl OwlryApp {
|
|||||||
match CoreClient::connect_or_start() {
|
match CoreClient::connect_or_start() {
|
||||||
Ok(client) => {
|
Ok(client) => {
|
||||||
info!("Connected to owlry-core daemon");
|
info!("Connected to owlry-core daemon");
|
||||||
SearchBackend::Daemon(client)
|
SearchBackend::Daemon(crate::backend::DaemonHandle::new(client))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(
|
warn!(
|
||||||
@@ -135,6 +135,9 @@ impl OwlryApp {
|
|||||||
Self::load_css(&config.borrow());
|
Self::load_css(&config.borrow());
|
||||||
|
|
||||||
window.present();
|
window.present();
|
||||||
|
|
||||||
|
// Populate results AFTER present() so the window appears immediately
|
||||||
|
window.schedule_initial_results();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a local backend as fallback when daemon is unavailable.
|
/// Create a local backend as fallback when daemon is unavailable.
|
||||||
|
|||||||
@@ -10,12 +10,87 @@ use owlry_core::data::FrecencyStore;
|
|||||||
use owlry_core::filter::ProviderFilter;
|
use owlry_core::filter::ProviderFilter;
|
||||||
use owlry_core::ipc::ResultItem;
|
use owlry_core::ipc::ResultItem;
|
||||||
use owlry_core::providers::{LaunchItem, ProviderManager, ProviderType};
|
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<Vec<String>>,
|
||||||
|
pub tag_filter: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of an async search, sent back to the main thread.
|
||||||
|
pub struct QueryResult {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub query: String,
|
||||||
|
pub items: Vec<LaunchItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Thread-safe handle to the daemon IPC connection.
|
||||||
|
pub struct DaemonHandle {
|
||||||
|
pub(crate) client: Arc<Mutex<CoreClient>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<QueryResult> {
|
||||||
|
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)
|
/// Backend for search operations. Wraps either an IPC client (daemon mode)
|
||||||
/// or a local ProviderManager (dmenu mode).
|
/// or a local ProviderManager (dmenu mode).
|
||||||
pub enum SearchBackend {
|
pub enum SearchBackend {
|
||||||
/// IPC client connected to owlry-core daemon
|
/// IPC client connected to owlry-core daemon
|
||||||
Daemon(CoreClient),
|
Daemon(DaemonHandle),
|
||||||
/// Direct local provider manager (dmenu mode only)
|
/// Direct local provider manager (dmenu mode only)
|
||||||
Local {
|
Local {
|
||||||
providers: Box<ProviderManager>,
|
providers: Box<ProviderManager>,
|
||||||
@@ -24,6 +99,22 @@ pub enum SearchBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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<Vec<String>> {
|
||||||
|
if filter.is_accept_all() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let modes: Vec<String> = filter
|
||||||
|
.enabled_providers()
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.to_string())
|
||||||
|
.collect();
|
||||||
|
if modes.is_empty() { None } else { Some(modes) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Search for items matching the query.
|
/// Search for items matching the query.
|
||||||
///
|
///
|
||||||
/// In daemon mode, sends query over IPC. The modes list is derived from
|
/// In daemon mode, sends query over IPC. The modes list is derived from
|
||||||
@@ -38,24 +129,18 @@ impl SearchBackend {
|
|||||||
config: &Config,
|
config: &Config,
|
||||||
) -> Vec<LaunchItem> {
|
) -> Vec<LaunchItem> {
|
||||||
match self {
|
match self {
|
||||||
SearchBackend::Daemon(client) => {
|
SearchBackend::Daemon(handle) => {
|
||||||
// When accept_all, send None so daemon doesn't restrict to a specific set
|
let modes_param = Self::build_modes_param(filter);
|
||||||
// (otherwise dynamically loaded plugin types would be filtered out)
|
match handle.client.lock() {
|
||||||
let modes_param = if filter.is_accept_all() {
|
Ok(mut client) => match client.query(query, modes_param) {
|
||||||
None
|
Ok(items) => items.into_iter().map(result_to_launch_item).collect(),
|
||||||
} else {
|
Err(e) => {
|
||||||
let modes: Vec<String> = filter
|
warn!("IPC query failed: {}", e);
|
||||||
.enabled_providers()
|
Vec::new()
|
||||||
.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(),
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("IPC query failed: {}", e);
|
warn!("Failed to lock daemon client: {}", e);
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,32 +186,24 @@ impl SearchBackend {
|
|||||||
tag_filter: Option<&str>,
|
tag_filter: Option<&str>,
|
||||||
) -> Vec<LaunchItem> {
|
) -> Vec<LaunchItem> {
|
||||||
match self {
|
match self {
|
||||||
SearchBackend::Daemon(client) => {
|
SearchBackend::Daemon(handle) => {
|
||||||
// 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 {
|
let effective_query = if let Some(tag) = tag_filter {
|
||||||
format!(":tag:{} {}", tag, query)
|
format!(":tag:{} {}", tag, query)
|
||||||
} else {
|
} else {
|
||||||
query.to_string()
|
query.to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
// When accept_all, send None so daemon doesn't restrict to a specific set
|
let modes_param = Self::build_modes_param(filter);
|
||||||
// (otherwise dynamically loaded plugin types would be filtered out)
|
match handle.client.lock() {
|
||||||
let modes_param = if filter.is_accept_all() {
|
Ok(mut client) => match client.query(&effective_query, modes_param) {
|
||||||
None
|
Ok(items) => items.into_iter().map(result_to_launch_item).collect(),
|
||||||
} else {
|
Err(e) => {
|
||||||
let modes: Vec<String> = filter
|
warn!("IPC query failed: {}", e);
|
||||||
.enabled_providers()
|
Vec::new()
|
||||||
.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(),
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("IPC query failed: {}", e);
|
warn!("Failed to lock daemon client: {}", e);
|
||||||
Vec::new()
|
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<futures_channel::oneshot::Receiver<QueryResult>> {
|
||||||
|
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.
|
/// Execute a plugin action command. Returns true if handled.
|
||||||
pub fn execute_plugin_action(&mut self, command: &str) -> bool {
|
pub fn execute_plugin_action(&mut self, command: &str) -> bool {
|
||||||
match self {
|
match self {
|
||||||
SearchBackend::Daemon(client) => match client.plugin_action(command) {
|
SearchBackend::Daemon(handle) => match handle.client.lock() {
|
||||||
Ok(handled) => handled,
|
Ok(mut client) => match client.plugin_action(command) {
|
||||||
|
Ok(handled) => handled,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("IPC plugin_action failed: {}", e);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
},
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("IPC plugin_action failed: {}", e);
|
warn!("Failed to lock daemon client: {}", e);
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -185,15 +292,21 @@ impl SearchBackend {
|
|||||||
display_name: &str,
|
display_name: &str,
|
||||||
) -> Option<(String, Vec<LaunchItem>)> {
|
) -> Option<(String, Vec<LaunchItem>)> {
|
||||||
match self {
|
match self {
|
||||||
SearchBackend::Daemon(client) => match client.submenu(plugin_id, data) {
|
SearchBackend::Daemon(handle) => match handle.client.lock() {
|
||||||
Ok(items) if !items.is_empty() => {
|
Ok(mut client) => match client.submenu(plugin_id, data) {
|
||||||
let actions: Vec<LaunchItem> =
|
Ok(items) if !items.is_empty() => {
|
||||||
items.into_iter().map(result_to_launch_item).collect();
|
let actions: Vec<LaunchItem> =
|
||||||
Some((display_name.to_string(), actions))
|
items.into_iter().map(result_to_launch_item).collect();
|
||||||
}
|
Some((display_name.to_string(), actions))
|
||||||
Ok(_) => None,
|
}
|
||||||
|
Ok(_) => None,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("IPC submenu query failed: {}", e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("IPC submenu query failed: {}", e);
|
warn!("Failed to lock daemon client: {}", e);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -206,9 +319,13 @@ impl SearchBackend {
|
|||||||
/// Record a launch event for frecency tracking.
|
/// Record a launch event for frecency tracking.
|
||||||
pub fn record_launch(&mut self, item_id: &str, provider: &str) {
|
pub fn record_launch(&mut self, item_id: &str, provider: &str) {
|
||||||
match self {
|
match self {
|
||||||
SearchBackend::Daemon(client) => {
|
SearchBackend::Daemon(handle) => {
|
||||||
if let Err(e) = client.launch(item_id, provider) {
|
if let Ok(mut client) = handle.client.lock() {
|
||||||
warn!("IPC launch notification failed: {}", e);
|
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, .. } => {
|
SearchBackend::Local { frecency, .. } => {
|
||||||
@@ -236,10 +353,16 @@ impl SearchBackend {
|
|||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn available_provider_ids(&mut self) -> Vec<String> {
|
pub fn available_provider_ids(&mut self) -> Vec<String> {
|
||||||
match self {
|
match self {
|
||||||
SearchBackend::Daemon(client) => match client.providers() {
|
SearchBackend::Daemon(handle) => match handle.client.lock() {
|
||||||
Ok(descs) => descs.into_iter().map(|d| d.id).collect(),
|
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) => {
|
Err(e) => {
|
||||||
warn!("IPC providers query failed: {}", e);
|
warn!("Failed to lock daemon client: {}", e);
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -224,7 +224,6 @@ impl MainWindow {
|
|||||||
|
|
||||||
main_window.setup_signals();
|
main_window.setup_signals();
|
||||||
main_window.setup_lazy_loading();
|
main_window.setup_lazy_loading();
|
||||||
main_window.update_results("");
|
|
||||||
|
|
||||||
// Ensure search entry has focus when window is shown
|
// Ensure search entry has focus when window is shown
|
||||||
main_window.search_entry.grab_focus();
|
main_window.search_entry.grab_focus();
|
||||||
@@ -458,7 +457,12 @@ impl MainWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Scroll the given row into view within the scrolled window
|
/// Scroll the given row into view within the scrolled window
|
||||||
fn scroll_to_row(scrolled: &ScrolledWindow, results_list: &ListBox, row: &ListBoxRow) {
|
fn scroll_to_row(
|
||||||
|
scrolled: &ScrolledWindow,
|
||||||
|
results_list: &ListBox,
|
||||||
|
row: &ListBoxRow,
|
||||||
|
lazy_state: &Rc<RefCell<LazyLoadState>>,
|
||||||
|
) {
|
||||||
let vadj = scrolled.vadjustment();
|
let vadj = scrolled.vadjustment();
|
||||||
|
|
||||||
let row_index = row.index();
|
let row_index = row.index();
|
||||||
@@ -470,15 +474,7 @@ impl MainWindow {
|
|||||||
let current_scroll = vadj.value();
|
let current_scroll = vadj.value();
|
||||||
|
|
||||||
let list_height = results_list.height() as f64;
|
let list_height = results_list.height() as f64;
|
||||||
let row_count = {
|
let row_count = lazy_state.borrow().displayed_count.max(1) as f64;
|
||||||
let mut count = 0;
|
|
||||||
let mut child = results_list.first_child();
|
|
||||||
while child.is_some() {
|
|
||||||
count += 1;
|
|
||||||
child = child.and_then(|c| c.next_sibling());
|
|
||||||
}
|
|
||||||
count.max(1) as f64
|
|
||||||
};
|
|
||||||
|
|
||||||
let row_height = list_height / row_count;
|
let row_height = list_height / row_count;
|
||||||
let row_top = row_index as f64 * row_height;
|
let row_top = row_index as f64 * row_height;
|
||||||
@@ -675,6 +671,11 @@ impl MainWindow {
|
|||||||
let filter = filter.clone();
|
let filter = filter.clone();
|
||||||
let lazy_state = lazy_state.clone();
|
let lazy_state = lazy_state.clone();
|
||||||
let debounce_source_for_closure = debounce_source.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
|
// Schedule debounced search
|
||||||
let source_id = gtk4::glib::timeout_add_local_once(
|
let source_id = gtk4::glib::timeout_add_local_once(
|
||||||
@@ -687,40 +688,92 @@ impl MainWindow {
|
|||||||
let max_results = cfg.general.max_results;
|
let max_results = cfg.general.max_results;
|
||||||
drop(cfg);
|
drop(cfg);
|
||||||
|
|
||||||
let results = backend.borrow_mut().search_with_tag(
|
// Try async path (daemon mode)
|
||||||
&parsed.query,
|
let receiver = {
|
||||||
max_results,
|
let be = backend.borrow();
|
||||||
&filter.borrow(),
|
let f = filter.borrow();
|
||||||
&config.borrow(),
|
let c = config.borrow();
|
||||||
parsed.tag_filter.as_deref(),
|
be.query_async(
|
||||||
);
|
&query_str,
|
||||||
|
max_results,
|
||||||
|
&f,
|
||||||
|
&c,
|
||||||
|
tag.as_deref(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
// Clear existing results
|
if let Some(rx) = receiver {
|
||||||
while let Some(child) = results_list.first_child() {
|
// Daemon mode: results arrive asynchronously on the main loop.
|
||||||
results_list.remove(&child);
|
// 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();
|
||||||
|
|
||||||
// Lazy loading: store all results but only display initial batch
|
gtk4::glib::spawn_future_local(async move {
|
||||||
let initial_count = INITIAL_RESULTS.min(results.len());
|
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 items = result.items;
|
||||||
|
let initial_count =
|
||||||
|
INITIAL_RESULTS.min(items.len());
|
||||||
|
|
||||||
|
for item in 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() =
|
||||||
|
items[..initial_count].to_vec();
|
||||||
|
let mut lazy = lazy_state_cb.borrow_mut();
|
||||||
|
lazy.all_results = items;
|
||||||
|
lazy.displayed_count = initial_count;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} 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());
|
||||||
|
|
||||||
|
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[..initial_count].to_vec();
|
||||||
let mut lazy = lazy_state.borrow_mut();
|
let mut lazy = lazy_state.borrow_mut();
|
||||||
lazy.all_results = results.clone();
|
lazy.all_results = results;
|
||||||
lazy.displayed_count = initial_count;
|
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();
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -856,6 +909,7 @@ impl MainWindow {
|
|||||||
let submenu_state = self.submenu_state.clone();
|
let submenu_state = self.submenu_state.clone();
|
||||||
let tab_order = self.tab_order.clone();
|
let tab_order = self.tab_order.clone();
|
||||||
let is_dmenu_mode = self.is_dmenu_mode;
|
let is_dmenu_mode = self.is_dmenu_mode;
|
||||||
|
let lazy_state_for_keys = self.lazy_state.clone();
|
||||||
|
|
||||||
key_controller.connect_key_pressed(move |_, key, _, modifiers| {
|
key_controller.connect_key_pressed(move |_, key, _, modifiers| {
|
||||||
let ctrl = modifiers.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
|
let ctrl = modifiers.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
|
||||||
@@ -919,7 +973,7 @@ impl MainWindow {
|
|||||||
let next_index = current.index() + 1;
|
let next_index = current.index() + 1;
|
||||||
if let Some(next_row) = results_list.row_at_index(next_index) {
|
if let Some(next_row) = results_list.row_at_index(next_index) {
|
||||||
results_list.select_row(Some(&next_row));
|
results_list.select_row(Some(&next_row));
|
||||||
Self::scroll_to_row(&scrolled, &results_list, &next_row);
|
Self::scroll_to_row(&scrolled, &results_list, &next_row, &lazy_state_for_keys);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
gtk4::glib::Propagation::Stop
|
gtk4::glib::Propagation::Stop
|
||||||
@@ -931,7 +985,7 @@ impl MainWindow {
|
|||||||
&& let Some(prev_row) = results_list.row_at_index(prev_index)
|
&& let Some(prev_row) = results_list.row_at_index(prev_index)
|
||||||
{
|
{
|
||||||
results_list.select_row(Some(&prev_row));
|
results_list.select_row(Some(&prev_row));
|
||||||
Self::scroll_to_row(&scrolled, &results_list, &prev_row);
|
Self::scroll_to_row(&scrolled, &results_list, &prev_row, &lazy_state_for_keys);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
gtk4::glib::Propagation::Stop
|
gtk4::glib::Propagation::Stop
|
||||||
@@ -1183,6 +1237,51 @@ impl MainWindow {
|
|||||||
entry.emit_by_name::<()>("changed", &[]);
|
entry.emit_by_name::<()>("changed", &[]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Schedule initial results population via idle callback.
|
||||||
|
/// Call this AFTER `window.present()` so the window appears immediately.
|
||||||
|
pub fn schedule_initial_results(&self) {
|
||||||
|
let backend = self.backend.clone();
|
||||||
|
let results_list = self.results_list.clone();
|
||||||
|
let config = self.config.clone();
|
||||||
|
let filter = self.filter.clone();
|
||||||
|
let current_results = self.current_results.clone();
|
||||||
|
let lazy_state = self.lazy_state.clone();
|
||||||
|
|
||||||
|
gtk4::glib::idle_add_local_once(move || {
|
||||||
|
let cfg = config.borrow();
|
||||||
|
let max_results = cfg.general.max_results;
|
||||||
|
drop(cfg);
|
||||||
|
|
||||||
|
let results = backend.borrow_mut().search(
|
||||||
|
"",
|
||||||
|
max_results,
|
||||||
|
&filter.borrow(),
|
||||||
|
&config.borrow(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear existing results
|
||||||
|
while let Some(child) = results_list.first_child() {
|
||||||
|
results_list.remove(&child);
|
||||||
|
}
|
||||||
|
|
||||||
|
let initial_count = INITIAL_RESULTS.min(results.len());
|
||||||
|
|
||||||
|
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[..initial_count].to_vec();
|
||||||
|
let mut lazy = lazy_state.borrow_mut();
|
||||||
|
lazy.all_results = results;
|
||||||
|
lazy.displayed_count = initial_count;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn update_results(&self, query: &str) {
|
fn update_results(&self, query: &str) {
|
||||||
let cfg = self.config.borrow();
|
let cfg = self.config.borrow();
|
||||||
let max_results = cfg.general.max_results;
|
let max_results = cfg.general.max_results;
|
||||||
@@ -1200,15 +1299,9 @@ impl MainWindow {
|
|||||||
self.results_list.remove(&child);
|
self.results_list.remove(&child);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store all results for lazy loading
|
|
||||||
let initial_count = INITIAL_RESULTS.min(results.len());
|
|
||||||
{
|
|
||||||
let mut lazy = self.lazy_state.borrow_mut();
|
|
||||||
lazy.all_results = results.clone();
|
|
||||||
lazy.displayed_count = initial_count;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display initial batch only
|
// Display initial batch only
|
||||||
|
let initial_count = INITIAL_RESULTS.min(results.len());
|
||||||
|
|
||||||
for item in results.iter().take(initial_count) {
|
for item in results.iter().take(initial_count) {
|
||||||
let row = ResultRow::new(item);
|
let row = ResultRow::new(item);
|
||||||
self.results_list.append(&row);
|
self.results_list.append(&row);
|
||||||
@@ -1218,8 +1311,11 @@ impl MainWindow {
|
|||||||
self.results_list.select_row(Some(&first_row));
|
self.results_list.select_row(Some(&first_row));
|
||||||
}
|
}
|
||||||
|
|
||||||
// current_results holds what's currently displayed
|
// current_results holds what's currently displayed; store full vec for lazy loading
|
||||||
*self.current_results.borrow_mut() = results.into_iter().take(initial_count).collect();
|
*self.current_results.borrow_mut() = results[..initial_count].to_vec();
|
||||||
|
let mut lazy = self.lazy_state.borrow_mut();
|
||||||
|
lazy.all_results = results;
|
||||||
|
lazy.displayed_count = initial_count;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set up lazy loading scroll detection
|
/// Set up lazy loading scroll detection
|
||||||
|
|||||||
Reference in New Issue
Block a user