mirror of
https://github.com/alexpasmantier/television.git
synced 2025-07-29 06:11:37 +00:00
Merge acba4b5f1100783902656e2ee266c3404087a8fc into 83f29f7418a7adb6f5ca9ee5ac057ef38728fa28
This commit is contained in:
commit
2afe225886
@ -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
|
||||
|
76
man/tv.1
76
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<selected_entry>` 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.
|
||||
|
||||
|
@ -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<tokio::task::JoinHandle<()>>,
|
||||
/// 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<u16>,
|
||||
|
@ -3,16 +3,22 @@ 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,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, trace};
|
||||
|
||||
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
|
||||
/// by delaying the rendering of a new frame.
|
||||
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 {
|
||||
@ -40,26 +50,34 @@ impl Channel {
|
||||
crawl_handle: None,
|
||||
current_source_index,
|
||||
reloading: Arc::new(AtomicBool::new(false)),
|
||||
current_dataset: FxHashSet::default(),
|
||||
dataset_rx: None,
|
||||
}
|
||||
}
|
||||
|
||||
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 crawl_handle = tokio::spawn(load_candidates(
|
||||
self.prototype.source.clone(),
|
||||
self.current_source_index,
|
||||
injector,
|
||||
dataset_tx,
|
||||
));
|
||||
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 +91,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 +107,137 @@ impl Channel {
|
||||
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();
|
||||
|
||||
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,21 +253,13 @@ impl Channel {
|
||||
}
|
||||
|
||||
pub fn get_result(&mut self, index: u32) -> Option<Entry> {
|
||||
if let Some(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();
|
||||
let item = self.matcher.get_result(index);
|
||||
|
||||
entry = entry.with_line_number(
|
||||
offset_str.parse::<usize>().unwrap_or(0),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(item) = item {
|
||||
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)
|
||||
} else {
|
||||
None
|
||||
@ -178,14 +315,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<String>,
|
||||
dataset_tx: &mpsc::UnboundedSender<Vec<String>>,
|
||||
) {
|
||||
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<String>,
|
||||
dataset_tx: mpsc::UnboundedSender<Vec<String>>,
|
||||
) {
|
||||
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,
|
||||
@ -228,7 +379,20 @@ 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();
|
||||
|
||||
// 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 {
|
||||
cols[0] = strip_ansi.format(e).unwrap_or_else(|_| {
|
||||
panic!(
|
||||
@ -260,6 +424,17 @@ async fn load_candidates(
|
||||
for line in reader.lines() {
|
||||
let line = line.unwrap();
|
||||
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| {
|
||||
cols[0] = e.clone().into();
|
||||
});
|
||||
@ -268,6 +443,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)]
|
||||
@ -286,8 +464,9 @@ mod tests {
|
||||
|
||||
let mut matcher = Matcher::<String>::new(&Config::default());
|
||||
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
|
||||
matcher.find("test");
|
||||
@ -309,8 +488,9 @@ mod tests {
|
||||
|
||||
let mut matcher = Matcher::<String>::new(&Config::default());
|
||||
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
|
||||
matcher.find("test");
|
||||
@ -332,8 +512,9 @@ mod tests {
|
||||
|
||||
let mut matcher = Matcher::<String>::new(&Config::default());
|
||||
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
|
||||
matcher.find("test");
|
||||
|
@ -183,6 +183,16 @@ pub struct HistoryConfig {
|
||||
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)]
|
||||
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<Action>,
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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.
|
||||
|
@ -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<PathBuf>,
|
||||
pub cable_dir: Option<PathBuf>,
|
||||
@ -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)),
|
||||
|
328
television/frecency.rs
Normal file
328
television/frecency.rs
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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::{
|
||||
@ -363,12 +364,45 @@ impl Television {
|
||||
}
|
||||
|
||||
pub fn get_selected_entry(&mut self) -> Option<Entry> {
|
||||
if self.channel.result_count() == 0 {
|
||||
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;
|
||||
}
|
||||
self.selected_index()
|
||||
let entry = self
|
||||
.selected_index()
|
||||
.map(|idx| self.channel.get_result(idx))
|
||||
.and_then(|entry| entry)
|
||||
.and_then(|entry| entry)?;
|
||||
Some(self.apply_preview_offset(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> {
|
||||
@ -583,7 +617,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 +638,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 +899,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<Option<Action>> {
|
||||
pub fn update(
|
||||
&mut self,
|
||||
action: &Action,
|
||||
frecency: Option<&Frecency>,
|
||||
) -> Result<Option<Action>> {
|
||||
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();
|
||||
|
Loading…
x
Reference in New Issue
Block a user