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
CLAUDE.md
.worktrees/
media.md
# AUR packages (each is its own git repo for aur.archlinux.org)

5
Cargo.lock generated
View File

@@ -2536,12 +2536,13 @@ dependencies = [
[[package]]
name = "owlry"
version = "1.0.1"
version = "1.0.2"
dependencies = [
"chrono",
"clap",
"dirs",
"env_logger",
"futures-channel",
"glib-build-tools",
"gtk4",
"gtk4-layer-shell",
@@ -2556,7 +2557,7 @@ dependencies = [
[[package]]
name = "owlry-core"
version = "1.1.0"
version = "1.1.1"
dependencies = [
"chrono",
"ctrlc",

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-core"
version = "1.1.0"
version = "1.1.1"
edition.workspace = true
rust-version.workspace = true
license.workspace = true

View File

@@ -2,7 +2,6 @@ use log::{debug, info, warn};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Command;
use crate::paths;
@@ -522,12 +521,15 @@ fn detect_de_terminal() -> Option<String> {
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 {
Command::new("which")
.arg(cmd)
.output()
.map(|o| o.status.success())
std::env::var_os("PATH")
.map(|paths| {
std::env::split_paths(&paths).any(|dir| {
let full = dir.join(cmd);
full.is_file()
})
})
.unwrap_or(false)
}
@@ -591,3 +593,17 @@ impl Config {
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
fn calculate_frecency(launch_count: u32, last_launch: DateTime<Utc>) -> f64 {
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_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 {
100.0 // Today
100.0
} else if age_days < 7.0 {
70.0 // This week
70.0
} else if age_days < 30.0 {
50.0 // This month
50.0
} else if age_days < 90.0 {
30.0 // This quarter
30.0
} else {
10.0 // Older
10.0
};
launch_count as f64 * recency_weight
@@ -206,6 +219,32 @@ mod tests {
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]
fn test_launch_count_matters() {
let now = Utc::now();

View File

@@ -16,6 +16,7 @@ pub use command::CommandProvider;
// Re-export native provider for plugin loading
pub use native_provider::NativeProvider;
use chrono::Utc;
use fuzzy_matcher::FuzzyMatcher;
use fuzzy_matcher::skim::SkimMatcherV2;
use log::info;
@@ -570,6 +571,7 @@ impl ProviderManager {
query, max_results, frecency_weight
);
let now = Utc::now();
let mut results: Vec<(LaunchItem, i64)> = Vec::new();
// Add widget items first (highest priority) - only when:
@@ -633,7 +635,7 @@ impl ProviderManager {
}
})
.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;
(item, boosted)
})
@@ -682,7 +684,7 @@ impl ProviderManager {
};
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;
(item.clone(), s + frecency_boost)
})

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry"
version = "1.0.1"
version = "1.0.2"
edition = "2024"
rust-version = "1.90"
description = "A lightweight, owl-themed application launcher for Wayland"
@@ -46,6 +46,9 @@ dirs = "5"
# Semantic versioning (needed by plugin commands)
semver = "1"
# Async oneshot channel (background thread -> main loop)
futures-channel = "0.3"
[build-dependencies]
# GResource compilation for bundled icons
glib-build-tools = "0.20"

View File

@@ -69,7 +69,7 @@ impl OwlryApp {
match CoreClient::connect_or_start() {
Ok(client) => {
info!("Connected to owlry-core daemon");
SearchBackend::Daemon(client)
SearchBackend::Daemon(crate::backend::DaemonHandle::new(client))
}
Err(e) => {
warn!(
@@ -135,6 +135,9 @@ impl OwlryApp {
Self::load_css(&config.borrow());
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.

View File

@@ -10,12 +10,87 @@ use owlry_core::data::FrecencyStore;
use owlry_core::filter::ProviderFilter;
use owlry_core::ipc::ResultItem;
use owlry_core::providers::{LaunchItem, ProviderManager, ProviderType};
use std::sync::{Arc, Mutex};
/// Parameters needed to run a search query on a background thread.
pub struct QueryParams {
pub query: String,
#[allow(dead_code)]
pub max_results: usize,
pub modes: Option<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)
/// or a local ProviderManager (dmenu mode).
pub enum SearchBackend {
/// IPC client connected to owlry-core daemon
Daemon(CoreClient),
Daemon(DaemonHandle),
/// Direct local provider manager (dmenu mode only)
Local {
providers: Box<ProviderManager>,
@@ -24,6 +99,22 @@ pub enum SearchBackend {
}
impl SearchBackend {
/// Build the modes parameter from a ProviderFilter.
/// When accept_all, returns None so the daemon doesn't restrict to a specific set
/// (otherwise dynamically loaded plugin types would be filtered out).
fn build_modes_param(filter: &ProviderFilter) -> Option<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.
///
/// In daemon mode, sends query over IPC. The modes list is derived from
@@ -38,24 +129,18 @@ impl SearchBackend {
config: &Config,
) -> Vec<LaunchItem> {
match self {
SearchBackend::Daemon(client) => {
// When accept_all, send None so daemon doesn't restrict to a specific set
// (otherwise dynamically loaded plugin types would be filtered out)
let modes_param = if filter.is_accept_all() {
None
} else {
let modes: Vec<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(),
SearchBackend::Daemon(handle) => {
let modes_param = Self::build_modes_param(filter);
match handle.client.lock() {
Ok(mut client) => match client.query(query, modes_param) {
Ok(items) => items.into_iter().map(result_to_launch_item).collect(),
Err(e) => {
warn!("IPC query failed: {}", e);
Vec::new()
}
},
Err(e) => {
warn!("IPC query failed: {}", e);
warn!("Failed to lock daemon client: {}", e);
Vec::new()
}
}
@@ -101,32 +186,24 @@ impl SearchBackend {
tag_filter: Option<&str>,
) -> Vec<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.
SearchBackend::Daemon(handle) => {
let effective_query = if let Some(tag) = tag_filter {
format!(":tag:{} {}", tag, query)
} else {
query.to_string()
};
// When accept_all, send None so daemon doesn't restrict to a specific set
// (otherwise dynamically loaded plugin types would be filtered out)
let modes_param = if filter.is_accept_all() {
None
} else {
let modes: Vec<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(),
let modes_param = Self::build_modes_param(filter);
match handle.client.lock() {
Ok(mut client) => match client.query(&effective_query, modes_param) {
Ok(items) => items.into_iter().map(result_to_launch_item).collect(),
Err(e) => {
warn!("IPC query failed: {}", e);
Vec::new()
}
},
Err(e) => {
warn!("IPC query failed: {}", e);
warn!("Failed to lock daemon client: {}", e);
Vec::new()
}
}
@@ -162,13 +239,43 @@ impl SearchBackend {
}
}
/// Dispatch async search (daemon mode only).
/// Returns `Some(Receiver)` if dispatched, `None` for local mode.
pub fn query_async(
&self,
query: &str,
max_results: usize,
filter: &ProviderFilter,
_config: &Config,
tag_filter: Option<&str>,
) -> Option<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.
pub fn execute_plugin_action(&mut self, command: &str) -> bool {
match self {
SearchBackend::Daemon(client) => match client.plugin_action(command) {
Ok(handled) => handled,
SearchBackend::Daemon(handle) => match handle.client.lock() {
Ok(mut client) => match client.plugin_action(command) {
Ok(handled) => handled,
Err(e) => {
warn!("IPC plugin_action failed: {}", e);
false
}
},
Err(e) => {
warn!("IPC plugin_action failed: {}", e);
warn!("Failed to lock daemon client: {}", e);
false
}
},
@@ -185,15 +292,21 @@ impl SearchBackend {
display_name: &str,
) -> Option<(String, Vec<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,
SearchBackend::Daemon(handle) => match handle.client.lock() {
Ok(mut client) => match client.submenu(plugin_id, data) {
Ok(items) if !items.is_empty() => {
let actions: Vec<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
}
},
Err(e) => {
warn!("IPC submenu query failed: {}", e);
warn!("Failed to lock daemon client: {}", e);
None
}
},
@@ -206,9 +319,13 @@ impl SearchBackend {
/// Record a launch event for frecency tracking.
pub fn record_launch(&mut self, item_id: &str, provider: &str) {
match self {
SearchBackend::Daemon(client) => {
if let Err(e) = client.launch(item_id, provider) {
warn!("IPC launch notification failed: {}", e);
SearchBackend::Daemon(handle) => {
if let Ok(mut client) = handle.client.lock() {
if let Err(e) = client.launch(item_id, provider) {
warn!("IPC launch notification failed: {}", e);
}
} else {
warn!("Failed to lock daemon client for launch");
}
}
SearchBackend::Local { frecency, .. } => {
@@ -236,10 +353,16 @@ impl SearchBackend {
#[allow(dead_code)]
pub fn available_provider_ids(&mut self) -> Vec<String> {
match self {
SearchBackend::Daemon(client) => match client.providers() {
Ok(descs) => descs.into_iter().map(|d| d.id).collect(),
SearchBackend::Daemon(handle) => match handle.client.lock() {
Ok(mut client) => match client.providers() {
Ok(descs) => descs.into_iter().map(|d| d.id).collect(),
Err(e) => {
warn!("IPC providers query failed: {}", e);
Vec::new()
}
},
Err(e) => {
warn!("IPC providers query failed: {}", e);
warn!("Failed to lock daemon client: {}", e);
Vec::new()
}
},

View File

@@ -224,7 +224,6 @@ impl MainWindow {
main_window.setup_signals();
main_window.setup_lazy_loading();
main_window.update_results("");
// Ensure search entry has focus when window is shown
main_window.search_entry.grab_focus();
@@ -458,7 +457,12 @@ impl MainWindow {
}
/// 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 row_index = row.index();
@@ -470,15 +474,7 @@ impl MainWindow {
let current_scroll = vadj.value();
let list_height = results_list.height() as f64;
let row_count = {
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_count = lazy_state.borrow().displayed_count.max(1) as f64;
let row_height = list_height / row_count;
let row_top = row_index as f64 * row_height;
@@ -675,6 +671,11 @@ impl MainWindow {
let filter = filter.clone();
let lazy_state = lazy_state.clone();
let debounce_source_for_closure = debounce_source.clone();
let query_str = parsed.query.clone();
let tag = parsed.tag_filter.clone();
// Capture the raw entry text at dispatch time for staleness detection.
let raw_text_at_dispatch = entry.text().to_string();
let search_entry_for_stale = search_entry_for_change.clone();
// Schedule debounced search
let source_id = gtk4::glib::timeout_add_local_once(
@@ -687,40 +688,92 @@ impl MainWindow {
let max_results = cfg.general.max_results;
drop(cfg);
let results = backend.borrow_mut().search_with_tag(
&parsed.query,
max_results,
&filter.borrow(),
&config.borrow(),
parsed.tag_filter.as_deref(),
);
// Try async path (daemon mode)
let receiver = {
let be = backend.borrow();
let f = filter.borrow();
let c = config.borrow();
be.query_async(
&query_str,
max_results,
&f,
&c,
tag.as_deref(),
)
};
// Clear existing results
while let Some(child) = results_list.first_child() {
results_list.remove(&child);
}
if let Some(rx) = receiver {
// Daemon mode: results arrive asynchronously on the main loop.
// spawn_future_local runs the async block on the GTK main
// thread, so non-Send types (Rc, GTK widgets) are fine.
let results_list_cb = results_list.clone();
let current_results_cb = current_results.clone();
let lazy_state_cb = lazy_state.clone();
// Lazy loading: store all results but only display initial batch
let initial_count = INITIAL_RESULTS.min(results.len());
{
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(
&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();
lazy.all_results = results.clone();
lazy.all_results = results;
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 tab_order = self.tab_order.clone();
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| {
let ctrl = modifiers.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
@@ -919,7 +973,7 @@ impl MainWindow {
let next_index = current.index() + 1;
if let Some(next_row) = results_list.row_at_index(next_index) {
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
@@ -931,7 +985,7 @@ impl MainWindow {
&& let Some(prev_row) = results_list.row_at_index(prev_index)
{
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
@@ -1183,6 +1237,51 @@ impl MainWindow {
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) {
let cfg = self.config.borrow();
let max_results = cfg.general.max_results;
@@ -1200,15 +1299,9 @@ impl MainWindow {
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
let initial_count = INITIAL_RESULTS.min(results.len());
for item in results.iter().take(initial_count) {
let row = ResultRow::new(item);
self.results_list.append(&row);
@@ -1218,8 +1311,11 @@ impl MainWindow {
self.results_list.select_row(Some(&first_row));
}
// current_results holds what's currently displayed
*self.current_results.borrow_mut() = results.into_iter().take(initial_count).collect();
// current_results holds what's currently displayed; store full vec for lazy loading
*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