Compare commits

...

9 Commits

Author SHA1 Message Date
10a685c62f chore(owlry): bump version to 1.0.2 2026-03-28 09:16:40 +01:00
34db33c75f chore(owlry-core): bump version to 1.1.1 2026-03-28 09:16:38 +01:00
4bff83b5e6 perf(ui): eliminate redundant results.clone() in search handlers
The full results Vec was cloned into lazy_state.all_results and then
separately consumed for current_results. Now we slice for current_results
and move the original into lazy_state, avoiding one full Vec allocation
per query.
2026-03-28 09:14:11 +01:00
8f7501038d perf(ui): move search IPC off the GTK main thread
Search queries in daemon mode now run on a background thread via
DaemonHandle::query_async(). Results are posted back to the main
thread via glib::spawn_future_local + futures_channel::oneshot.
The GTK event loop is never blocked by IPC, eliminating perceived
input lag.

Local mode (dmenu) continues to use synchronous search since it
has no IPC overhead.
2026-03-28 09:12:20 +01:00
4032205800 perf(ui): defer initial query to after window.present()
update_results('') was called inside MainWindow::new(), blocking the
window from appearing until the daemon responded. Move it to a
glib::idle_add_local_once callback scheduled after present() so the
window renders immediately.
2026-03-28 08:51:33 +01:00
99985c7f3b perf(ui): use tracked count in scroll_to_row instead of child walk
scroll_to_row walked all GTK children via first_child/next_sibling
to count rows. The count is already available in LazyLoadState, so
use that directly. Eliminates O(n) widget traversal per arrow key.
2026-03-28 08:48:52 +01:00
6113217f7b perf(core): sample Utc::now() once per search instead of per-item
get_score() called Utc::now() inside calculate_frecency() for every
item in the search loop. Added get_score_at() that accepts a pre-sampled
timestamp. Eliminates hundreds of unnecessary clock_gettime syscalls
per keystroke.
2026-03-28 08:45:21 +01:00
558d415e12 perf(config): replace which subprocesses with in-process PATH scan
detect_terminal() was spawning up to 17 'which' subprocesses sequentially
on every startup. Replace with std::env::split_paths + is_file() check.
Eliminates 200-500ms of fork+exec overhead on cold cache.
2026-03-28 08:40:22 +01:00
6bde1504b1 chore: add .worktrees/ to gitignore 2026-03-28 08:35:51 +01:00
10 changed files with 409 additions and 125 deletions

1
.gitignore vendored
View File

@@ -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
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.

View File

@@ -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,26 +129,20 @@ 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
} else {
let modes: Vec<String> = filter
.enabled_providers()
.iter()
.map(|p| p.to_string())
.collect();
if modes.is_empty() { None } else { Some(modes) }
};
match client.query(query, modes_param) {
Ok(items) => items.into_iter().map(result_to_launch_item).collect(), Ok(items) => items.into_iter().map(result_to_launch_item).collect(),
Err(e) => { Err(e) => {
warn!("IPC query failed: {}", e); warn!("IPC query failed: {}", e);
Vec::new() Vec::new()
} }
},
Err(e) => {
warn!("Failed to lock daemon client: {}", e);
Vec::new()
}
} }
} }
SearchBackend::Local { SearchBackend::Local {
@@ -101,34 +186,26 @@ 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
} else {
let modes: Vec<String> = filter
.enabled_providers()
.iter()
.map(|p| p.to_string())
.collect();
if modes.is_empty() { None } else { Some(modes) }
};
match client.query(&effective_query, modes_param) {
Ok(items) => items.into_iter().map(result_to_launch_item).collect(), Ok(items) => items.into_iter().map(result_to_launch_item).collect(),
Err(e) => { Err(e) => {
warn!("IPC query failed: {}", e); warn!("IPC query failed: {}", e);
Vec::new() Vec::new()
} }
},
Err(e) => {
warn!("Failed to lock daemon client: {}", e);
Vec::new()
}
} }
} }
SearchBackend::Local { SearchBackend::Local {
@@ -162,16 +239,46 @@ 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(mut client) => match client.plugin_action(command) {
Ok(handled) => handled, Ok(handled) => handled,
Err(e) => { Err(e) => {
warn!("IPC plugin_action failed: {}", e); warn!("IPC plugin_action failed: {}", e);
false false
} }
}, },
Err(e) => {
warn!("Failed to lock daemon client: {}", e);
false
}
},
SearchBackend::Local { providers, .. } => providers.execute_plugin_action(command), SearchBackend::Local { providers, .. } => providers.execute_plugin_action(command),
} }
} }
@@ -185,7 +292,8 @@ 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(mut client) => match client.submenu(plugin_id, data) {
Ok(items) if !items.is_empty() => { Ok(items) if !items.is_empty() => {
let actions: Vec<LaunchItem> = let actions: Vec<LaunchItem> =
items.into_iter().map(result_to_launch_item).collect(); items.into_iter().map(result_to_launch_item).collect();
@@ -197,6 +305,11 @@ impl SearchBackend {
None None
} }
}, },
Err(e) => {
warn!("Failed to lock daemon client: {}", e);
None
}
},
SearchBackend::Local { providers, .. } => { SearchBackend::Local { providers, .. } => {
providers.query_submenu_actions(plugin_id, data, display_name) providers.query_submenu_actions(plugin_id, data, display_name)
} }
@@ -206,10 +319,14 @@ 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 Ok(mut client) = handle.client.lock() {
if let Err(e) = client.launch(item_id, provider) { if let Err(e) = client.launch(item_id, provider) {
warn!("IPC launch notification failed: {}", e); warn!("IPC launch notification failed: {}", e);
} }
} else {
warn!("Failed to lock daemon client for launch");
}
} }
SearchBackend::Local { frecency, .. } => { SearchBackend::Local { frecency, .. } => {
frecency.record_launch(item_id); frecency.record_launch(item_id);
@@ -236,13 +353,19 @@ 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(mut client) => match client.providers() {
Ok(descs) => descs.into_iter().map(|d| d.id).collect(), Ok(descs) => descs.into_iter().map(|d| d.id).collect(),
Err(e) => { Err(e) => {
warn!("IPC providers query failed: {}", e); warn!("IPC providers query failed: {}", e);
Vec::new() Vec::new()
} }
}, },
Err(e) => {
warn!("Failed to lock daemon client: {}", e);
Vec::new()
}
},
SearchBackend::Local { providers, .. } => providers SearchBackend::Local { providers, .. } => providers
.available_providers() .available_providers()
.into_iter() .into_iter()

