Merge acba4b5f1100783902656e2ee266c3404087a8fc into 83f29f7418a7adb6f5ca9ee5ac057ef38728fa28

This commit is contained in:
LM 2025-07-28 14:38:53 +02:00 committed by GitHub
commit 2afe225886
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 804 additions and 40 deletions

View File

@ -491,13 +491,13 @@ pub fn draw(c: &mut Criterion) {
tv.find("television"); tv.find("television");
for _ in 0..5 { for _ in 0..5 {
// tick the matcher // 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)); std::thread::sleep(std::time::Duration::from_millis(10));
} }
tv.move_cursor(Movement::Next, 10); tv.move_cursor(Movement::Next, 10);
let selected_entry = tv.get_selected_entry(); let selected_entry = tv.get_selected_entry();
let _ = tv.update_preview_state(&selected_entry); let _ = tv.update_preview_state(&selected_entry);
let _ = tv.update(&Action::Tick); let _ = tv.update(&Action::Tick, None);
(tv, terminal) (tv, terminal)
}, },
// Measurement // Measurement

View File

@ -4,7 +4,7 @@
.SH NAME .SH NAME
television \- Cross\-platform, fast and extensible general purpose fuzzy finder TUI. television \- Cross\-platform, fast and extensible general purpose fuzzy finder TUI.
.SH SYNOPSIS .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 .SH DESCRIPTION
Cross\-platform, fast and extensible general purpose fuzzy finder TUI. Cross\-platform, fast and extensible general purpose fuzzy finder TUI.
.SH OPTIONS .SH OPTIONS
@ -84,6 +84,16 @@ expressions using the configuration file formalism.
Example: `tv \-\-keybindings=\*(Aqquit="esc";select_next_entry=["down","ctrl\-j"]\*(Aq` Example: `tv \-\-keybindings=\*(Aqquit="esc";select_next_entry=["down","ctrl\-j"]\*(Aq`
.TP .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<selected_entry>` when `ctrl\-q` is
pressed to confirm the selection.
.TP
\fB\-i\fR, \fB\-\-input\fR=\fISTRING\fR \fB\-i\fR, \fB\-\-input\fR=\fISTRING\fR
Input text to pass to the channel to prefill the prompt. 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. The given value is used as the prompt string shown before the input field.
Defaults to ">" when omitted. Defaults to ">" when omitted.
.TP .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 \fB\-\-preview\-header\fR=\fISTRING\fR
Preview header template 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 The given value is parsed as a `MultiTemplate`. It is evaluated for every
entry and its result is displayed below the preview panel. entry and its result is displayed below the preview panel.
.TP .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 \fB\-s\fR, \fB\-\-source\-command\fR=\fISTRING\fR
Source command to use for the current channel. 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. The template is used to format the final output when an entry is selected.
Example: "{}" (output the full entry) Example: "{}" (output the full entry)
.TP .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 \fB\-\-source\-entry\-delimiter\fR=\fISTRING\fR
The delimiter byte to use for splitting the source\*(Aqs command output into entries. 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 enabled, history navigation will show entries from all channels.
When disabled (default), history navigation is scoped to the current channel. When disabled (default), history navigation is scoped to the current channel.
.TP .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 \fB\-\-height\fR=\fIINTEGER\fR
Height in lines for non\-fullscreen mode. Height in lines for non\-fullscreen mode.

View File

@ -5,6 +5,7 @@ use crate::{
cli::PostProcessedCli, cli::PostProcessedCli,
config::{Config, DEFAULT_PREVIEW_SIZE, default_tick_rate}, config::{Config, DEFAULT_PREVIEW_SIZE, default_tick_rate},
event::{Event, EventLoop, InputEvent, Key, MouseInputEvent}, event::{Event, EventLoop, InputEvent, Key, MouseInputEvent},
frecency::Frecency,
history::History, history::History,
keymap::InputMap, keymap::InputMap,
render::{RenderingTask, UiState, render}, render::{RenderingTask, UiState, render},
@ -13,6 +14,7 @@ use crate::{
}; };
use anyhow::Result; use anyhow::Result;
use rustc_hash::FxHashSet; use rustc_hash::FxHashSet;
use std::path::Path;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tracing::{debug, error, trace}; use tracing::{debug, error, trace};
@ -138,6 +140,8 @@ pub struct App {
watch_timer_task: Option<tokio::task::JoinHandle<()>>, watch_timer_task: Option<tokio::task::JoinHandle<()>>,
/// Global history for selected entries /// Global history for selected entries
history: History, history: History,
/// Frecency tracking for selected entries
frecency: Frecency,
} }
/// The outcome of an action. /// The outcome of an action.
@ -223,6 +227,16 @@ impl App {
error!("Failed to initialize history: {}", e); 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 { let mut app = Self {
input_map, input_map,
television, television,
@ -240,6 +254,7 @@ impl App {
options, options,
watch_timer_task: None, watch_timer_task: None,
history, history,
frecency,
}; };
// populate input_map by going through all cable channels and adding their shortcuts if remote // 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, &channel_prototype.metadata.name,
global_mode, 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. /// Run the application main loop.
@ -463,6 +490,11 @@ impl App {
error!("Failed to persist history: {}", e); 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 // wait for the rendering task to finish
if let Some(rendering_task) = self.render_task.take() { if let Some(rendering_task) = self.render_task.take() {
rendering_task.await?.expect("Rendering task failed"); rendering_task.await?.expect("Rendering task failed");
@ -605,6 +637,20 @@ impl App {
query, query,
self.television.current_channel(), 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)); return Ok(ActionOutcome::Entries(entries));
} }
@ -627,6 +673,20 @@ impl App {
query, query,
self.television.current_channel(), 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( return Ok(ActionOutcome::EntriesWithExpect(
entries, k, entries, k,
)); ));
@ -676,7 +736,14 @@ impl App {
self.television.mode == Mode::RemoteControl; self.television.mode == Mode::RemoteControl;
// forward action to the television handler // 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)?; 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. /// Determine the TUI mode based on the provided options.
fn determine_tui_mode( fn determine_tui_mode(
height: Option<u16>, height: Option<u16>,

View File

@ -3,16 +3,22 @@ use crate::{
entry::Entry, entry::Entry,
prototypes::{ChannelPrototype, SourceSpec, Template}, 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, utils::command::shell_command,
}; };
use rustc_hash::{FxBuildHasher, FxHashSet}; use rustc_hash::{FxBuildHasher, FxHashSet};
use std::collections::HashSet; use std::collections::HashSet;
use std::io::{BufRead, BufReader}; use std::io::{BufRead, BufReader};
use std::process::Stdio; use std::process::Stdio;
use std::sync::Arc; use std::sync::{
use std::sync::atomic::AtomicBool; Arc,
atomic::{AtomicBool, Ordering},
};
use std::time::Duration; use std::time::Duration;
use tokio::sync::mpsc;
use tracing::{debug, trace}; use tracing::{debug, trace};
const RELOAD_RENDERING_DELAY: Duration = Duration::from_millis(200); const RELOAD_RENDERING_DELAY: Duration = Duration::from_millis(200);
@ -26,6 +32,10 @@ pub struct Channel {
/// Indicates if the channel is currently reloading to prevent UI flickering /// Indicates if the channel is currently reloading to prevent UI flickering
/// by delaying the rendering of a new frame. /// by delaying the rendering of a new frame.
pub reloading: Arc<AtomicBool>, pub reloading: Arc<AtomicBool>,
/// Track current dataset items as they're loaded for frecency filtering
current_dataset: FxHashSet<String>,
/// Receiver for batched dataset updates from the loading task
dataset_rx: Option<mpsc::UnboundedReceiver<Vec<String>>>,
} }
impl Channel { impl Channel {
@ -40,26 +50,34 @@ impl Channel {
crawl_handle: None, crawl_handle: None,
current_source_index, current_source_index,
reloading: Arc::new(AtomicBool::new(false)), reloading: Arc::new(AtomicBool::new(false)),
current_dataset: FxHashSet::default(),
dataset_rx: None,
} }
} }
pub fn load(&mut self) { pub fn load(&mut self) {
// Clear the current dataset at the start of each load
self.current_dataset.clear();
let (dataset_tx, dataset_rx) = mpsc::unbounded_channel();
self.dataset_rx = Some(dataset_rx);
let injector = self.matcher.injector(); let injector = self.matcher.injector();
let crawl_handle = tokio::spawn(load_candidates( let crawl_handle = tokio::spawn(load_candidates(
self.prototype.source.clone(), self.prototype.source.clone(),
self.current_source_index, self.current_source_index,
injector, injector,
dataset_tx,
)); ));
self.crawl_handle = Some(crawl_handle); self.crawl_handle = Some(crawl_handle);
} }
pub fn reload(&mut self) { 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."); debug!("Reload already in progress, skipping.");
return; return;
} }
self.reloading self.reloading.store(true, Ordering::Relaxed);
.store(true, std::sync::atomic::Ordering::Relaxed);
if let Some(handle) = self.crawl_handle.take() { if let Some(handle) = self.crawl_handle.take() {
if !handle.is_finished() { if !handle.is_finished() {
@ -73,7 +91,7 @@ impl Channel {
let reloading = self.reloading.clone(); let reloading = self.reloading.clone();
tokio::spawn(async move { tokio::spawn(async move {
tokio::time::sleep(RELOAD_RENDERING_DELAY).await; tokio::time::sleep(RELOAD_RENDERING_DELAY).await;
reloading.store(false, std::sync::atomic::Ordering::Relaxed); reloading.store(false, Ordering::Relaxed);
}); });
} }
@ -89,10 +107,137 @@ impl Channel {
self.matcher.find(pattern); self.matcher.find(pattern);
} }
pub fn results(&mut self, num_entries: u32, offset: u32) -> Vec<Entry> { /// 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<String> {
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
#[allow(clippy::cast_possible_truncation)]
fn fuzzy_match_recent_items(
pattern: &str,
recent_items: &[String],
) -> Vec<MatchedItem<String>> {
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<Entry> {
// Try to update dataset from loading task
self.try_update_dataset();
self.matcher.tick(); 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()); let mut entries = Vec::with_capacity(results.len());
@ -108,21 +253,13 @@ impl Channel {
} }
pub fn get_result(&mut self, index: u32) -> Option<Entry> { pub fn get_result(&mut self, index: u32) -> Option<Entry> {
if let Some(item) = self.matcher.get_result(index) { let item = self.matcher.get_result(index);
let mut 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( if let Some(item) = item {
offset_str.parse::<usize>().unwrap_or(0), let entry = Entry::new(item.inner.clone())
); .with_display(item.matched_string)
} .with_match_indices(&item.match_indices)
} .ansi(self.prototype.source.ansi);
Some(entry) Some(entry)
} else { } else {
None None
@ -178,14 +315,28 @@ impl Channel {
} }
const DEFAULT_LINE_BUFFER_SIZE: usize = 512; 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<String>,
dataset_tx: &mpsc::UnboundedSender<Vec<String>>,
) {
if !batch.is_empty() {
let _ = dataset_tx.send(std::mem::take(batch));
}
}
#[allow(clippy::unused_async)] #[allow(clippy::unused_async)]
async fn load_candidates( async fn load_candidates(
source: SourceSpec, source: SourceSpec,
source_command_index: usize, source_command_index: usize,
injector: Injector<String>, injector: Injector<String>,
dataset_tx: mpsc::UnboundedSender<Vec<String>>,
) { ) {
debug!("Loading candidates from command: {:?}", source.command); debug!("Loading candidates from command: {:?}", source.command);
let mut current_batch = Vec::with_capacity(BATCH_SIZE);
let mut child = shell_command( let mut child = shell_command(
source.command.get_nth(source_command_index).raw(), source.command.get_nth(source_command_index).raw(),
source.command.interactive, source.command.interactive,
@ -228,7 +379,20 @@ async fn load_candidates(
if let Ok(l) = std::str::from_utf8(&buf) { if let Ok(l) = std::str::from_utf8(&buf) {
trace!("Read line: {}", l); trace!("Read line: {}", l);
if !l.trim().is_empty() { if !l.trim().is_empty() {
let () = injector.push(l.to_string(), |e, cols| { let entry = l.to_string();
// 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| {
if source.ansi { if source.ansi {
cols[0] = strip_ansi.format(e).unwrap_or_else(|_| { cols[0] = strip_ansi.format(e).unwrap_or_else(|_| {
panic!( panic!(
@ -260,6 +424,17 @@ async fn load_candidates(
for line in reader.lines() { for line in reader.lines() {
let line = line.unwrap(); let line = line.unwrap();
if !line.trim().is_empty() { if !line.trim().is_empty() {
// 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| { let () = injector.push(line, |e, cols| {
cols[0] = e.clone().into(); cols[0] = e.clone().into();
}); });
@ -268,6 +443,9 @@ async fn load_candidates(
} }
} }
let _ = child.wait(); let _ = child.wait();
// Send any remaining entries in the final batch
send_batch_if_not_empty(&mut current_batch, &dataset_tx);
} }
#[cfg(test)] #[cfg(test)]
@ -286,8 +464,9 @@ mod tests {
let mut matcher = Matcher::<String>::new(&Config::default()); let mut matcher = Matcher::<String>::new(&Config::default());
let injector = matcher.injector(); let injector = matcher.injector();
let (dataset_tx, _dataset_rx) = mpsc::unbounded_channel();
load_candidates(source_spec, 0, injector).await; load_candidates(source_spec, 0, injector, dataset_tx).await;
// Check if the matcher has the expected results // Check if the matcher has the expected results
matcher.find("test"); matcher.find("test");
@ -309,8 +488,9 @@ mod tests {
let mut matcher = Matcher::<String>::new(&Config::default()); let mut matcher = Matcher::<String>::new(&Config::default());
let injector = matcher.injector(); let injector = matcher.injector();
let (dataset_tx, _dataset_rx) = mpsc::unbounded_channel();
load_candidates(source_spec, 0, injector).await; load_candidates(source_spec, 0, injector, dataset_tx).await;
// Check if the matcher has the expected results // Check if the matcher has the expected results
matcher.find("test"); matcher.find("test");
@ -332,8 +512,9 @@ mod tests {
let mut matcher = Matcher::<String>::new(&Config::default()); let mut matcher = Matcher::<String>::new(&Config::default());
let injector = matcher.injector(); let injector = matcher.injector();
let (dataset_tx, _dataset_rx) = mpsc::unbounded_channel();
load_candidates(source_spec, 0, injector).await; load_candidates(source_spec, 0, injector, dataset_tx).await;
// Check if the matcher has the expected results // Check if the matcher has the expected results
matcher.find("test"); matcher.find("test");

View File

@ -183,6 +183,16 @@ pub struct HistoryConfig {
pub global_mode: Option<bool>, pub global_mode: Option<bool>,
} }
#[derive(Default, Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct FrecencyConfig {
/// Whether to enable frecency scoring for this channel
#[serde(default)]
pub enabled: Option<bool>,
/// Whether to use global frecency for this channel (overrides global setting)
#[serde(default)]
pub global_mode: Option<bool>,
}
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] #[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct ChannelPrototype { pub struct ChannelPrototype {
pub metadata: Metadata, pub metadata: Metadata,
@ -198,6 +208,8 @@ pub struct ChannelPrototype {
pub watch: f64, pub watch: f64,
#[serde(default)] #[serde(default)]
pub history: HistoryConfig, pub history: HistoryConfig,
#[serde(default)]
pub frecency: FrecencyConfig,
// actions: Vec<Action>, // actions: Vec<Action>,
} }
@ -228,6 +240,7 @@ impl ChannelPrototype {
keybindings: None, keybindings: None,
watch: 0.0, watch: 0.0,
history: HistoryConfig::default(), history: HistoryConfig::default(),
frecency: FrecencyConfig::default(),
} }
} }
@ -259,6 +272,7 @@ impl ChannelPrototype {
keybindings: None, keybindings: None,
watch: 0.0, watch: 0.0,
history: HistoryConfig::default(), history: HistoryConfig::default(),
frecency: FrecencyConfig::default(),
} }
} }

View File

@ -489,6 +489,24 @@ pub struct Cli {
#[arg(long, verbatim_doc_comment)] #[arg(long, verbatim_doc_comment)]
pub global_history: bool, 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. /// Height in lines for non-fullscreen mode.
/// ///
/// This flag works identically in both channel mode and ad-hoc mode. /// This flag works identically in both channel mode and ad-hoc mode.

View File

@ -119,6 +119,10 @@ pub struct PostProcessedCli {
// History configuration // History configuration
pub global_history: bool, pub global_history: bool,
// Frecency
pub frecency: bool,
pub global_frecency: bool,
// Configuration sources // Configuration sources
pub config_file: Option<PathBuf>, pub config_file: Option<PathBuf>,
pub cable_dir: Option<PathBuf>, pub cable_dir: Option<PathBuf>,
@ -201,6 +205,10 @@ impl Default for PostProcessedCli {
// History configuration // History configuration
global_history: false, global_history: false,
// Frecency
frecency: false,
global_frecency: false,
// Configuration sources // Configuration sources
config_file: None, config_file: None,
cable_dir: None, cable_dir: None,
@ -432,6 +440,10 @@ pub fn post_process(cli: Cli, readable_stdin: bool) -> PostProcessedCli {
// History configuration // History configuration
global_history: cli.global_history, global_history: cli.global_history,
// Frecency
frecency: cli.frecency,
global_frecency: cli.global_frecency,
// Configuration sources // Configuration sources
config_file: cli.config_file.map(|p| expand_tilde(&p)), config_file: cli.config_file.map(|p| expand_tilde(&p)),
cable_dir: cli.cable_dir.map(|p| expand_tilde(&p)), cable_dir: cli.cable_dir.map(|p| expand_tilde(&p)),

328
television/frecency.rs Normal file
View File

@ -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<FrecencyEntry>,
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<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())
};
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<String> {
// 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<FrecencyEntry> =
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;
}
}

View File

@ -8,6 +8,7 @@ pub mod draw;
pub mod errors; pub mod errors;
pub mod event; pub mod event;
pub mod features; pub mod features;
pub mod frecency;
pub mod gh; pub mod gh;
pub mod history; pub mod history;
pub mod input; pub mod input;

View File

@ -12,6 +12,7 @@ use crate::{
draw::{ChannelState, Ctx, TvState}, draw::{ChannelState, Ctx, TvState},
errors::os_error_exit, errors::os_error_exit,
features::FeatureFlags, features::FeatureFlags,
frecency::Frecency,
input::convert_action_to_input_request, input::convert_action_to_input_request,
picker::{Movement, Picker}, picker::{Movement, Picker},
previewer::{ previewer::{
@ -363,12 +364,45 @@ impl Television {
} }
pub fn get_selected_entry(&mut self) -> Option<Entry> { pub fn get_selected_entry(&mut self) -> Option<Entry> {
if self.channel.result_count() == 0 { match self.mode {
return None; 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::<usize>().unwrap_or(0),
);
}
}
entry
} }
pub fn get_selected_cable_entry(&mut self) -> Option<CableEntry> { pub fn get_selected_cable_entry(&mut self) -> Option<CableEntry> {
@ -583,7 +617,10 @@ impl Television {
Ok(()) 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() if self.results_picker.selected().is_none()
&& self.channel.result_count() > 0 && self.channel.result_count() > 0
{ {
@ -601,7 +638,7 @@ impl Television {
// Re-use the existing allocation instead of constructing a new // Re-use the existing allocation instead of constructing a new
// `Vec` every tick: // `Vec` every tick:
entries.clear(); 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(); self.results_picker.total_items = self.channel.result_count();
} }
@ -862,10 +899,14 @@ impl Television {
/// Update the television state based on the action provided. /// Update the television state based on the action provided.
/// ///
/// This function may return an Action that'll be processed by the parent `App`. /// This function may return an Action that'll be processed by the parent `App`.
pub fn update(&mut self, action: &Action) -> Result<Option<Action>> { pub fn update(
&mut self,
action: &Action,
frecency: Option<&Frecency>,
) -> Result<Option<Action>> {
self.handle_action(action)?; self.handle_action(action)?;
self.update_results_picker_state(); self.update_results_picker_state(frecency);
if self.remote_control.is_some() { if self.remote_control.is_some() {
self.update_rc_picker_state(); self.update_rc_picker_state();