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");
|
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
|
||||||
|
76
man/tv.1
76
man/tv.1
@ -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.
|
||||||
|
|
||||||
|
@ -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>,
|
||||||
|
@ -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");
|
||||||
|
@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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.
|
||||||
|
@ -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
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 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;
|
||||||
|
@ -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();
|
||||||
|
Loading…
x
Reference in New Issue
Block a user