View File

@@ -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,28 +688,77 @@ impl MainWindow {
let max_results = cfg.general.max_results; let max_results = cfg.general.max_results;
drop(cfg); drop(cfg);
// Try async path (daemon mode)
let receiver = {
let be = backend.borrow();
let f = filter.borrow();
let c = config.borrow();
be.query_async(
&query_str,
max_results,
&f,
&c,
tag.as_deref(),
)
};
if let Some(rx) = receiver {
// Daemon mode: results arrive asynchronously on the main loop.
// spawn_future_local runs the async block on the GTK main
// thread, so non-Send types (Rc, GTK widgets) are fine.
let results_list_cb = results_list.clone();
let current_results_cb = current_results.clone();
let lazy_state_cb = lazy_state.clone();
gtk4::glib::spawn_future_local(async move {
if let Ok(result) = rx.await {
// Discard stale results: the user has typed something new
// since this query was dispatched.
if search_entry_for_stale.text().as_str() != raw_text_at_dispatch {
return;
}
while let Some(child) = results_list_cb.first_child() {
results_list_cb.remove(&child);
}
let 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( let results = backend.borrow_mut().search_with_tag(
&parsed.query, &query_str,
max_results, max_results,
&filter.borrow(), &filter.borrow(),
&config.borrow(), &config.borrow(),
parsed.tag_filter.as_deref(), tag.as_deref(),
); );
// Clear existing results
while let Some(child) = results_list.first_child() { while let Some(child) = results_list.first_child() {
results_list.remove(&child); results_list.remove(&child);
} }
// Lazy loading: store all results but only display initial batch
let initial_count = INITIAL_RESULTS.min(results.len()); let initial_count = INITIAL_RESULTS.min(results.len());
{
let mut lazy = lazy_state.borrow_mut();
lazy.all_results = results.clone();
lazy.displayed_count = initial_count;
}
// Display only initial batch
for item in results.iter().take(initial_count) { for item in results.iter().take(initial_count) {
let row = ResultRow::new(item); let row = ResultRow::new(item);
results_list.append(&row); results_list.append(&row);
@@ -718,9 +768,12 @@ impl MainWindow {
results_list.select_row(Some(&first_row)); results_list.select_row(Some(&first_row));
} }
// current_results holds only what's displayed (for selection/activation)
*current_results.borrow_mut() = *current_results.borrow_mut() =
results.into_iter().take(initial_count).collect(); results[..initial_count].to_vec();
let mut lazy = lazy_state.borrow_mut();
lazy.all_results = results;
lazy.displayed_count = initial_count;
}
}, },
); );
@@ -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