From 26db5a95d0ac12d7b292ff78f5a2ecd00f3897fd Mon Sep 17 00:00:00 2001 From: lalvarezt Date: Sun, 27 Jul 2025 20:29:52 +0200 Subject: [PATCH 1/3] feat(frecency): initial frecency support --- benches/main/ui.rs | 4 +- man/tv.1 | 76 ++++++- television/app.rs | 97 ++++++++- television/channels/channel.rs | 181 +++++++++++++++-- television/channels/prototypes.rs | 14 ++ television/cli/args.rs | 18 ++ television/cli/mod.rs | 12 ++ television/frecency.rs | 328 ++++++++++++++++++++++++++++++ television/lib.rs | 1 + television/television.rs | 16 +- 10 files changed, 725 insertions(+), 22 deletions(-) create mode 100644 television/frecency.rs diff --git a/benches/main/ui.rs b/benches/main/ui.rs index 8e095f4..5a5da7a 100644 --- a/benches/main/ui.rs +++ b/benches/main/ui.rs @@ -491,13 +491,13 @@ pub fn draw(c: &mut Criterion) { tv.find("television"); for _ in 0..5 { // tick the matcher - let _ = tv.channel.results(10, 0); + let _ = tv.channel.results(10, 0, None); std::thread::sleep(std::time::Duration::from_millis(10)); } tv.move_cursor(Movement::Next, 10); let selected_entry = tv.get_selected_entry(); let _ = tv.update_preview_state(&selected_entry); - let _ = tv.update(&Action::Tick); + let _ = tv.update(&Action::Tick, None); (tv, terminal) }, // Measurement diff --git a/man/tv.1 b/man/tv.1 index d04cbcc..6fdd53e 100644 --- a/man/tv.1 +++ b/man/tv.1 @@ -4,7 +4,7 @@ .SH NAME television \- Cross\-platform, fast and extensible general purpose fuzzy finder TUI. .SH SYNOPSIS -\fBtelevision\fR [\fB\-\-preview\-offset\fR] [\fB\-\-no\-preview\fR] [\fB\-\-hide\-preview\fR] [\fB\-\-show\-preview\fR] [\fB\-\-no\-status\-bar\fR] [\fB\-\-hide\-status\-bar\fR] [\fB\-\-show\-status\-bar\fR] [\fB\-t\fR|\fB\-\-tick\-rate\fR] [\fB\-\-watch\fR] [\fB\-k\fR|\fB\-\-keybindings\fR] [\fB\-i\fR|\fB\-\-input\fR] [\fB\-\-input\-header\fR] [\fB\-\-input\-prompt\fR] [\fB\-\-preview\-header\fR] [\fB\-\-preview\-footer\fR] [\fB\-s\fR|\fB\-\-source\-command\fR] [\fB\-\-ansi\fR] [\fB\-\-source\-display\fR] [\fB\-\-source\-output\fR] [\fB\-\-source\-entry\-delimiter\fR] [\fB\-p\fR|\fB\-\-preview\-command\fR] [\fB\-\-layout\fR] [\fB\-\-autocomplete\-prompt\fR] [\fB\-\-exact\fR] [\fB\-\-select\-1\fR] [\fB\-\-take\-1\fR] [\fB\-\-take\-1\-fast\fR] [\fB\-\-no\-remote\fR] [\fB\-\-hide\-remote\fR] [\fB\-\-show\-remote\fR] [\fB\-\-no\-help\-panel\fR] [\fB\-\-hide\-help\-panel\fR] [\fB\-\-show\-help\-panel\fR] [\fB\-\-ui\-scale\fR] [\fB\-\-preview\-size\fR] [\fB\-\-config\-file\fR] [\fB\-\-cable\-dir\fR] [\fB\-\-global\-history\fR] [\fB\-\-height\fR] [\fB\-\-width\fR] [\fB\-\-inline\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] [\fICHANNEL\fR] [\fIPATH\fR] [\fIsubcommands\fR] +\fBtelevision\fR [\fB\-\-preview\-offset\fR] [\fB\-\-no\-preview\fR] [\fB\-\-hide\-preview\fR] [\fB\-\-show\-preview\fR] [\fB\-\-no\-status\-bar\fR] [\fB\-\-hide\-status\-bar\fR] [\fB\-\-show\-status\-bar\fR] [\fB\-t\fR|\fB\-\-tick\-rate\fR] [\fB\-\-watch\fR] [\fB\-k\fR|\fB\-\-keybindings\fR] [\fB\-\-expect\fR] [\fB\-i\fR|\fB\-\-input\fR] [\fB\-\-input\-header\fR] [\fB\-\-input\-prompt\fR] [\fB\-\-input\-border\fR] [\fB\-\-input\-padding\fR] [\fB\-\-preview\-header\fR] [\fB\-\-preview\-footer\fR] [\fB\-\-preview\-border\fR] [\fB\-\-preview\-padding\fR] [\fB\-s\fR|\fB\-\-source\-command\fR] [\fB\-\-ansi\fR] [\fB\-\-source\-display\fR] [\fB\-\-source\-output\fR] [\fB\-\-results\-border\fR] [\fB\-\-results\-padding\fR] [\fB\-\-source\-entry\-delimiter\fR] [\fB\-p\fR|\fB\-\-preview\-command\fR] [\fB\-\-layout\fR] [\fB\-\-autocomplete\-prompt\fR] [\fB\-\-exact\fR] [\fB\-\-select\-1\fR] [\fB\-\-take\-1\fR] [\fB\-\-take\-1\-fast\fR] [\fB\-\-no\-remote\fR] [\fB\-\-hide\-remote\fR] [\fB\-\-show\-remote\fR] [\fB\-\-no\-help\-panel\fR] [\fB\-\-hide\-help\-panel\fR] [\fB\-\-show\-help\-panel\fR] [\fB\-\-ui\-scale\fR] [\fB\-\-preview\-size\fR] [\fB\-\-config\-file\fR] [\fB\-\-cable\-dir\fR] [\fB\-\-global\-history\fR] [\fB\-\-frecency\fR] [\fB\-\-global\-frecency\fR] [\fB\-\-height\fR] [\fB\-\-width\fR] [\fB\-\-inline\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] [\fICHANNEL\fR] [\fIPATH\fR] [\fIsubcommands\fR] .SH DESCRIPTION Cross\-platform, fast and extensible general purpose fuzzy finder TUI. .SH OPTIONS @@ -84,6 +84,16 @@ expressions using the configuration file formalism. Example: `tv \-\-keybindings=\*(Aqquit="esc";select_next_entry=["down","ctrl\-j"]\*(Aq` .TP +\fB\-\-expect\fR=\fISTRING\fR +Keys that can be used to confirm the current selection in addition to the default ones +(typically `enter`). + +When this is set, confirming the selection will first output an extra line with the key +that was used to confirm the selection before outputting the selected entry. + +Example: `tv \-\-expect=\*(Aqctrl\-q\*(Aq` will output `ctr\-q\\n` when `ctrl\-q` is +pressed to confirm the selection. +.TP \fB\-i\fR, \fB\-\-input\fR=\fISTRING\fR Input text to pass to the channel to prefill the prompt. @@ -111,6 +121,22 @@ When no channel is specified: Sets the input prompt for the ad\-hoc channel. The given value is used as the prompt string shown before the input field. Defaults to ">" when omitted. .TP +\fB\-\-input\-border\fR=\fIINPUT_BORDER\fR +Sets the input panel border type. + +Available options are: `none`, `plain`, `rounded`, `thick`. +.br + +.br +[\fIpossible values: \fRnone, plain, rounded, thick] +.TP +\fB\-\-input\-padding\fR=\fISTRING\fR +Sets the input panel padding. + +Format: `top=INTEGER;left=INTEGER;bottom=INTEGER;right=INTEGER` + +Example: `\-\-input\-padding=\*(Aqtop=1;left=2;bottom=1;right=2\*(Aq` +.TP \fB\-\-preview\-header\fR=\fISTRING\fR Preview header template @@ -129,6 +155,22 @@ When no channel is specified: This flag requires \-\-preview\-command to be set. The given value is parsed as a `MultiTemplate`. It is evaluated for every entry and its result is displayed below the preview panel. .TP +\fB\-\-preview\-border\fR=\fIPREVIEW_BORDER\fR +Sets the preview panel border type. + +Available options are: `none`, `plain`, `rounded`, `thick`. +.br + +.br +[\fIpossible values: \fRnone, plain, rounded, thick] +.TP +\fB\-\-preview\-padding\fR=\fISTRING\fR +Sets the preview panel padding. + +Format: `top=INTEGER;left=INTEGER;bottom=INTEGER;right=INTEGER` + +Example: `\-\-preview\-padding=\*(Aqtop=1;left=2;bottom=1;right=2\*(Aq` +.TP \fB\-s\fR, \fB\-\-source\-command\fR=\fISTRING\fR Source command to use for the current channel. @@ -164,6 +206,22 @@ When no channel is specified: This flag requires \-\-source\-command to be set. The template is used to format the final output when an entry is selected. Example: "{}" (output the full entry) .TP +\fB\-\-results\-border\fR=\fIRESULTS_BORDER\fR +Sets the results panel border type. + +Available options are: `none`, `plain`, `rounded`, `thick`. +.br + +.br +[\fIpossible values: \fRnone, plain, rounded, thick] +.TP +\fB\-\-results\-padding\fR=\fISTRING\fR +Sets the results panel padding. + +Format: `top=INTEGER;left=INTEGER;bottom=INTEGER;right=INTEGER` + +Example: `\-\-results\-padding=\*(Aqtop=1;left=2;bottom=1;right=2\*(Aq` +.TP \fB\-\-source\-entry\-delimiter\fR=\fISTRING\fR The delimiter byte to use for splitting the source\*(Aqs command output into entries. @@ -318,6 +376,22 @@ This flag only works in channel mode. When enabled, history navigation will show entries from all channels. When disabled (default), history navigation is scoped to the current channel. .TP +\fB\-\-frecency\fR +Enable frecency scoring to boost previously selected entries. + +This flag works in both channel mode and ad\-hoc mode. + +When enabled, entries that were previously selected will be ranked higher +in the results list based on frequency of use and recency of access. +.TP +\fB\-\-global\-frecency\fR +Use global frecency across all channels instead of channel\-specific frecency. + +This flag only works when \-\-frecency is enabled. + +When enabled, frecency scoring will consider selections from all channels. +When disabled (default), frecency is scoped to the current channel. +.TP \fB\-\-height\fR=\fIINTEGER\fR Height in lines for non\-fullscreen mode. diff --git a/television/app.rs b/television/app.rs index 06f6d2b..a296b19 100644 --- a/television/app.rs +++ b/television/app.rs @@ -5,6 +5,7 @@ use crate::{ cli::PostProcessedCli, config::{Config, DEFAULT_PREVIEW_SIZE, default_tick_rate}, event::{Event, EventLoop, InputEvent, Key, MouseInputEvent}, + frecency::Frecency, history::History, keymap::InputMap, render::{RenderingTask, UiState, render}, @@ -13,6 +14,7 @@ use crate::{ }; use anyhow::Result; use rustc_hash::FxHashSet; +use std::path::Path; use tokio::sync::mpsc; use tracing::{debug, error, trace}; @@ -138,6 +140,8 @@ pub struct App { watch_timer_task: Option>, /// Global history for selected entries history: History, + /// Frecency tracking for selected entries + frecency: Frecency, } /// The outcome of an action. @@ -223,6 +227,16 @@ impl App { error!("Failed to initialize history: {}", e); } + // Initialize frecency + let mut frecency = Self::create_frecency( + &television.cli_args, + &television.channel_prototype, + &television.config.application.data_dir, + ); + if let Err(e) = frecency.init() { + error!("Failed to initialize frecency: {}", e); + } + let mut app = Self { input_map, television, @@ -240,6 +254,7 @@ impl App { options, watch_timer_task: None, history, + frecency, }; // populate input_map by going through all cable channels and adding their shortcuts if remote @@ -336,6 +351,18 @@ impl App { &channel_prototype.metadata.name, global_mode, ); + + // Determine the effective global_frecency (channel overrides global config) + let frecency_global = channel_prototype + .frecency + .global_mode + .unwrap_or(self.television.cli_args.global_frecency); + + // Update existing frecency with new channel context + self.frecency.update_channel_context( + &channel_prototype.metadata.name, + frecency_global, + ); } /// Run the application main loop. @@ -463,6 +490,11 @@ impl App { error!("Failed to persist history: {}", e); } + // persist frecency data + if let Err(e) = self.frecency.save_to_file() { + error!("Failed to persist frecency: {}", e); + } + // wait for the rendering task to finish if let Some(rendering_task) = self.render_task.take() { rendering_task.await?.expect("Rendering task failed"); @@ -605,6 +637,20 @@ impl App { query, self.television.current_channel(), )?; + + // Add selected entries to frecency + for entry in &entries { + if let Err(e) = self.frecency.add_entry( + entry.raw.clone(), + self.television.current_channel(), + ) { + error!( + "Failed to add entry to frecency: {}", + e + ); + } + } + return Ok(ActionOutcome::Entries(entries)); } @@ -627,6 +673,20 @@ impl App { query, self.television.current_channel(), )?; + + // Add selected entries to frecency + for entry in &entries { + if let Err(e) = self.frecency.add_entry( + entry.raw.clone(), + self.television.current_channel(), + ) { + error!( + "Failed to add entry to frecency: {}", + e + ); + } + } + return Ok(ActionOutcome::EntriesWithExpect( entries, k, )); @@ -676,7 +736,14 @@ impl App { self.television.mode == Mode::RemoteControl; // forward action to the television handler - if let Some(action) = self.television.update(&action)? { + let frecency_ref = if self.frecency.max_size() > 0 { + Some(&self.frecency) + } else { + None + }; + if let Some(action) = + self.television.update(&action, frecency_ref)? + { self.action_tx.send(action)?; } @@ -745,6 +812,34 @@ impl App { } } + /// Create frecency instance + fn create_frecency( + cli_args: &PostProcessedCli, + channel_prototype: &ChannelPrototype, + data_dir: &Path, + ) -> Frecency { + // Determine if frecency is enabled (CLI overrides channel config) + let frecency_enabled = cli_args.frecency + || channel_prototype.frecency.enabled.unwrap_or(false); + + // Determine global mode (CLI overrides channel config) + let frecency_global = cli_args.global_frecency + || channel_prototype.frecency.global_mode.unwrap_or(false); + + let frecency_size = if frecency_enabled { + crate::frecency::DEFAULT_FRECENCY_SIZE + } else { + 0 + }; + + Frecency::new( + frecency_size, + &channel_prototype.metadata.name, + frecency_global, + data_dir, + ) + } + /// Determine the TUI mode based on the provided options. fn determine_tui_mode( height: Option, diff --git a/television/channels/channel.rs b/television/channels/channel.rs index 81ea97d..be45f3e 100644 --- a/television/channels/channel.rs +++ b/television/channels/channel.rs @@ -3,15 +3,20 @@ use crate::{ entry::Entry, prototypes::{ChannelPrototype, SourceSpec, Template}, }, - matcher::{Matcher, config::Config, injector::Injector}, + frecency::Frecency, + matcher::{ + Matcher, config::Config, injector::Injector, matched_item::MatchedItem, + }, utils::command::shell_command, }; use rustc_hash::{FxBuildHasher, FxHashSet}; use std::collections::HashSet; use std::io::{BufRead, BufReader}; use std::process::Stdio; -use std::sync::Arc; -use std::sync::atomic::AtomicBool; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, +}; use std::time::Duration; use tracing::{debug, trace}; @@ -26,6 +31,8 @@ pub struct Channel { /// Indicates if the channel is currently reloading to prevent UI flickering /// by delaying the rendering of a new frame. pub reloading: Arc, + /// Track current dataset items as they're loaded for frecency filtering + current_dataset: Arc>>, } impl Channel { @@ -40,26 +47,33 @@ impl Channel { crawl_handle: None, current_source_index, reloading: Arc::new(AtomicBool::new(false)), + current_dataset: Arc::new(Mutex::new(FxHashSet::default())), } } pub fn load(&mut self) { + // Clear the current dataset at the start of each load + if let Ok(mut dataset) = self.current_dataset.lock() { + dataset.clear(); + } + let injector = self.matcher.injector(); + let current_dataset = self.current_dataset.clone(); let crawl_handle = tokio::spawn(load_candidates( self.prototype.source.clone(), self.current_source_index, injector, + current_dataset, )); self.crawl_handle = Some(crawl_handle); } pub fn reload(&mut self) { - if self.reloading.load(std::sync::atomic::Ordering::Relaxed) { + if self.reloading.load(Ordering::Relaxed) { debug!("Reload already in progress, skipping."); return; } - self.reloading - .store(true, std::sync::atomic::Ordering::Relaxed); + self.reloading.store(true, Ordering::Relaxed); if let Some(handle) = self.crawl_handle.take() { if !handle.is_finished() { @@ -73,7 +87,7 @@ impl Channel { let reloading = self.reloading.clone(); tokio::spawn(async move { tokio::time::sleep(RELOAD_RENDERING_DELAY).await; - reloading.store(false, std::sync::atomic::Ordering::Relaxed); + reloading.store(false, Ordering::Relaxed); }); } @@ -89,10 +103,131 @@ impl Channel { self.matcher.find(pattern); } - pub fn results(&mut self, num_entries: u32, offset: u32) -> Vec { + /// Filter recent items to only include those that exist in the current dataset + fn filter_recent_items_by_current_dataset( + &self, + recent_items: &[String], + ) -> Vec { + match self.current_dataset.lock() { + Ok(current_dataset) => { + let mut filtered = Vec::with_capacity(recent_items.len()); + filtered.extend( + recent_items + .iter() + .filter(|item| current_dataset.contains(*item)) + .cloned(), + ); + filtered + } + Err(_) => { + // If we can't lock, return empty to prevent inconsistent results + Vec::new() + } + } + } + + /// Fuzzy match against a list of recent items + #[allow(clippy::cast_possible_truncation)] + fn fuzzy_match_recent_items( + pattern: &str, + recent_items: &[String], + ) -> Vec> { + if recent_items.is_empty() { + return Vec::new(); + } + + // Create a temporary matcher for recent items + let config = Config::default().prefer_prefix(true); + let mut recent_matcher = Matcher::new(&config); + + // Inject recent items into the matcher + let injector = recent_matcher.injector(); + for item in recent_items { + injector.push(item.clone(), |e, cols| { + cols[0] = e.clone().into(); + }); + } + + // Apply the pattern + recent_matcher.find(pattern); + + // Let the matcher process + recent_matcher.tick(); + + // Get all matches (recent items are small, so we can get all) + recent_matcher.results(recent_items.len() as u32, 0) + } + + #[allow(clippy::cast_possible_truncation)] + pub fn results( + &mut self, + num_entries: u32, + offset: u32, + frecency: Option<&Frecency>, + ) -> Vec { self.matcher.tick(); - let results = self.matcher.results(num_entries, offset); + let results = if let Some(frecency_data) = frecency { + // Frecency-aware results with dataset validation + let recent_items = frecency_data.get_recent_items(); + + // Early exit if no recent items to avoid unnecessary work + if recent_items.is_empty() { + self.matcher.results(num_entries, offset) + } else { + let filtered_recent_items = + self.filter_recent_items_by_current_dataset(&recent_items); + + // If no recent items pass validation, fall back to regular matching + if filtered_recent_items.is_empty() { + self.matcher.results(num_entries, offset) + } else { + // Fuzzy match the validated recent items + let recent_matches = Self::fuzzy_match_recent_items( + &self.matcher.last_pattern, + &filtered_recent_items, + ); + + // Get regular results, excluding recent matches to avoid duplicates + let remaining_slots = num_entries + .saturating_sub(recent_matches.len() as u32); + + let mut regular_matches = Vec::new(); + if remaining_slots > 0 { + // Fetch full list to account for deduplication + let nucleo_results = + self.matcher.results(num_entries, 0); + + // Use Vec::contains for small recent items list (faster than HashSet creation) + regular_matches.reserve(remaining_slots as usize); + for item in nucleo_results { + if !filtered_recent_items.contains(&item.inner) { + regular_matches.push(item); + if regular_matches.len() + >= remaining_slots as usize + { + break; + } + } + } + } + + // Combine with recent items prioritized first + let mut combined = recent_matches; + combined.extend(regular_matches); + + // Apply pagination + combined + .into_iter() + .skip(offset as usize) + .take(num_entries as usize) + .collect() + } + } + } else { + // No frecency: use standard nucleo matching + self.matcher.results(num_entries, offset) + }; let mut entries = Vec::with_capacity(results.len()); @@ -108,7 +243,9 @@ impl Channel { } pub fn get_result(&mut self, index: u32) -> Option { - if let Some(item) = self.matcher.get_result(index) { + let item = self.matcher.get_result(index); + + if let Some(item) = item { let mut entry = Entry::new(item.inner.clone()) .with_display(item.matched_string) .with_match_indices(&item.match_indices); @@ -184,6 +321,7 @@ async fn load_candidates( source: SourceSpec, source_command_index: usize, injector: Injector, + current_dataset: Arc>>, ) { debug!("Loading candidates from command: {:?}", source.command); let mut child = shell_command( @@ -228,7 +366,14 @@ async fn load_candidates( if let Ok(l) = std::str::from_utf8(&buf) { trace!("Read line: {}", l); if !l.trim().is_empty() { - let () = injector.push(l.to_string(), |e, cols| { + let entry = l.to_string(); + + // Track this item in our current dataset + if let Ok(mut dataset) = current_dataset.lock() { + dataset.insert(entry.clone()); + } + + let () = injector.push(entry, |e, cols| { if source.ansi { cols[0] = strip_ansi.format(e).unwrap_or_else(|_| { panic!( @@ -260,6 +405,11 @@ async fn load_candidates( for line in reader.lines() { let line = line.unwrap(); if !line.trim().is_empty() { + // Track this item in our current dataset + if let Ok(mut dataset) = current_dataset.lock() { + dataset.insert(line.clone()); + } + let () = injector.push(line, |e, cols| { cols[0] = e.clone().into(); }); @@ -286,8 +436,9 @@ mod tests { let mut matcher = Matcher::::new(&Config::default()); let injector = matcher.injector(); + let current_dataset = Arc::new(Mutex::new(FxHashSet::default())); - load_candidates(source_spec, 0, injector).await; + load_candidates(source_spec, 0, injector, current_dataset).await; // Check if the matcher has the expected results matcher.find("test"); @@ -309,8 +460,9 @@ mod tests { let mut matcher = Matcher::::new(&Config::default()); let injector = matcher.injector(); + let current_dataset = Arc::new(Mutex::new(FxHashSet::default())); - load_candidates(source_spec, 0, injector).await; + load_candidates(source_spec, 0, injector, current_dataset).await; // Check if the matcher has the expected results matcher.find("test"); @@ -332,8 +484,9 @@ mod tests { let mut matcher = Matcher::::new(&Config::default()); let injector = matcher.injector(); + let current_dataset = Arc::new(Mutex::new(FxHashSet::default())); - load_candidates(source_spec, 0, injector).await; + load_candidates(source_spec, 0, injector, current_dataset).await; // Check if the matcher has the expected results matcher.find("test"); diff --git a/television/channels/prototypes.rs b/television/channels/prototypes.rs index b05100f..93ee5f0 100644 --- a/television/channels/prototypes.rs +++ b/television/channels/prototypes.rs @@ -183,6 +183,16 @@ pub struct HistoryConfig { pub global_mode: Option, } +#[derive(Default, Debug, Clone, serde::Deserialize, serde::Serialize)] +pub struct FrecencyConfig { + /// Whether to enable frecency scoring for this channel + #[serde(default)] + pub enabled: Option, + /// Whether to use global frecency for this channel (overrides global setting) + #[serde(default)] + pub global_mode: Option, +} + #[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] pub struct ChannelPrototype { pub metadata: Metadata, @@ -198,6 +208,8 @@ pub struct ChannelPrototype { pub watch: f64, #[serde(default)] pub history: HistoryConfig, + #[serde(default)] + pub frecency: FrecencyConfig, // actions: Vec, } @@ -228,6 +240,7 @@ impl ChannelPrototype { keybindings: None, watch: 0.0, history: HistoryConfig::default(), + frecency: FrecencyConfig::default(), } } @@ -259,6 +272,7 @@ impl ChannelPrototype { keybindings: None, watch: 0.0, history: HistoryConfig::default(), + frecency: FrecencyConfig::default(), } } diff --git a/television/cli/args.rs b/television/cli/args.rs index 7030eaf..91b72d8 100644 --- a/television/cli/args.rs +++ b/television/cli/args.rs @@ -489,6 +489,24 @@ pub struct Cli { #[arg(long, verbatim_doc_comment)] pub global_history: bool, + /// Enable frecency scoring to boost previously selected entries. + /// + /// This flag works in both channel mode and ad-hoc mode. + /// + /// When enabled, entries that were previously selected will be ranked higher + /// in the results list based on frequency of use and recency of access. + #[arg(long, verbatim_doc_comment)] + pub frecency: bool, + + /// Use global frecency across all channels instead of channel-specific frecency. + /// + /// This flag only works when --frecency is enabled. + /// + /// When enabled, frecency scoring will consider selections from all channels. + /// When disabled (default), frecency is scoped to the current channel. + #[arg(long, verbatim_doc_comment)] + pub global_frecency: bool, + /// Height in lines for non-fullscreen mode. /// /// This flag works identically in both channel mode and ad-hoc mode. diff --git a/television/cli/mod.rs b/television/cli/mod.rs index f42e5b6..aa562a5 100644 --- a/television/cli/mod.rs +++ b/television/cli/mod.rs @@ -119,6 +119,10 @@ pub struct PostProcessedCli { // History configuration pub global_history: bool, + // Frecency + pub frecency: bool, + pub global_frecency: bool, + // Configuration sources pub config_file: Option, pub cable_dir: Option, @@ -201,6 +205,10 @@ impl Default for PostProcessedCli { // History configuration global_history: false, + // Frecency + frecency: false, + global_frecency: false, + // Configuration sources config_file: None, cable_dir: None, @@ -432,6 +440,10 @@ pub fn post_process(cli: Cli, readable_stdin: bool) -> PostProcessedCli { // History configuration global_history: cli.global_history, + // Frecency + frecency: cli.frecency, + global_frecency: cli.global_frecency, + // Configuration sources config_file: cli.config_file.map(|p| expand_tilde(&p)), cable_dir: cli.cable_dir.map(|p| expand_tilde(&p)), diff --git a/television/frecency.rs b/television/frecency.rs new file mode 100644 index 0000000..f67888b --- /dev/null +++ b/television/frecency.rs @@ -0,0 +1,328 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::{ + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; +use tracing::debug; + +const FRECENCY_FILE_NAME: &str = "frecency.json"; +const SECONDS_PER_DAY: f64 = 86400.0; // 24 * 60 * 60 +pub const DEFAULT_FRECENCY_SIZE: usize = 1000; +/// Maximum number of recent items to prioritize in search results +/// This ensures frequently-used items get higher priority in nucleo matching +pub const RECENT_ITEMS_PRIORITY_COUNT: usize = 200; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FrecencyEntry { + /// The actual selected entry (file path, command, etc.) + pub entry: String, + /// The channel that the entry belongs to + pub channel: String, + /// Number of times this entry was selected + pub access_count: u32, + /// Timestamp of the last access + pub last_access: u64, +} + +impl PartialEq for FrecencyEntry { + fn eq(&self, other: &Self) -> bool { + self.entry == other.entry && self.channel == other.channel + } +} + +impl FrecencyEntry { + pub fn new(entry: String, channel: String) -> Self { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + Self { + entry, + channel, + access_count: 1, + last_access: timestamp, + } + } + + /// Calculate frecency score based on frequency and recency + pub fn calculate_score(&self, now: u64) -> f64 { + // let's limit unreasonably high scores when the access is very recent + let days_since_access = + ((now - self.last_access) as f64 / SECONDS_PER_DAY).max(0.1); + + // Recency weight: more recent = higher score + let recency_weight = 1.0 / days_since_access; + + // Frequency weight: logarithmic scaling to avoid runaway scores + let frequency_weight = f64::from(self.access_count).ln_1p(); + + // Combined score with recency having stronger influence for recent items + recency_weight * frequency_weight + } + + /// Update access information + pub fn update_access(&mut self) { + self.access_count += 1; + self.last_access = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + } +} + +#[derive(Debug, Clone)] +pub struct Frecency { + entries: Vec, + max_size: usize, + file_path: PathBuf, + current_channel: String, + global_mode: bool, +} + +impl Frecency { + pub fn new( + max_size: usize, + channel_name: &str, + global_mode: bool, + data_dir: &Path, + ) -> Self { + let file_path = data_dir.join(FRECENCY_FILE_NAME); + + Self { + entries: Vec::with_capacity(max_size), + max_size, + file_path, + current_channel: channel_name.to_string(), + global_mode, + } + } + + /// Initialize the frecency by loading previously persisted entries from disk. + pub fn init(&mut self) -> Result<()> { + // if max_size is 0, frecency is disabled + if self.max_size > 0 { + self.load_from_file()?; + } + Ok(()) + } + + /// Add or update a frecency entry for a selected item. + pub fn add_entry(&mut self, entry: String, channel: String) -> Result<()> { + if self.max_size == 0 { + return Ok(()); + } + + // Don't add empty entries + if entry.trim().is_empty() { + return Ok(()); + } + + // Check if entry already exists + if let Some(existing) = self + .entries + .iter_mut() + .find(|e| e.entry == entry && e.channel == channel) + { + existing.update_access(); + } else { + // Add new entry + let frecency_entry = FrecencyEntry::new(entry, channel); + self.entries.push(frecency_entry); + + // Trim if exceeding max size - remove oldest entries + if self.entries.len() > self.max_size { + // Sort by last_access and remove the oldest + self.entries.sort_by_key(|e| e.last_access); + self.entries.drain(0..self.entries.len() - self.max_size); + } + } + + Ok(()) + } + + /// Get frecency score for a specific entry (immutable version for sorting) + pub fn get_score(&self, entry: &str) -> Option { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + // Cache channel filter calculation + let channel_filter = if self.global_mode { + None + } else { + Some(self.current_channel.as_str()) + }; + + self.entries + .iter() + .find(|e| { + e.entry == entry + && channel_filter.is_none_or(|ch| e.channel == ch) + }) + .map(|e| e.calculate_score(now)) + } + + /// Check if an entry has frecency data (was previously selected) + pub fn has_entry(&self, entry: &str) -> bool { + // Cache channel filter calculation + let channel_filter = if self.global_mode { + None + } else { + Some(self.current_channel.as_str()) + }; + + self.entries.iter().any(|e| { + e.entry == entry && channel_filter.is_none_or(|ch| e.channel == ch) + }) + } + + /// Get all frecency entries sorted by score (highest first) + pub fn get_sorted_entries(&self) -> Vec<(String, f64)> { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + // Cache channel filter calculation + let channel_filter = if self.global_mode { + None + } else { + Some(self.current_channel.as_str()) + }; + + let mut entries = Vec::with_capacity(self.entries.len()); + entries.extend( + self.entries + .iter() + .filter(|e| channel_filter.is_none_or(|ch| e.channel == ch)) + .map(|e| (e.entry.clone(), e.calculate_score(now))), + ); + + entries.sort_by(|a, b| { + b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal) + }); + entries + } + + /// Get the most recent items for priority matching + /// Returns up to `RECENT_ITEMS_PRIORITY_COUNT` items sorted by recency (newest first) + pub fn get_recent_items(&self) -> Vec { + // Early exit if frecency is disabled or no entries + if self.max_size == 0 || self.entries.is_empty() { + return Vec::new(); + } + + // Cache channel filter calculation + let channel_filter = if self.global_mode { + None + } else { + Some(self.current_channel.as_str()) + }; + + let mut recent_entries: Vec<_> = + Vec::with_capacity(RECENT_ITEMS_PRIORITY_COUNT); + recent_entries.extend( + self.entries + .iter() + .filter(|e| channel_filter.is_none_or(|ch| e.channel == ch)), + ); + + // Early exit if no entries match the channel filter + if recent_entries.is_empty() { + return Vec::new(); + } + + // Sort by last_access timestamp (newest first) + recent_entries.sort_by(|a, b| b.last_access.cmp(&a.last_access)); + + // Take the most recent items up to the configured limit + let mut result = Vec::with_capacity( + RECENT_ITEMS_PRIORITY_COUNT.min(recent_entries.len()), + ); + result.extend( + recent_entries + .into_iter() + .take(RECENT_ITEMS_PRIORITY_COUNT) + .map(|e| e.entry.clone()), + ); + result + } + + fn load_from_file(&mut self) -> Result<()> { + if !self.file_path.exists() { + debug!("Frecency file not found: {}", self.file_path.display()); + return Ok(()); + } + + let content = std::fs::read_to_string(&self.file_path)?; + if content.trim().is_empty() { + debug!("Frecency file is empty: {}", self.file_path.display()); + return Ok(()); + } + + let mut loaded_entries: Vec = + serde_json::from_str(&content)?; + + // Keep only the most recent entries if file is too large + if loaded_entries.len() > self.max_size { + loaded_entries.sort_by_key(|e| e.last_access); + loaded_entries.drain(0..loaded_entries.len() - self.max_size); + } + + self.entries = loaded_entries; + Ok(()) + } + + pub fn save_to_file(&self) -> Result<()> { + if self.max_size == 0 { + debug!("Frecency is disabled, not saving to file."); + return Ok(()); + } + + if let Some(parent) = self.file_path.parent() { + std::fs::create_dir_all(parent)?; + } + + let json_content = serde_json::to_string_pretty(&self.entries)?; + std::fs::write(&self.file_path, json_content)?; + Ok(()) + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn max_size(&self) -> usize { + self.max_size + } + + pub fn current_channel(&self) -> &str { + &self.current_channel + } + + pub fn global_mode(&self) -> bool { + self.global_mode + } + + /// Get all entries in the frecency store. + pub fn get_entries(&self) -> &[FrecencyEntry] { + &self.entries + } + + /// Update the current channel context for this frecency instance. + pub fn update_channel_context( + &mut self, + channel_name: &str, + global_mode: bool, + ) { + self.current_channel = channel_name.to_string(); + self.global_mode = global_mode; + } +} diff --git a/television/lib.rs b/television/lib.rs index 914508f..46ce5ca 100644 --- a/television/lib.rs +++ b/television/lib.rs @@ -8,6 +8,7 @@ pub mod draw; pub mod errors; pub mod event; pub mod features; +pub mod frecency; pub mod gh; pub mod history; pub mod input; diff --git a/television/television.rs b/television/television.rs index ab52ee4..67f2c65 100644 --- a/television/television.rs +++ b/television/television.rs @@ -12,6 +12,7 @@ use crate::{ draw::{ChannelState, Ctx, TvState}, errors::os_error_exit, features::FeatureFlags, + frecency::Frecency, input::convert_action_to_input_request, picker::{Movement, Picker}, previewer::{ @@ -583,7 +584,10 @@ impl Television { Ok(()) } - pub fn update_results_picker_state(&mut self) { + pub fn update_results_picker_state( + &mut self, + frecency: Option<&Frecency>, + ) { if self.results_picker.selected().is_none() && self.channel.result_count() > 0 { @@ -601,7 +605,7 @@ impl Television { // Re-use the existing allocation instead of constructing a new // `Vec` every tick: entries.clear(); - entries.extend(self.channel.results(height, offset)); + entries.extend(self.channel.results(height, offset, frecency)); } self.results_picker.total_items = self.channel.result_count(); } @@ -862,10 +866,14 @@ impl Television { /// Update the television state based on the action provided. /// /// This function may return an Action that'll be processed by the parent `App`. - pub fn update(&mut self, action: &Action) -> Result> { + pub fn update( + &mut self, + action: &Action, + frecency: Option<&Frecency>, + ) -> Result> { self.handle_action(action)?; - self.update_results_picker_state(); + self.update_results_picker_state(frecency); if self.remote_control.is_some() { self.update_rc_picker_state(); From e5303a3052caba10a07fb1ac8421ab5aa8b6dced Mon Sep 17 00:00:00 2001 From: lalvarezt Date: Mon, 28 Jul 2025 13:47:35 +0200 Subject: [PATCH 2/3] refactor(frecency): drop ArcMutex and opt for unbounded batch instead, no need to always lock --- television/channels/channel.rs | 112 ++++++++++++++++++++++----------- 1 file changed, 75 insertions(+), 37 deletions(-) diff --git a/television/channels/channel.rs b/television/channels/channel.rs index be45f3e..7d2b69e 100644 --- a/television/channels/channel.rs +++ b/television/channels/channel.rs @@ -14,10 +14,11 @@ use std::collections::HashSet; use std::io::{BufRead, BufReader}; use std::process::Stdio; use std::sync::{ - Arc, Mutex, + Arc, atomic::{AtomicBool, Ordering}, }; use std::time::Duration; +use tokio::sync::mpsc; use tracing::{debug, trace}; const RELOAD_RENDERING_DELAY: Duration = Duration::from_millis(200); @@ -32,7 +33,9 @@ pub struct Channel { /// by delaying the rendering of a new frame. pub reloading: Arc, /// Track current dataset items as they're loaded for frecency filtering - current_dataset: Arc>>, + current_dataset: FxHashSet, + /// Receiver for batched dataset updates from the loading task + dataset_rx: Option>>, } impl Channel { @@ -47,23 +50,24 @@ impl Channel { crawl_handle: None, current_source_index, reloading: Arc::new(AtomicBool::new(false)), - current_dataset: Arc::new(Mutex::new(FxHashSet::default())), + current_dataset: FxHashSet::default(), + dataset_rx: None, } } pub fn load(&mut self) { // Clear the current dataset at the start of each load - if let Ok(mut dataset) = self.current_dataset.lock() { - dataset.clear(); - } + self.current_dataset.clear(); + + let (dataset_tx, dataset_rx) = mpsc::unbounded_channel(); + self.dataset_rx = Some(dataset_rx); let injector = self.matcher.injector(); - let current_dataset = self.current_dataset.clone(); let crawl_handle = tokio::spawn(load_candidates( self.prototype.source.clone(), self.current_source_index, injector, - current_dataset, + dataset_tx, )); self.crawl_handle = Some(crawl_handle); } @@ -103,27 +107,30 @@ impl Channel { self.matcher.find(pattern); } + /// Try to update the dataset from the loading task if available + fn try_update_dataset(&mut self) { + if let Some(rx) = &mut self.dataset_rx { + // Process all available batches (non-blocking) + while let Ok(batch) = rx.try_recv() { + // Extend current dataset with the new batch + self.current_dataset.extend(batch); + } + } + } + /// Filter recent items to only include those that exist in the current dataset fn filter_recent_items_by_current_dataset( &self, recent_items: &[String], ) -> Vec { - match self.current_dataset.lock() { - Ok(current_dataset) => { - let mut filtered = Vec::with_capacity(recent_items.len()); - filtered.extend( - recent_items - .iter() - .filter(|item| current_dataset.contains(*item)) - .cloned(), - ); - filtered - } - Err(_) => { - // If we can't lock, return empty to prevent inconsistent results - Vec::new() - } - } + let mut filtered = Vec::with_capacity(recent_items.len()); + filtered.extend( + recent_items + .iter() + .filter(|item| self.current_dataset.contains(*item)) + .cloned(), + ); + filtered } /// Fuzzy match against a list of recent items @@ -165,6 +172,9 @@ impl Channel { offset: u32, frecency: Option<&Frecency>, ) -> Vec { + // Try to update dataset from loading task + self.try_update_dataset(); + self.matcher.tick(); let results = if let Some(frecency_data) = frecency { @@ -315,15 +325,28 @@ impl Channel { } const DEFAULT_LINE_BUFFER_SIZE: usize = 512; +const BATCH_SIZE: usize = 100; + +/// Helper function to send a batch if it's not empty +fn send_batch_if_not_empty( + batch: &mut Vec, + dataset_tx: &mpsc::UnboundedSender>, +) { + if !batch.is_empty() { + let _ = dataset_tx.send(std::mem::take(batch)); + } +} #[allow(clippy::unused_async)] async fn load_candidates( source: SourceSpec, source_command_index: usize, injector: Injector, - current_dataset: Arc>>, + dataset_tx: mpsc::UnboundedSender>, ) { debug!("Loading candidates from command: {:?}", source.command); + let mut current_batch = Vec::with_capacity(BATCH_SIZE); + let mut child = shell_command( source.command.get_nth(source_command_index).raw(), source.command.interactive, @@ -368,9 +391,15 @@ async fn load_candidates( if !l.trim().is_empty() { let entry = l.to_string(); - // Track this item in our current dataset - if let Ok(mut dataset) = current_dataset.lock() { - dataset.insert(entry.clone()); + // Add to current batch + current_batch.push(entry.clone()); + + // Send batch if it reaches the batch size + if current_batch.len() >= BATCH_SIZE { + send_batch_if_not_empty( + &mut current_batch, + &dataset_tx, + ); } let () = injector.push(entry, |e, cols| { @@ -405,9 +434,15 @@ async fn load_candidates( for line in reader.lines() { let line = line.unwrap(); if !line.trim().is_empty() { - // Track this item in our current dataset - if let Ok(mut dataset) = current_dataset.lock() { - dataset.insert(line.clone()); + // Add to current batch + current_batch.push(line.clone()); + + // Send batch if it reaches the batch size + if current_batch.len() >= BATCH_SIZE { + send_batch_if_not_empty( + &mut current_batch, + &dataset_tx, + ); } let () = injector.push(line, |e, cols| { @@ -418,6 +453,9 @@ async fn load_candidates( } } let _ = child.wait(); + + // Send any remaining entries in the final batch + send_batch_if_not_empty(&mut current_batch, &dataset_tx); } #[cfg(test)] @@ -436,9 +474,9 @@ mod tests { let mut matcher = Matcher::::new(&Config::default()); let injector = matcher.injector(); - let current_dataset = Arc::new(Mutex::new(FxHashSet::default())); + let (dataset_tx, _dataset_rx) = mpsc::unbounded_channel(); - load_candidates(source_spec, 0, injector, current_dataset).await; + load_candidates(source_spec, 0, injector, dataset_tx).await; // Check if the matcher has the expected results matcher.find("test"); @@ -460,9 +498,9 @@ mod tests { let mut matcher = Matcher::::new(&Config::default()); let injector = matcher.injector(); - let current_dataset = Arc::new(Mutex::new(FxHashSet::default())); + let (dataset_tx, _dataset_rx) = mpsc::unbounded_channel(); - load_candidates(source_spec, 0, injector, current_dataset).await; + load_candidates(source_spec, 0, injector, dataset_tx).await; // Check if the matcher has the expected results matcher.find("test"); @@ -484,9 +522,9 @@ mod tests { let mut matcher = Matcher::::new(&Config::default()); let injector = matcher.injector(); - let current_dataset = Arc::new(Mutex::new(FxHashSet::default())); + let (dataset_tx, _dataset_rx) = mpsc::unbounded_channel(); - load_candidates(source_spec, 0, injector, current_dataset).await; + load_candidates(source_spec, 0, injector, dataset_tx).await; // Check if the matcher has the expected results matcher.find("test"); From acba4b5f1100783902656e2ee266c3404087a8fc Mon Sep 17 00:00:00 2001 From: lalvarezt Date: Mon, 28 Jul 2025 13:09:53 +0200 Subject: [PATCH 3/3] fix(preview): move preview config logic out of the channel --- television/channels/channel.rs | 16 +++---------- television/television.rs | 43 ++++++++++++++++++++++++++++++---- 2 files changed, 41 insertions(+), 18 deletions(-) diff --git a/television/channels/channel.rs b/television/channels/channel.rs index 7d2b69e..b445c4a 100644 --- a/television/channels/channel.rs +++ b/television/channels/channel.rs @@ -256,20 +256,10 @@ impl Channel { let item = self.matcher.get_result(index); if let Some(item) = item { - let mut entry = Entry::new(item.inner.clone()) + let entry = Entry::new(item.inner.clone()) .with_display(item.matched_string) - .with_match_indices(&item.match_indices); - if let Some(p) = &self.prototype.preview { - // FIXME: this should be done by the previewer instead - if let Some(offset_expr) = &p.offset { - let offset_str = - offset_expr.format(&item.inner).unwrap_or_default(); - - entry = entry.with_line_number( - offset_str.parse::().unwrap_or(0), - ); - } - } + .with_match_indices(&item.match_indices) + .ansi(self.prototype.source.ansi); Some(entry) } else { None diff --git a/television/television.rs b/television/television.rs index 67f2c65..2c3a417 100644 --- a/television/television.rs +++ b/television/television.rs @@ -364,12 +364,45 @@ impl Television { } pub fn get_selected_entry(&mut self) -> Option { - if self.channel.result_count() == 0 { - return None; + match self.mode { + Mode::Channel => { + let entry = self + .results_picker + .selected() + .and_then(|idx| self.results_picker.entries.get(idx)) + .cloned()?; + Some(self.apply_preview_offset(entry)) + } + Mode::RemoteControl => { + if self + .remote_control + .as_ref() + .map_or(0, RemoteControl::result_count) + == 0 + { + return None; + } + let entry = self + .selected_index() + .map(|idx| self.channel.get_result(idx)) + .and_then(|entry| entry)?; + Some(self.apply_preview_offset(entry)) + } } - self.selected_index() - .map(|idx| self.channel.get_result(idx)) - .and_then(|entry| entry) + } + + /// Apply preview offset logic to an entry + fn apply_preview_offset(&self, mut entry: Entry) -> Entry { + if let Some(p) = &self.channel.prototype.preview { + if let Some(offset_expr) = &p.offset { + let offset_str = + offset_expr.format(&entry.raw).unwrap_or_default(); + entry = entry.with_line_number( + offset_str.parse::().unwrap_or(0), + ); + } + } + entry } pub fn get_selected_cable_entry(&mut self) -> Option {