diff --git a/Cargo.lock b/Cargo.lock index 3bb5a58..adeb425 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -835,6 +835,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -2398,6 +2407,7 @@ dependencies = [ "derive_deref", "directories", "futures", + "fuzzy-matcher", "human-panic", "ignore", "json5", @@ -2405,6 +2415,7 @@ dependencies = [ "libc", "nucleo", "nucleo-matcher", + "parking_lot", "pretty_assertions", "ratatui", "rust-devicons", diff --git a/Cargo.toml b/Cargo.toml index a49c0c8..4b11eb0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ crossterm = { version = "0.28.1", features = ["serde", "event-stream"] } derive_deref = "1.1.1" directories = "5.0.1" futures = "0.3.30" +fuzzy-matcher = "0.3.7" human-panic = "2.0.1" ignore = "0.4.23" json5 = "0.4.1" @@ -41,6 +42,7 @@ lazy_static = "1.5.0" libc = "0.2.158" nucleo = "0.5.0" nucleo-matcher = "0.3.1" +parking_lot = "0.12.3" pretty_assertions = "1.4.0" ratatui = { version = "0.28.1", features = ["serde", "macros"] } rust-devicons = "0.2.2" diff --git a/TODO.md b/TODO.md index 0b4f6ca..f60c0f7 100644 --- a/TODO.md +++ b/TODO.md @@ -4,7 +4,7 @@ - [ ] maybe filter out image types etc. for now ## bugs -- [ ] sanitize input (tabs, \0, etc) +- [ ] sanitize input (tabs, \0, etc) (see https://github.com/autobib/nucleo-picker/blob/d51dec9efd523e88842c6eda87a19c0a492f4f36/src/lib.rs#L212-L227) ## improvements - [ ] async finder initialization diff --git a/src/app.rs b/src/app.rs index d24388e..2e5ee11 100644 --- a/src/app.rs +++ b/src/app.rs @@ -47,11 +47,11 @@ /// │ └────────────────────────┘ └────────────────────┘ │ /// │ │ /// └──────────────────────────────────────────────────────────────────────────────────────────────────────┘ -use std::sync::{Arc, Mutex}; +use std::sync::Arc; use color_eyre::Result; use serde::{Deserialize, Serialize}; -use tokio::sync::mpsc; +use tokio::sync::{mpsc, Mutex}; use tracing::{debug, info}; use crate::{ @@ -144,7 +144,7 @@ impl App { loop { // handle event and convert to action if let Some(event) = self.event_rx.recv().await { - let action = self.convert_event_to_action(event); + let action = self.convert_event_to_action(event).await; action_tx.send(action)?; } @@ -158,13 +158,13 @@ impl App { Ok(()) } - fn convert_event_to_action(&self, event: Event) -> Action { + async fn convert_event_to_action(&self, event: Event) -> Action { match event { Event::Input(keycode) => { info!("{:?}", keycode); // if the current component is the television // and the mode is input, we want to handle - if self.television.lock().unwrap().is_input_focused() { + if self.television.lock().await.is_input_focused() { match keycode { Key::Backspace => return Action::DeletePrevChar, Key::Delete => return Action::DeleteNextChar, @@ -219,13 +219,7 @@ impl App { Action::Render => self.render_tx.send(RenderingTask::Render)?, _ => {} } - if let Some(action) = self - .television - .lock() - .unwrap() - .update(action.clone()) - .await? - { + if let Some(action) = self.television.lock().await.update(action.clone()).await? { self.action_tx.send(action)? }; } diff --git a/src/components.rs b/src/components.rs index 3dc3e62..5ddce9b 100644 --- a/src/components.rs +++ b/src/components.rs @@ -9,8 +9,8 @@ use crate::{action::Action, config::Config}; pub mod channels; mod finders; +mod fuzzy; mod input; -mod pickers; mod previewers; pub mod television; mod utils; diff --git a/src/components/channels.rs b/src/components/channels.rs index a54a322..dd1f0f4 100644 --- a/src/components/channels.rs +++ b/src/components/channels.rs @@ -1,61 +1,76 @@ use color_eyre::Result; use tokio::sync::mpsc::UnboundedSender; +use tracing::info; use crate::action::Action; use crate::cli::UnitTvChannel; use crate::components::finders::Entry; -use crate::components::pickers::{self, Picker}; use crate::components::previewers; +use crate::components::{ + finders::{EnvVarFinder, FileFinder}, + previewers::{EnvVarPreviewer, FilePreviewer}, +}; + +use super::finders::Finder; pub enum TvChannel { - Env(pickers::env::EnvVarPicker), - Files(pickers::files::FilePicker), + Env(EnvChannel), + Files(FilesChannel), } impl TvChannel { - pub fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { + pub fn find(&mut self, pattern: &str) { match self { - TvChannel::Files(picker) => picker.register_action_handler(tx), - _ => Ok(()), + TvChannel::Env(channel) => channel.finder.find(pattern), + TvChannel::Files(channel) => channel.finder.find(pattern), } } - pub async fn load_entries(&mut self, pattern: &str) -> Result<()> { + pub fn results(&mut self, num_entries: u32, offset: u32) -> Vec { match self { - TvChannel::Env(picker) => picker.load_entries(pattern).await, - TvChannel::Files(picker) => picker.load_entries(pattern).await, + TvChannel::Env(channel) => channel.finder.results(num_entries, offset).collect(), + TvChannel::Files(channel) => channel.finder.results(num_entries, offset).collect(), } } - pub fn entries(&self) -> &Vec { + pub fn get_result(&self, index: u32) -> Option { match self { - TvChannel::Env(picker) => picker.entries(), - TvChannel::Files(picker) => picker.entries(), + TvChannel::Env(channel) => channel.finder.get_result(index), + TvChannel::Files(channel) => channel.finder.get_result(index), } } - pub fn clear(&mut self) { + pub fn result_count(&self) -> u32 { match self { - TvChannel::Env(picker) => picker.clear(), - TvChannel::Files(picker) => picker.clear(), + TvChannel::Env(channel) => channel.finder.result_count(), + TvChannel::Files(channel) => channel.finder.result_count(), } } - pub fn get_preview(&mut self, entry: &Entry) -> previewers::Preview { - if entry.name.is_empty() { - return previewers::Preview::default(); - } + pub fn total_count(&self) -> u32 { match self { - TvChannel::Env(picker) => picker.get_preview(entry), - TvChannel::Files(picker) => picker.get_preview(entry), + TvChannel::Env(channel) => channel.finder.total_count(), + TvChannel::Files(channel) => channel.finder.total_count(), } } } -pub async fn get_tv_channel(channel: UnitTvChannel) -> TvChannel { +pub fn get_tv_channel(channel: UnitTvChannel) -> TvChannel { match channel { - UnitTvChannel::ENV => TvChannel::Env(pickers::env::EnvVarPicker::new().await), - UnitTvChannel::FILES => TvChannel::Files(pickers::files::FilePicker::new().await), + UnitTvChannel::ENV => TvChannel::Env(EnvChannel { + finder: EnvVarFinder::new(), + }), + UnitTvChannel::FILES => TvChannel::Files(FilesChannel { + finder: FileFinder::new(), + }), _ => unimplemented!(), } } + +pub struct EnvChannel { + pub finder: EnvVarFinder, +} + +pub struct FilesChannel { + pub finder: FileFinder, +} diff --git a/src/components/finders.rs b/src/components/finders.rs index 0e945ca..032cecb 100644 --- a/src/components/finders.rs +++ b/src/components/finders.rs @@ -12,9 +12,8 @@ pub struct Entry { pub name: String, display_name: Option, pub preview: Option, - pub score: i64, - pub name_match_ranges: Option>, - pub preview_match_ranges: Option>, + pub name_match_ranges: Option>, + pub preview_match_ranges: Option>, pub icon: Option, pub line_number: Option, } @@ -29,7 +28,6 @@ pub const ENTRY_PLACEHOLDER: Entry = Entry { name: String::new(), display_name: None, preview: None, - score: 0, name_match_ranges: None, preview_match_ranges: None, icon: None, @@ -42,5 +40,13 @@ pub const ENTRY_PLACEHOLDER: Entry = Entry { /// # Methods /// - `find`: Find entries based on a pattern. pub trait Finder { - async fn find(&mut self, pattern: &str) -> impl Iterator; + fn find(&mut self, pattern: &str); + + fn results(&mut self, num_entries: u32, offset: u32) -> impl Iterator; + + fn get_result(&self, index: u32) -> Option; + + fn result_count(&self) -> u32; + + fn total_count(&self) -> u32; } diff --git a/src/components/finders/env.rs b/src/components/finders/env.rs index 69f947d..b0df711 100644 --- a/src/components/finders/env.rs +++ b/src/components/finders/env.rs @@ -1,8 +1,8 @@ -use std::{cmp::max, collections::HashMap, env::vars, path::Path}; +use std::{collections::HashMap, env::vars, path::Path}; -use futures::{stream, Stream}; use fuzzy_matcher::skim::SkimMatcherV2; use rust_devicons::{icon_for_file, File, FileIcon}; +use tracing::info; use crate::components::finders::{Entry, Finder}; @@ -16,6 +16,9 @@ pub struct EnvVarFinder { matcher: SkimMatcherV2, file_icon: FileIcon, cache: HashMap>, + result_count: u32, + total_count: u32, + pub entries: Vec, } impl EnvVarFinder { @@ -25,17 +28,22 @@ impl EnvVarFinder { env_vars.push(EnvVar { name, value }); } let file_icon = icon_for_file(&File::new(&Path::new("config")), None); + let total_count = env_vars.len() as u32; EnvVarFinder { env_vars, matcher: SkimMatcherV2::default(), file_icon, cache: HashMap::new(), + result_count: 0, + total_count, + entries: Vec::new(), } } } impl Finder for EnvVarFinder { - async fn find(&mut self, pattern: &str) -> impl Iterator { + // TDOO: replace this by a nucleo matcher + fn find(&mut self, pattern: &str) { let mut results: Vec = Vec::new(); // try to get from cache if let Some(entries) = self.cache.get(pattern) { @@ -47,27 +55,23 @@ impl Finder for EnvVarFinder { name: env_var.name.clone(), display_name: None, preview: Some(env_var.value.clone()), - score: 0, name_match_ranges: None, preview_match_ranges: None, icon: Some(self.file_icon.clone()), line_number: None, }); } else { - let mut final_score = 0; - let preview_match_ranges = if let Some((score, indices)) = + let preview_match_ranges = if let Some((_, indices)) = self.matcher.fuzzy(&env_var.value, pattern, true) { - final_score = max(final_score, score); - Some(indices.iter().map(|i| (*i, *i + 1)).collect()) + Some(indices.iter().map(|i| (*i as u32, *i as u32 + 1)).collect()) } else { None }; - let name_match_ranges = if let Some((score, indices)) = + let name_match_ranges = if let Some((_, indices)) = self.matcher.fuzzy(&env_var.name, pattern, true) { - final_score = max(final_score, score); - Some(indices.iter().map(|i| (*i, *i + 1)).collect()) + Some(indices.iter().map(|i| (*i as u32, *i as u32 + 1)).collect()) } else { None }; @@ -76,7 +80,6 @@ impl Finder for EnvVarFinder { name: env_var.name.clone(), display_name: None, preview: Some(env_var.value.clone()), - score: final_score, name_match_ranges, preview_match_ranges, icon: Some(self.file_icon.clone()), @@ -88,6 +91,27 @@ impl Finder for EnvVarFinder { // cache the results self.cache.insert(pattern.to_string(), results.clone()); } - results.into_iter() + self.result_count = results.len() as u32; + self.entries = results; + } + + fn results(&mut self, num_entries: u32, offset: u32) -> impl Iterator { + self.entries + .iter() + .skip(offset as usize) + .take(num_entries as usize) + .cloned() + } + + fn get_result(&self, index: u32) -> Option { + self.entries.get(index as usize).cloned() + } + + fn result_count(&self) -> u32 { + self.result_count + } + + fn total_count(&self) -> u32 { + self.total_count } } diff --git a/src/components/finders/files.rs b/src/components/finders/files.rs index fd92a04..c335cd2 100644 --- a/src/components/finders/files.rs +++ b/src/components/finders/files.rs @@ -1,9 +1,7 @@ -use color_eyre::Result; use nucleo::{ pattern::{CaseMatching, Normalization}, - Config, Injector, Item, Nucleo, Utf32String, + Config, Injector, Nucleo, }; -use nucleo_matcher::{Config as LowConfig, Matcher}; use std::{ collections::HashMap, path::{Path, PathBuf}, @@ -11,22 +9,24 @@ use std::{ }; use ignore::{types::TypesBuilder, DirEntry, WalkBuilder}; -use tokio::sync::mpsc::{self, UnboundedSender}; use tracing::info; use crate::{ - action::Action, - components::finders::{Entry, Finder}, + components::{ + finders::{Entry, Finder}, + fuzzy::MATCHER, + }, config::default_num_threads, }; pub struct FileFinder { current_directory: PathBuf, files: Vec, - high_matcher: Nucleo, - low_matcher: Matcher, - cache: HashMap>, + matcher: Nucleo, + //cache: HashMap>, last_pattern: String, + result_count: u32, + total_count: u32, } struct MatchItem { @@ -34,9 +34,8 @@ struct MatchItem { } impl FileFinder { - pub async fn new() -> Self { + pub fn new() -> Self { let high_matcher = Nucleo::new(Config::DEFAULT.match_paths(), Arc::new(|| {}), None, 1); - let low_matcher = Matcher::new(LowConfig::DEFAULT.match_paths()); // start loading files in the background tokio::spawn(load_files( std::env::current_dir().unwrap(), @@ -45,23 +44,26 @@ impl FileFinder { FileFinder { current_directory: std::env::current_dir().unwrap(), files: Vec::new(), - high_matcher, - low_matcher, - cache: HashMap::new(), + matcher: high_matcher, + //cache: HashMap::new(), last_pattern: String::new(), + result_count: 0, + total_count: 0, } } + + const MATCHER_TICK_TIMEOUT: u64 = 10; } impl Finder for FileFinder { - async fn find(&mut self, pattern: &str) -> impl Iterator { + fn find(&mut self, pattern: &str) { if pattern != self.last_pattern { - self.high_matcher.pattern.reparse( + self.matcher.pattern.reparse( 0, pattern, CaseMatching::Smart, Normalization::Smart, - if pattern.len() > self.last_pattern.len() { + if pattern.starts_with(&self.last_pattern) { true } else { false @@ -69,20 +71,66 @@ impl Finder for FileFinder { ); self.last_pattern = pattern.to_string(); } - let snapshot = self.high_matcher.snapshot(); + } + + fn result_count(&self) -> u32 { + self.result_count + } + + fn total_count(&self) -> u32 { + self.total_count + } + + fn results(&mut self, num_entries: u32, offset: u32) -> impl Iterator { + let status = self.matcher.tick(Self::MATCHER_TICK_TIMEOUT); + let snapshot = self.matcher.snapshot(); + if status.changed { + self.result_count = snapshot.matched_item_count(); + self.total_count = snapshot.item_count(); + } + let mut indices = Vec::new(); + let mut matcher = MATCHER.lock(); + snapshot - .matched_items(..snapshot.matched_item_count()) - .map(|item| { - // rematch with indices - let mut indices: Vec = Vec::new(); - let haystack = item.matcher_columns[0]; - self.low_matcher.fuzzy_indices( - haystack.slice(..), - Utf32String::from(pattern).slice(..), + .matched_items(offset..(num_entries + offset).min(snapshot.matched_item_count())) + .map(move |item| { + snapshot.pattern().column_pattern(0).indices( + item.matcher_columns[0].slice(..), + &mut matcher, &mut indices, - ) + ); + indices.sort_unstable(); + indices.dedup(); + let indices = indices.drain(..); + + let path = item.matcher_columns[0].to_string(); + Entry { + name: path.clone(), + display_name: None, + preview: None, + name_match_ranges: Some(indices.map(|i| (i, i + 1)).collect()), + preview_match_ranges: None, + icon: None, + line_number: None, + } }) } + + fn get_result(&self, index: u32) -> Option { + let snapshot = self.matcher.snapshot(); + snapshot.get_item(index).and_then(|item| { + let path = item.matcher_columns[0].to_string(); + Some(Entry { + name: path.clone(), + display_name: None, + preview: None, + name_match_ranges: None, + preview_match_ranges: None, + icon: None, + line_number: None, + }) + }) + } } lazy_static::lazy_static! { @@ -102,18 +150,25 @@ fn walk_builder(path: &Path, n_threads: usize) -> WalkBuilder { } async fn load_files(path: PathBuf, injector: Injector) { + let current_dir = std::env::current_dir().unwrap(); let walker = WalkBuilder::new(path) .threads(*DEFAULT_NUM_THREADS) .build_parallel(); walker.run(|| { let injector = injector.clone(); + let current_dir = current_dir.clone(); Box::new(move |result| { if let Ok(entry) = result { if entry.file_type().unwrap().is_file() { // Send the path via the async channel let _ = injector.push(entry, |e, cols| { - cols[0] = e.path().to_string_lossy().into(); + cols[0] = e + .path() + .strip_prefix(¤t_dir) + .unwrap() + .to_string_lossy() + .into(); }); } } diff --git a/src/components/fuzzy.rs b/src/components/fuzzy.rs new file mode 100644 index 0000000..c3a9672 --- /dev/null +++ b/src/components/fuzzy.rs @@ -0,0 +1,22 @@ +use parking_lot::Mutex; +use std::ops::DerefMut; + +pub struct LazyMutex { + inner: Mutex>, + init: fn() -> T, +} + +impl LazyMutex { + pub const fn new(init: fn() -> T) -> Self { + Self { + inner: Mutex::new(None), + init, + } + } + + pub fn lock(&self) -> impl DerefMut + '_ { + parking_lot::MutexGuard::map(self.inner.lock(), |val| val.get_or_insert_with(self.init)) + } +} + +pub static MATCHER: LazyMutex = LazyMutex::new(nucleo::Matcher::default); diff --git a/src/components/pickers.rs b/src/components/pickers.rs deleted file mode 100644 index c744677..0000000 --- a/src/components/pickers.rs +++ /dev/null @@ -1,37 +0,0 @@ -use color_eyre::Result; - -use crate::components::finders::{self, Entry}; -use crate::components::previewers; - -pub mod env; -pub mod files; - -/// A trait for a picker that can load entries, select an entry, get the selected entry, get all -/// entries, clear the entries, and get a preview of the selected entry. -/// -/// # Type Parameters -/// - `F`: The type of the finder used to find entries. -/// - `R`: The type of the entry. -/// - `P`: The type of the previewer used to preview entries. -/// -/// # Methods -/// - `load_entries`: Load entries based on a pattern. -/// - `select_entry`: Select an entry based on an index. -/// - `selected_entry`: Get the selected entry. -/// - `entries`: Get all entries. -/// - `clear`: Clear all entries. -/// - `get_preview`: Get a preview of the currently selected entry. -pub trait Picker { - type F: finders::Finder; - type P: previewers::Previewer; - - /// NOTE: this method could eventually be async - /// Load entries based on a pattern. - async fn load_entries(&mut self, pattern: &str) -> Result<()>; - /// Get all entries. - fn entries(&self) -> &Vec; - /// Clear all entries. - fn clear(&mut self); - /// Get a preview of the currently selected entry. - fn get_preview(&mut self, entry: &Entry) -> previewers::Preview; -} diff --git a/src/components/pickers/env.rs b/src/components/pickers/env.rs deleted file mode 100644 index 2a7d252..0000000 --- a/src/components/pickers/env.rs +++ /dev/null @@ -1,47 +0,0 @@ -use color_eyre::Result; -use tokio_stream::StreamExt; - -use crate::components::finders::{self, Finder}; -use crate::components::pickers::Picker; -use crate::components::previewers::{self, Previewer}; - -pub struct EnvVarPicker { - finder: finders::EnvVarFinder, - entries: Vec, - previewer: previewers::EnvVarPreviewer, -} - -impl EnvVarPicker { - pub async fn new() -> Self { - let mut finder = finders::EnvVarFinder::new(); - let entries = finder.find("").await.collect().await; - EnvVarPicker { - finder, - entries, - previewer: previewers::EnvVarPreviewer::new(), - } - } -} - -impl Picker for EnvVarPicker { - type F = finders::EnvVarFinder; - type P = previewers::EnvVarPreviewer; - - async fn load_entries(&mut self, pattern: &str) -> Result<()> { - self.entries = self.finder.find(pattern).await.collect().await; - self.entries.sort_by_key(|e| -e.score); - Ok(()) - } - - fn entries(&self) -> &Vec { - &self.entries - } - - fn clear(&mut self) { - self.entries.clear(); - } - - fn get_preview(&mut self, entry: &finders::Entry) -> previewers::Preview { - self.previewer.preview(entry) - } -} diff --git a/src/components/pickers/files.rs b/src/components/pickers/files.rs deleted file mode 100644 index 51cb2a6..0000000 --- a/src/components/pickers/files.rs +++ /dev/null @@ -1,52 +0,0 @@ -use color_eyre::Result; -use tokio::sync::mpsc::UnboundedSender; -use tokio_stream::StreamExt; - -use crate::action::Action; -use crate::components::finders::{self, Finder}; -use crate::components::pickers::Picker; -use crate::components::previewers::{self, Previewer}; - -pub struct FilePicker { - finder: finders::FileFinder, - entries: Vec, - previewer: previewers::FilePreviewer, -} - -impl FilePicker { - pub async fn new() -> Self { - FilePicker { - finder: finders::FileFinder::new().await, - entries: Vec::new(), - previewer: previewers::FilePreviewer::new(), - } - } - - pub fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { - self.finder.register_action_handler(tx) - } -} - -impl Picker for FilePicker { - type F = finders::FileFinder; - type P = previewers::FilePreviewer; - - async fn load_entries(&mut self, pattern: &str) -> Result<()> { - // this is probably not the best way to do this - self.entries = self.finder.find(pattern).await.collect().await; - self.entries.sort_by_key(|e| -e.score); - Ok(()) - } - - fn entries(&self) -> &Vec { - &self.entries - } - - fn clear(&mut self) { - self.entries.clear(); - } - - fn get_preview(&mut self, entry: &finders::Entry) -> previewers::Preview { - self.previewer.preview(entry) - } -} diff --git a/src/components/television.rs b/src/components/television.rs index 9288120..59e4ba1 100644 --- a/src/components/television.rs +++ b/src/components/television.rs @@ -12,12 +12,12 @@ use ratatui::{ use std::str::FromStr; use syntect; use syntect::highlighting::Color as SyntectColor; +use tracing::info; use tokio::sync::mpsc::UnboundedSender; use crate::components::{ channels::{get_tv_channel, TvChannel}, - finders::ENTRY_PLACEHOLDER, input::{Input, InputRequest, StateChanged}, previewers::{Preview, PreviewContent}, utils::centered_rect, @@ -35,11 +35,6 @@ enum Pane { static PANES: [Pane; 3] = [Pane::Input, Pane::Results, Pane::Preview]; -struct ResultsList { - results: Vec, - state: ListState, -} - pub struct Television { action_tx: Option>, config: Config, @@ -47,15 +42,19 @@ pub struct Television { current_pattern: String, current_pane: Pane, input: Input, - results_list: ResultsList, + picker_state: ListState, + relative_picker_state: ListState, + picker_view_offset: usize, + results_area_height: u32, + last_selected_entry: Option, } const EMPTY_STRING: &str = ""; impl Television { pub async fn new(channel: UnitTvChannel) -> Self { - let tv_channel = get_tv_channel(channel).await; - let results = tv_channel.entries().clone(); + let mut tv_channel = get_tv_channel(channel); + tv_channel.find(EMPTY_STRING); Self { action_tx: None, config: Config::default(), @@ -63,47 +62,76 @@ impl Television { current_pattern: EMPTY_STRING.to_string(), current_pane: Pane::Input, input: Input::new(EMPTY_STRING.to_string()), - results_list: ResultsList { - results, - state: ListState::default(), - }, + picker_state: ListState::default(), + relative_picker_state: ListState::default(), + picker_view_offset: 0, + results_area_height: 0, + last_selected_entry: None, } } fn find(&mut self, pattern: &str) { - self.channel.load_entries(pattern); + self.channel.find(pattern); } - async fn sync_channel(&mut self, pattern: &str) -> Result<()> { - self.channel.load_entries(pattern).await?; - self.results_list.results = self.channel.entries().clone(); - self.results_list.state.select(Some(0)); - Ok(()) - } - - fn get_selected_entry(&self) -> Option<&Entry> { - self.results_list - .state + fn get_selected_entry(&self) -> Option { + self.picker_state .selected() - .and_then(|i| self.results_list.results.get(i)) + .and_then(|i| self.channel.get_result(i as u32)) } pub fn select_prev_entry(&mut self) { - let selected = self.results_list.state.selected().unwrap_or(0); - let total_search_results = self.results_list.results.len(); - self.results_list - .state - .select(Some((selected + 1) % total_search_results)); + if self.channel.result_count() == 0 { + return; + } + let new_index = + (self.picker_state.selected().unwrap_or(0) + 1) % self.channel.result_count() as usize; + self.picker_state.select(Some(new_index)); + if new_index == 0 { + self.picker_view_offset = 0; + } + if self.relative_picker_state.selected().unwrap_or(0) + == self.results_area_height as usize - 3 + { + self.picker_view_offset += 1; + self.relative_picker_state.select(Some( + self.picker_state + .selected() + .unwrap_or(0) + .min(self.results_area_height as usize - 3), + )) + } else { + self.relative_picker_state.select(Some( + (self.relative_picker_state.selected().unwrap_or(0) + 1) + .min(self.picker_state.selected().unwrap_or(0)), + )); + } } pub fn select_next_entry(&mut self) { - let selected = self.results_list.state.selected().unwrap_or(0); + if self.channel.result_count() == 0 { + return; + } + let selected = self.picker_state.selected().unwrap_or(0); + let relative_selected = self.relative_picker_state.selected().unwrap_or(0); if selected > 0 { - self.results_list.state.select(Some(selected - 1)); + self.picker_state.select(Some(selected - 1)); + self.relative_picker_state + .select(Some(relative_selected.saturating_sub(1))); + if relative_selected == 0 { + self.picker_view_offset = self.picker_view_offset.saturating_sub(1); + } } else { - self.results_list - .state - .select(Some(self.results_list.results.len() - 1)); + self.picker_view_offset = (self + .channel + .result_count() + .saturating_sub(self.results_area_height as u32)) + as usize; + self.picker_state.select(Some( + (self.channel.result_count() as usize).saturating_sub(1), + )); + self.relative_picker_state + .select(Some(self.results_area_height as usize - 3)); } } @@ -230,7 +258,6 @@ const DEFAULT_PREVIEW_GUTTER_SELECTED_FG: Color = Color::Rgb(255, 150, 150); impl Component for Television { fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { self.action_tx = Some(tx.clone()); - self.channel.register_action_handler(tx.clone()); Ok(()) } @@ -276,14 +303,14 @@ impl Component for Television { if new_pattern != self.current_pattern { self.current_pattern = new_pattern.clone(); self.find(&new_pattern); + self.picker_state.select(Some(0)); + self.relative_picker_state.select(Some(0)); + self.picker_view_offset = 0; } } _ => {} } } - Action::SyncFinderResults => { - self.sync_channel(&self.current_pattern).await?; - } Action::SelectNextEntry => self.select_next_entry(), Action::SelectPrevEntry => self.select_prev_entry(), _ => {} @@ -293,6 +320,7 @@ impl Component for Television { fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { let (results_area, input_area, preview_title_area, preview_area) = self.create_layout(area); + self.results_area_height = results_area.height as u32; // top left block: results let results_block = Block::default() @@ -307,13 +335,18 @@ impl Component for Television { .style(Style::default()) .padding(Padding::right(1)); - let results_list = self.build_results_list(results_block); + if self.channel.result_count() > 0 && self.picker_state.selected().is_none() { + self.picker_state.select(Some(0)); + self.relative_picker_state.select(Some(0)); + } - frame.render_stateful_widget( - results_list, - results_area, - &mut self.results_list.state.clone(), - ); + // we load two more entries to allow for smooth scrolling + let entries = self + .channel + .results(results_area.height.into(), self.picker_view_offset as u32); + let results_list = self.build_results_list(results_block, &entries); + + frame.render_stateful_widget(results_list, results_area, &mut self.relative_picker_state); // bottom left block: input let input_block = Block::default() @@ -331,14 +364,13 @@ impl Component for Television { frame.render_widget(input_block, input_area); - let total_search_results = self.channel.entries().len(); let inner_input_chunks = Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Length(2), Constraint::Fill(1), Constraint::Length( - 2 * ((total_search_results as f32).log10().ceil() as u16 + 1) + 3, + 3 * ((self.channel.total_count() as f32).log10().ceil() as u16 + 1) + 3, ), ]) .split(input_block_inner); @@ -357,20 +389,22 @@ impl Component for Television { .alignment(Alignment::Left); frame.render_widget(input, inner_input_chunks[1]); - if self.results_list.state.selected().is_some() && total_search_results > 0 { - let result_count_block = Block::default(); - let result_count = Paragraph::new(Span::styled( - format!( - " {} / {} ", - self.results_list.state.selected().unwrap() + 1, - total_search_results, - ), - Style::default().fg(DEFAULT_RESULTS_COUNT_FG), - )) - .block(result_count_block) - .alignment(Alignment::Right); - frame.render_widget(result_count, inner_input_chunks[2]); - } + let result_count_block = Block::default(); + let result_count = Paragraph::new(Span::styled( + format!( + " {} / {} ", + if self.channel.result_count() == 0 { + 0 + } else { + self.picker_state.selected().unwrap_or(0) + 1 + }, + self.channel.result_count(), + ), + Style::default().fg(DEFAULT_RESULTS_COUNT_FG), + )) + .block(result_count_block) + .alignment(Alignment::Right); + frame.render_widget(result_count, inner_input_chunks[2]); if let Pane::Input = self.current_pane { // Make the cursor visible and ask tui-rs to put it at the specified coordinates after rendering @@ -386,10 +420,12 @@ impl Component for Television { // top right block: preview title let selected_entry = self.get_selected_entry(); - let preview = self - .channel - .get_preview(&selected_entry.unwrap_or(&ENTRY_PLACEHOLDER).clone()); - let preview_title = Paragraph::new(Line::from(preview.title.clone())) + // FIXME: compute the preview only if the selected entry has changed and cache it + //let preview = self + // .channel + // .get_preview(&selected_entry.unwrap_or(&ENTRY_PLACEHOLDER).clone()); + //let preview_title = Paragraph::new(Line::from(preview.title.clone())) + let preview_title = Paragraph::new(Line::from(" Preview ")) .block( Block::default() .borders(Borders::ALL) @@ -422,7 +458,14 @@ impl Component for Television { let inner = preview_outer_block.inner(preview_area); frame.render_widget(preview_outer_block, preview_area); - let preview = self.build_preview(preview_inner_block, &inner, preview); + let preview = self.build_preview( + preview_inner_block, + &inner, + Preview { + content: PreviewContent::NotSupported, + title: "".to_string(), + }, + ); frame.render_widget(preview, inner); @@ -468,8 +511,15 @@ impl Television { ) } - fn build_results_list<'a>(&'a self, results_block: Block<'a>) -> List<'a> { - List::new(self.channel.entries().iter().map(|entry| { + fn build_results_list<'a, 'b>( + &self, + results_block: Block<'b>, + entries: &'a Vec, + ) -> List<'a> + where + 'b: 'a, + { + List::new(entries.iter().map(|entry| { let mut spans = Vec::new(); // optional icon if let Some(icon) = &entry.icon { @@ -482,20 +532,22 @@ impl Television { // entry name if let Some(name_match_ranges) = &entry.name_match_ranges { let mut last_match_end = 0; - for (start, end) in name_match_ranges { + for (start, end) in name_match_ranges + .iter() + .map(|(s, e)| (*s as usize, *e as usize)) + { spans.push(Span::styled( - slice_at_char_boundaries(&entry.name, last_match_end, *start), + slice_at_char_boundaries(&entry.name, last_match_end, start), Style::default().fg(DEFAULT_RESULT_NAME_FG), )); spans.push(Span::styled( - slice_at_char_boundaries(&entry.name, *start, *end), + slice_at_char_boundaries(&entry.name, start, end), Style::default().fg(Color::Red), )); - last_match_end = *end; + last_match_end = end; } spans.push(Span::styled( - &entry.name - [next_char_boundary(&entry.name, name_match_ranges.last().unwrap().1)..], + &entry.name[next_char_boundary(&entry.name, last_match_end)..], Style::default().fg(DEFAULT_RESULT_NAME_FG), )); } else { @@ -517,21 +569,24 @@ impl Television { if let Some(preview_match_ranges) = &entry.preview_match_ranges { let mut last_match_end = 0; - for (start, end) in preview_match_ranges { + for (start, end) in preview_match_ranges + .iter() + .map(|(s, e)| (*s as usize, *e as usize)) + { spans.push(Span::styled( - slice_at_char_boundaries(preview, last_match_end, *start), + slice_at_char_boundaries(preview, last_match_end, start), Style::default().fg(DEFAULT_RESULT_PREVIEW_FG), )); spans.push(Span::styled( - slice_at_char_boundaries(preview, *start, *end), + slice_at_char_boundaries(preview, start, end), Style::default().fg(Color::Red), )); - last_match_end = *end; + last_match_end = end; } spans.push(Span::styled( &preview[next_char_boundary( &preview, - preview_match_ranges.last().unwrap().1, + preview_match_ranges.last().unwrap().1 as usize, )..], Style::default().fg(DEFAULT_RESULT_PREVIEW_FG), )); @@ -550,12 +605,12 @@ impl Television { .block(results_block) } - fn build_preview<'a>( - &'a mut self, - preview_block: Block<'a>, + fn build_preview<'b>( + &'b mut self, + preview_block: Block<'b>, inner: &Rect, preview: Preview, - ) -> Paragraph<'a> { + ) -> Paragraph<'b> { let preview_content = preview.content.clone(); match preview_content { PreviewContent::PlainText(content) => { @@ -641,7 +696,9 @@ fn build_unsupported_preview_paragraph<'a>(inner: &Rect) -> Paragraph<'a> { "/".repeat(inner.width as usize - horizontal_padding - custom_message.len()) ); lines.push(Line::from(line)); - } else if i as usize + 1 == vertical_center || i as usize - 1 == vertical_center { + } else if i as usize + 1 == vertical_center + || (i as usize).saturating_sub(1) == vertical_center + { let line = format!( "{} {} {}", "/".repeat(horizontal_padding), diff --git a/src/render.rs b/src/render.rs index 001cc72..fa5fada 100644 --- a/src/render.rs +++ b/src/render.rs @@ -1,8 +1,11 @@ use color_eyre::Result; use ratatui::layout::Rect; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; -use tokio::{select, sync::mpsc}; +use tokio::{ + select, + sync::{mpsc, Mutex}, +}; use crate::{ action::Action, @@ -32,13 +35,13 @@ pub async fn render( television .lock() - .unwrap() + .await .register_action_handler(action_tx.clone())?; television .lock() - .unwrap() + .await .register_config_handler(config.clone())?; - television.lock().unwrap().init(tui.size().unwrap())?; + television.lock().await.init(tui.size().unwrap())?; // Rendering loop loop { @@ -53,7 +56,7 @@ pub async fn render( tui.terminal.clear()?; } RenderingTask::Render => { - let mut television = television.lock().unwrap(); + let mut television = television.lock().await; tui.terminal.draw(|frame| { if let Err(err) = television.draw(frame, frame.area()) { let _ = action_tx