refactor: television is going the cable-only route

This commit is contained in:
Alexandre Pasmantier 2025-04-23 21:33:00 +02:00 committed by Alexandre Pasmantier
parent 1f0c178a2d
commit ae1dd99118
24 changed files with 223 additions and 2054 deletions

View File

@ -8,12 +8,12 @@ use ratatui::prelude::{Line, Style};
use ratatui::style::Color; use ratatui::style::Color;
use ratatui::widgets::{Block, BorderType, Borders, ListDirection, Padding}; use ratatui::widgets::{Block, BorderType, Borders, ListDirection, Padding};
use ratatui::Terminal; use ratatui::Terminal;
use std::path::PathBuf;
use television::action::Action; use television::action::Action;
use television::channels::cable::CableChannelPrototype;
use television::channels::entry::into_ranges; use television::channels::entry::into_ranges;
use television::channels::entry::{Entry, PreviewType}; use television::channels::entry::{Entry, PreviewType};
use television::channels::OnAir; use television::channels::OnAir;
use television::channels::{files::Channel, TelevisionChannel}; use television::channels::TelevisionChannel;
use television::config::{Config, ConfigEnv}; use television::config::{Config, ConfigEnv};
use television::screen::colors::ResultsColorscheme; use television::screen::colors::ResultsColorscheme;
use television::screen::results::build_results_list; use television::screen::results::build_results_list;
@ -506,10 +506,9 @@ pub fn draw(c: &mut Criterion) {
let backend = TestBackend::new(width, height); let backend = TestBackend::new(width, height);
let terminal = Terminal::new(backend).unwrap(); let terminal = Terminal::new(backend).unwrap();
let (tx, _) = tokio::sync::mpsc::unbounded_channel(); let (tx, _) = tokio::sync::mpsc::unbounded_channel();
let mut channel = let mut channel = TelevisionChannel::Cable(
TelevisionChannel::Files(Channel::new(vec![ CableChannelPrototype::default().into(),
PathBuf::from("."), );
]));
channel.find("television"); channel.find("television");
// Wait for the channel to finish loading // Wait for the channel to finish loading
let mut tv = Television::new( let mut tv = Television::new(

View File

@ -1,4 +1,34 @@
# Files
[[cable_channel]]
name = "files"
source_command = "fd -t f"
preview_command = ":files:"
# Directories
[[cable_channel]]
name = "dirs"
source_command = "fd -t d"
preview_command = "ls -la --color=always {}"
# Environment variables
[[cable_channel]]
name = "env"
source_command = "printenv"
preview_command = "cut -d= -f2 <<< ${0} | cut -d\" \" -f2- | sed 's/:/\\n/g'"
# Aliases
[[cable_channel]]
name = "aliases"
source_command = "alias"
interactive = true
# GIT # GIT
[[cable_channel]]
name = "git-repos"
# this is a MacOS version but feel free to change it to fit your needs
source_command = "fd -g .git -HL -t d -d 10 --prune ~ -E 'Library' -E 'Application Support' --exec dirname {}"
preview_command = "cd {} && git log -n 200 --pretty=medium --all --graph --color"
[[cable_channel]] [[cable_channel]]
name = "git-diff" name = "git-diff"
source_command = "git diff --name-only" source_command = "git diff --name-only"

View File

@ -1,41 +1,73 @@
# Files
[[cable_channel]]
name = "files"
source_command = "Get-ChildItem -Recurse -File"
preview_command = ":files:"
# Directories
[[cable_channel]]
name = "dirs"
source_command = "Get-ChildItem -Recurse -Directory"
preview_command = "Get-ChildItem -Force -LiteralPath '{}' | Format-List"
# Environment variables
[[cable_channel]]
name = "env"
source_command = "Get-ChildItem Env:"
preview_command = "$env:{0} -split ';'"
# Aliases
[[cable_channel]]
name = "aliases"
source_command = "Get-Alias"
interactive = true
# GIT # GIT
[[cable_channel]]
name = "git-repos"
source_command = "Get-ChildItem -Recurse -Directory -Force -ErrorAction SilentlyContinue | Where-Object { Test-Path \"$($_.FullName)\\.git\" } | Select-Object -ExpandProperty FullName"
preview_command = "cd '{}' ; git log -n 200 --pretty=medium --all --graph --color"
[[cable_channel]] [[cable_channel]]
name = "git-diff" name = "git-diff"
source_command = "git diff --name-only" source_command = "git diff --name-only"
preview_command = "git diff --color=always -- {0}" preview_command = "git diff --color=always {0}"
[[cable_channel]] [[cable_channel]]
name = "git-reflog" name = "git-reflog"
source_command = 'git reflog' source_command = "git reflog"
preview_command = 'git show -p --stat --pretty=fuller --color=always {0}' preview_command = "git show -p --stat --pretty=fuller --color=always {0}"
[[cable_channel]] [[cable_channel]]
name = "git-log" name = "git-log"
source_command = "git log --oneline --date=short --pretty=\"format:%h %s %an %cd\" \"$@\"" source_command = "git log --oneline --date=short --pretty='format:%h %s %an %cd'"
preview_command = "git show -p --stat --pretty=fuller --color=always {0}" preview_command = "git show -p --stat --pretty=fuller --color=always {0}"
[[cable_channel]] [[cable_channel]]
name = "git-branch" name = "git-branch"
source_command = "git --no-pager branch --all --format=\"%(refname:short)\"" source_command = "git branch --all --format='%(refname:short)'"
preview_command = "git show -p --stat --pretty=fuller --color=always {0}" preview_command = "git show -p --stat --pretty=fuller --color=always {0}"
# Docker # Docker
[[cable_channel]] [[cable_channel]]
name = "docker-images" name = "docker-images"
source_command = "docker image list --format \"{{.ID}}\"" source_command = "docker image ls --format '{{.ID}}'"
preview_command = "docker image inspect {0} | jq -C" preview_command = "docker image inspect {0} | jq -C"
# S3
[[cable_channel]]
name = "s3-buckets"
source_command = "aws s3 ls | ForEach-Object { ($_ -split '\s+')[-1] }"
preview_command = "aws s3 ls s3://{0}"
# Dotfiles (adapted to common Windows dotfile locations)
[[cable_channel]]
name = "my-dotfiles"
source_command = "Get-ChildItem -Recurse -File -Path \"$env:USERPROFILE\\AppData\\Roaming\\\""
preview_command = ":files:"
# Shell history # Shell history
[[cable_channel]] [[cable_channel]]
name = "zsh-history" name = "powershell-history"
source_command = "tail -r $HISTFILE | cut -d\";\" -f 2-" source_command = "Get-Content (Get-PSReadLineOption).HistorySavePath | Select-Object -Last 500"
[[cable_channel]]
name = "bash-history"
source_command = "tail -r $HISTFILE"
[[cable_channel]]
name = "fish-history"
source_command = "fish -c 'history'"

View File

@ -1,129 +1,6 @@
use proc_macro::TokenStream; use proc_macro::TokenStream;
use quote::quote; use quote::quote;
/// This macro generates a `CliChannel` enum and the necessary glue code
/// to convert into a `TelevisionChannel` member:
///
/// ```ignore
/// use television::channels::{TelevisionChannel, OnAir};
/// use television-derive::ToCliChannel;
/// use television::channels::{files, text};
///
/// #[derive(ToCliChannel)]
/// enum TelevisionChannel {
/// Files(files::Channel),
/// Text(text::Channel),
/// // ...
/// }
///
/// let television_channel: TelevisionChannel = CliTvChannel::Files.to_channel();
///
/// assert!(matches!(television_channel, TelevisionChannel::Files(_)));
/// ```
///
/// The `CliChannel` enum is used to select channels from the command line.
///
/// Any variant that should not be included in the CLI should be annotated with
/// `#[exclude_from_cli]`.
#[proc_macro_derive(ToCliChannel, attributes(exclude_from_cli))]
pub fn cli_channel_derive(input: TokenStream) -> TokenStream {
// Construct a representation of Rust code as a syntax tree
// that we can manipulate
let ast = syn::parse(input).unwrap();
// Build the trait implementation
impl_cli_channel(&ast)
}
fn has_attribute(attrs: &[syn::Attribute], attribute: &str) -> bool {
attrs.iter().any(|attr| attr.path().is_ident(attribute))
}
const EXCLUDE_FROM_CLI: &str = "exclude_from_cli";
fn impl_cli_channel(ast: &syn::DeriveInput) -> TokenStream {
// check that the struct is an enum
let variants = if let syn::Data::Enum(data_enum) = &ast.data {
&data_enum.variants
} else {
panic!("#[derive(CliChannel)] is only defined for enums");
};
// check that the enum has at least one variant
assert!(
!variants.is_empty(),
"#[derive(CliChannel)] requires at least one variant"
);
// create the CliTvChannel enum
let cli_enum_variants: Vec<_> = variants
.iter()
.filter(|variant| !has_attribute(&variant.attrs, EXCLUDE_FROM_CLI))
.map(|variant| {
let variant_name = &variant.ident;
quote! {
#variant_name
}
})
.collect();
let cli_enum = quote! {
use clap::ValueEnum;
use serde::{Deserialize, Serialize};
use strum::{Display, EnumIter, EnumString};
use std::default::Default;
#[derive(Debug, Clone, ValueEnum, EnumIter, EnumString, Default, Copy, PartialEq, Eq, Serialize, Deserialize, Display)]
#[strum(serialize_all = "kebab_case")]
pub enum CliTvChannel {
#[default]
#(#cli_enum_variants),*
}
};
// Generate the match arms for the `to_channel` method
let arms = variants.iter().filter(
|variant| !has_attribute(&variant.attrs, EXCLUDE_FROM_CLI),
).map(|variant| {
let variant_name = &variant.ident;
// Get the inner type of the variant, assuming it is the first field of the variant
if let syn::Fields::Unnamed(fields) = &variant.fields {
if fields.unnamed.len() == 1 {
// Get the inner type of the variant (e.g., EnvChannel)
let inner_type = &fields.unnamed[0].ty;
quote! {
CliTvChannel::#variant_name => TelevisionChannel::#variant_name(#inner_type::default())
}
} else {
panic!("Enum variants should have exactly one unnamed field.");
}
} else {
panic!("Enum variants expected to only have unnamed fields.");
}
});
let gen = quote! {
#cli_enum
impl CliTvChannel {
pub fn to_channel(self) -> TelevisionChannel {
match self {
#(#arms),*
}
}
pub fn all_channels() -> Vec<String> {
use strum::IntoEnumIterator;
Self::iter().map(|v| v.to_string()).collect()
}
}
};
gen.into()
}
/// This macro generates the `OnAir` trait implementation for the given enum. /// This macro generates the `OnAir` trait implementation for the given enum.
/// ///
/// The `OnAir` trait is used to interact with the different television channels /// The `OnAir` trait is used to interact with the different television channels
@ -287,95 +164,3 @@ fn impl_tv_channel(ast: &syn::DeriveInput) -> TokenStream {
trait_impl.into() trait_impl.into()
} }
/// This macro generates a `UnitChannel` enum and the necessary glue code
/// to convert from and to a `TelevisionChannel` member.
///
/// The `UnitChannel` enum is used as a unit variant of the `TelevisionChannel`
/// enum.
#[proc_macro_derive(ToUnitChannel, attributes(exclude_from_unit))]
pub fn unit_channel_derive(input: TokenStream) -> TokenStream {
// Construct a representation of Rust code as a syntax tree
// that we can manipulate
let ast = syn::parse(input).unwrap();
// Build the trait implementation
impl_unit_channel(&ast)
}
const EXCLUDE_FROM_UNIT: &str = "exclude_from_unit";
fn impl_unit_channel(ast: &syn::DeriveInput) -> TokenStream {
// Ensure the struct is an enum
let variants = if let syn::Data::Enum(data_enum) = &ast.data {
&data_enum.variants
} else {
panic!("#[derive(UnitChannel)] is only defined for enums");
};
// Ensure the enum has at least one variant
assert!(
!variants.is_empty(),
"#[derive(UnitChannel)] requires at least one variant"
);
let variant_names: Vec<_> = variants
.iter()
.filter(|variant| !has_attribute(&variant.attrs, EXCLUDE_FROM_UNIT))
.map(|v| &v.ident)
.collect();
let excluded_variants: Vec<_> = variants
.iter()
.filter(|variant| has_attribute(&variant.attrs, EXCLUDE_FROM_UNIT))
.map(|v| &v.ident)
.collect();
// Generate a unit enum from the given enum
let unit_enum = quote! {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Hash)]
#[strum(serialize_all = "kebab_case")]
pub enum UnitChannel {
#(
#variant_names,
)*
}
};
// Generate Into<TelevisionChannel> implementation
let into_impl = quote! {
impl Into<TelevisionChannel> for UnitChannel {
fn into(self) -> TelevisionChannel {
match self {
#(
UnitChannel::#variant_names => TelevisionChannel::#variant_names(Default::default()),
)*
}
}
}
};
// Generate From<&TelevisionChannel> implementation
let from_impl = quote! {
impl From<&TelevisionChannel> for UnitChannel {
fn from(channel: &TelevisionChannel) -> Self {
match channel {
#(
TelevisionChannel::#variant_names(_) => Self::#variant_names,
)*
#(
TelevisionChannel::#excluded_variants(_) => panic!("Cannot convert excluded variant to unit channel."),
)*
}
}
}
};
let gen = quote! {
#unit_enum
#into_impl
#from_impl
};
gen.into()
}

View File

@ -65,14 +65,14 @@ pub fn load_cable_channels() -> Result<CableChannels> {
file_paths.push(default_channels_path); file_paths.push(default_channels_path);
} }
let user_defined_prototypes = file_paths.iter().fold( let prototypes = file_paths.iter().fold(
Vec::<CableChannelPrototype>::new(), Vec::<CableChannelPrototype>::new(),
|mut acc, p| { |mut acc, p| {
match toml::from_str::<ChannelPrototypes>( match toml::from_str::<ChannelPrototypes>(
&std::fs::read_to_string(p) &std::fs::read_to_string(p)
.expect("Unable to read configuration file"), .expect("Unable to read configuration file"),
) { ) {
Ok(prototypes) => acc.extend(prototypes.prototypes), Ok(pts) => acc.extend(pts.prototypes),
Err(e) => { Err(e) => {
error!( error!(
"Failed to parse cable channel file {:?}: {}", "Failed to parse cable channel file {:?}: {}",
@ -84,10 +84,10 @@ pub fn load_cable_channels() -> Result<CableChannels> {
}, },
); );
debug!("Loaded cable channels: {:?}", user_defined_prototypes); debug!("Loaded cable channels: {:?}", prototypes);
let mut cable_channels = FxHashMap::default(); let mut cable_channels = FxHashMap::default();
for prototype in user_defined_prototypes { for prototype in prototypes {
cable_channels.insert(prototype.name.clone(), prototype); cable_channels.insert(prototype.name.clone(), prototype);
} }
Ok(CableChannels(cable_channels)) Ok(CableChannels(cable_channels))

View File

@ -1,182 +0,0 @@
use std::collections::HashSet;
use crate::channels::entry::Entry;
use crate::channels::entry::PreviewType;
use crate::channels::OnAir;
use crate::matcher::{config::Config, injector::Injector, Matcher};
use crate::utils::command::shell_command;
use crate::utils::indices::sep_name_and_value_indices;
use crate::utils::shell::Shell;
use devicons::FileIcon;
use rustc_hash::FxBuildHasher;
use rustc_hash::FxHashSet;
use tracing::debug;
#[derive(Debug, Clone)]
struct Alias {
name: String,
value: String,
}
impl Alias {
fn new(name: String, value: String) -> Self {
Self { name, value }
}
}
pub struct Channel {
matcher: Matcher<Alias>,
file_icon: FileIcon,
selected_entries: FxHashSet<Entry>,
crawl_handle: tokio::task::JoinHandle<()>,
}
const NUM_THREADS: usize = 1;
const FILE_ICON_STR: &str = "nu";
fn get_raw_aliases(shell: Shell) -> Vec<String> {
// this needs to be run in an interactive shell in order to get the aliases
let mut command = shell_command(true);
let output = match shell {
Shell::PowerShell => {
command.arg("Get-Alias | Format-List -Property Name, Definition")
}
Shell::Cmd => command.arg("doskey /macros"),
_ => command.arg("-i").arg("alias").arg("2>/dev/null"),
}
.output()
.expect("failed to execute process");
let aliases = String::from_utf8_lossy(&output.stdout);
aliases.lines().map(ToString::to_string).collect()
}
impl Channel {
pub fn new() -> Self {
let matcher = Matcher::new(Config::default().n_threads(NUM_THREADS));
let injector = matcher.injector();
let crawl_handle = tokio::spawn(load_aliases(injector));
Self {
matcher,
file_icon: FileIcon::from(FILE_ICON_STR),
selected_entries: HashSet::with_hasher(FxBuildHasher),
crawl_handle,
}
}
}
impl Default for Channel {
fn default() -> Self {
Self::new()
}
}
impl OnAir for Channel {
fn find(&mut self, pattern: &str) {
self.matcher.find(pattern);
}
fn results(&mut self, num_entries: u32, offset: u32) -> Vec<Entry> {
self.matcher.tick();
self.matcher
.results(num_entries, offset)
.into_iter()
.map(|item| {
let (
name_indices,
value_indices,
should_add_name_indices,
should_add_value_indices,
) = sep_name_and_value_indices(
item.match_indices,
u32::try_from(item.inner.name.len()).unwrap(),
);
let mut entry =
Entry::new(item.inner.name.clone(), PreviewType::EnvVar)
.with_value(item.inner.value)
.with_icon(self.file_icon);
if should_add_name_indices {
entry = entry.with_name_match_indices(&name_indices);
}
if should_add_value_indices {
entry = entry.with_value_match_indices(&value_indices);
}
entry
})
.collect()
}
fn get_result(&self, index: u32) -> Option<Entry> {
self.matcher.get_result(index).map(|item| {
Entry::new(item.inner.name.clone(), PreviewType::EnvVar)
.with_value(item.inner.value)
.with_icon(self.file_icon)
})
}
fn selected_entries(&self) -> &FxHashSet<Entry> {
&self.selected_entries
}
fn toggle_selection(&mut self, entry: &Entry) {
if self.selected_entries.contains(entry) {
self.selected_entries.remove(entry);
} else {
self.selected_entries.insert(entry.clone());
}
}
fn result_count(&self) -> u32 {
self.matcher.matched_item_count
}
fn total_count(&self) -> u32 {
self.matcher.total_item_count
}
fn running(&self) -> bool {
self.matcher.status.running || !self.crawl_handle.is_finished()
}
fn shutdown(&self) {}
fn supports_preview(&self) -> bool {
true
}
}
#[allow(clippy::unused_async)]
async fn load_aliases(injector: Injector<Alias>) {
let shell = Shell::from_env().unwrap_or_default();
debug!("Current shell: {}", shell);
let raw_aliases = get_raw_aliases(shell);
raw_aliases
.iter()
.filter_map(|alias| {
let mut parts = alias.split('=');
if let Some(name) = parts.next() {
if let Some(value) = parts.next() {
return Some(Alias::new(
name.to_string(),
value.to_string(),
));
}
} else {
debug!("Invalid alias format: {}", alias);
}
None
})
.for_each(|alias| {
let () = injector.push(alias, |e, cols| {
cols[0] = (e.name.clone() + &e.value).into();
});
});
}

View File

@ -41,6 +41,7 @@ impl Default for Channel {
Self::new( Self::new(
"Files", "Files",
"find . -type f", "find . -type f",
false,
Some(PreviewCommand::new("bat -n --color=always {}", ":")), Some(PreviewCommand::new("bat -n --color=always {}", ":")),
) )
} }
@ -51,6 +52,7 @@ impl From<CableChannelPrototype> for Channel {
Self::new( Self::new(
&prototype.name, &prototype.name,
&prototype.source_command, &prototype.source_command,
prototype.interactive,
match prototype.preview_command { match prototype.preview_command {
Some(command) => Some(PreviewCommand::new( Some(command) => Some(PreviewCommand::new(
&command, &command,
@ -79,12 +81,14 @@ impl Channel {
pub fn new( pub fn new(
name: &str, name: &str,
entries_command: &str, entries_command: &str,
interactive: bool,
preview_command: Option<PreviewCommand>, preview_command: Option<PreviewCommand>,
) -> Self { ) -> Self {
let matcher = Matcher::new(Config::default()); let matcher = Matcher::new(Config::default());
let injector = matcher.injector(); let injector = matcher.injector();
let crawl_handle = tokio::spawn(load_candidates( let crawl_handle = tokio::spawn(load_candidates(
entries_command.to_string(), entries_command.to_string(),
interactive,
injector, injector,
)); ));
let preview_kind = match preview_command { let preview_kind = match preview_command {
@ -108,9 +112,13 @@ impl Channel {
} }
#[allow(clippy::unused_async)] #[allow(clippy::unused_async)]
async fn load_candidates(command: String, injector: Injector<String>) { async fn load_candidates(
command: String,
interactive: bool,
injector: Injector<String>,
) {
debug!("Loading candidates from command: {:?}", command); debug!("Loading candidates from command: {:?}", command);
let mut child = shell_command(false) let mut child = shell_command(interactive)
.arg(command) .arg(command)
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::piped()) .stderr(Stdio::piped())
@ -228,11 +236,47 @@ impl OnAir for Channel {
pub struct CableChannelPrototype { pub struct CableChannelPrototype {
pub name: String, pub name: String,
pub source_command: String, pub source_command: String,
#[serde(default)]
pub interactive: bool,
pub preview_command: Option<String>, pub preview_command: Option<String>,
#[serde(default = "default_delimiter")] #[serde(default = "default_delimiter")]
pub preview_delimiter: Option<String>, pub preview_delimiter: Option<String>,
} }
impl CableChannelPrototype {
pub fn new(
name: &str,
source_command: &str,
interactive: bool,
preview_command: Option<String>,
preview_delimiter: Option<String>,
) -> Self {
Self {
name: name.to_string(),
source_command: source_command.to_string(),
interactive,
preview_command,
preview_delimiter,
}
}
}
const DEFAULT_PROTOTYPE_NAME: &str = "files";
const DEFAULT_SOURCE_COMMAND: &str = "fd -t f";
const DEFAULT_PREVIEW_COMMAND: &str = ":files:";
impl Default for CableChannelPrototype {
fn default() -> Self {
Self {
name: DEFAULT_PROTOTYPE_NAME.to_string(),
source_command: DEFAULT_SOURCE_COMMAND.to_string(),
interactive: false,
preview_command: Some(DEFAULT_PREVIEW_COMMAND.to_string()),
preview_delimiter: Some(DEFAULT_DELIMITER.to_string()),
}
}
}
pub const DEFAULT_DELIMITER: &str = " "; pub const DEFAULT_DELIMITER: &str = " ";
#[allow(clippy::unnecessary_wraps)] #[allow(clippy::unnecessary_wraps)]

View File

@ -1,186 +0,0 @@
use crate::channels::entry::{Entry, PreviewCommand, PreviewType};
use crate::channels::{OnAir, TelevisionChannel};
use crate::matcher::{config::Config, injector::Injector, Matcher};
use crate::utils::files::{get_default_num_threads, walk_builder};
use devicons::FileIcon;
use rustc_hash::{FxBuildHasher, FxHashSet};
use std::collections::HashSet;
use std::path::PathBuf;
pub struct Channel {
matcher: Matcher<String>,
crawl_handle: tokio::task::JoinHandle<()>,
// PERF: cache results (to make deleting characters smoother) with
// a shallow stack of sub-patterns as keys (e.g. "a", "ab", "abc")
selected_entries: FxHashSet<Entry>,
}
impl Channel {
pub fn new(paths: Vec<PathBuf>) -> Self {
let matcher = Matcher::new(Config::default().match_paths(true));
// start loading files in the background
let crawl_handle = tokio::spawn(load_dirs(paths, matcher.injector()));
Channel {
matcher,
crawl_handle,
selected_entries: HashSet::with_hasher(FxBuildHasher),
}
}
}
impl Default for Channel {
fn default() -> Self {
Self::new(vec![std::env::current_dir().unwrap()])
}
}
impl From<&mut TelevisionChannel> for Channel {
fn from(value: &mut TelevisionChannel) -> Self {
match value {
c @ TelevisionChannel::GitRepos(_) => {
let entries = if c.selected_entries().is_empty() {
c.results(c.result_count(), 0)
} else {
c.selected_entries().iter().cloned().collect()
};
Self::new(
entries
.iter()
.map(|entry| PathBuf::from(entry.name.clone()))
.collect(),
)
}
c @ TelevisionChannel::Dirs(_) => {
let entries = if c.selected_entries().is_empty() {
c.results(c.result_count(), 0)
} else {
c.selected_entries().iter().cloned().collect()
};
Self::new(
entries
.iter()
.map(|entry| PathBuf::from(&entry.name))
.collect::<HashSet<_>>()
.into_iter()
.collect(),
)
}
_ => unreachable!(),
}
}
}
#[cfg(unix)]
const PREVIEW_COMMAND: &str = "ls -la --color=always {}";
#[cfg(windows)]
const PREVIEW_COMMAND: &str = "dir /Q {}";
impl OnAir for Channel {
fn find(&mut self, pattern: &str) {
self.matcher.find(pattern);
}
fn results(&mut self, num_entries: u32, offset: u32) -> Vec<Entry> {
self.matcher.tick();
self.matcher
.results(num_entries, offset)
.into_iter()
.map(|item| {
let path = item.matched_string;
Entry::new(
path.clone(),
PreviewType::Command(PreviewCommand::new(
PREVIEW_COMMAND,
" ",
)),
)
.with_name_match_indices(&item.match_indices)
.with_icon(FileIcon::from(&path))
})
.collect()
}
fn get_result(&self, index: u32) -> Option<Entry> {
self.matcher.get_result(index).map(|item| {
let path = item.matched_string;
Entry::new(
path.clone(),
PreviewType::Command(PreviewCommand::new(
PREVIEW_COMMAND,
" ",
)),
)
.with_icon(FileIcon::from(&path))
})
}
fn selected_entries(&self) -> &FxHashSet<Entry> {
&self.selected_entries
}
fn toggle_selection(&mut self, entry: &Entry) {
if self.selected_entries.contains(entry) {
self.selected_entries.remove(entry);
} else {
self.selected_entries.insert(entry.clone());
}
}
fn result_count(&self) -> u32 {
self.matcher.matched_item_count
}
fn total_count(&self) -> u32 {
self.matcher.total_item_count
}
fn running(&self) -> bool {
self.matcher.status.running || !self.crawl_handle.is_finished()
}
fn shutdown(&self) {
self.crawl_handle.abort();
}
fn supports_preview(&self) -> bool {
true
}
}
#[allow(clippy::unused_async)]
async fn load_dirs(paths: Vec<PathBuf>, injector: Injector<String>) {
if paths.is_empty() {
return;
}
let current_dir = std::env::current_dir().unwrap();
let mut builder =
walk_builder(&paths[0], get_default_num_threads(), None, None);
paths[1..].iter().for_each(|path| {
builder.add(path);
});
let walker = builder.build_parallel();
walker.run(|| {
let injector = injector.clone();
let current_dir = current_dir.clone();
Box::new(move |result| {
if let Ok(entry) = result {
if entry.file_type().unwrap().is_dir() {
let dir_path = &entry
.path()
.strip_prefix(&current_dir)
.unwrap_or(entry.path())
.to_string_lossy();
if dir_path == "" {
return ignore::WalkState::Continue;
}
let () = injector.push(dir_path.to_string(), |e, cols| {
cols[0] = e.to_string().into();
});
}
}
ignore::WalkState::Continue
})
});
}

View File

@ -1,132 +0,0 @@
use std::collections::HashSet;
use devicons::FileIcon;
use rustc_hash::{FxBuildHasher, FxHashSet};
use super::OnAir;
use crate::channels::entry::{Entry, PreviewType};
use crate::matcher::{config::Config, Matcher};
use crate::utils::indices::sep_name_and_value_indices;
#[derive(Debug, Clone)]
struct EnvVar {
name: String,
value: String,
}
#[allow(clippy::module_name_repetitions)]
pub struct Channel {
matcher: Matcher<EnvVar>,
file_icon: FileIcon,
selected_entries: FxHashSet<Entry>,
}
const NUM_THREADS: usize = 1;
const FILE_ICON_STR: &str = "config";
impl Channel {
pub fn new() -> Self {
let matcher = Matcher::new(Config::default().n_threads(NUM_THREADS));
let injector = matcher.injector();
for (name, value) in std::env::vars() {
let () = injector.push(
EnvVar {
name: name.clone(),
value: value.clone(),
},
|e, cols| {
cols[0] = (e.name.clone() + &e.value).into();
},
);
}
Channel {
matcher,
file_icon: FileIcon::from(FILE_ICON_STR),
selected_entries: HashSet::with_hasher(FxBuildHasher),
}
}
}
impl Default for Channel {
fn default() -> Self {
Self::new()
}
}
impl OnAir for Channel {
fn find(&mut self, pattern: &str) {
self.matcher.find(pattern);
}
fn results(&mut self, num_entries: u32, offset: u32) -> Vec<Entry> {
self.matcher.tick();
self.matcher
.results(num_entries, offset)
.into_iter()
.map(|item| {
let (
name_indices,
value_indices,
should_add_name_indices,
should_add_value_indices,
) = sep_name_and_value_indices(
item.match_indices,
u32::try_from(item.inner.name.len()).unwrap(),
);
let mut entry =
Entry::new(item.inner.name.clone(), PreviewType::EnvVar)
.with_value(item.inner.value)
.with_icon(self.file_icon);
if should_add_name_indices {
entry = entry.with_name_match_indices(&name_indices);
}
if should_add_value_indices {
entry = entry.with_value_match_indices(&value_indices);
}
entry
})
.collect()
}
fn get_result(&self, index: u32) -> Option<Entry> {
self.matcher.get_result(index).map(|item| {
Entry::new(item.inner.name.clone(), PreviewType::EnvVar)
.with_value(item.inner.value)
.with_icon(self.file_icon)
})
}
fn selected_entries(&self) -> &FxHashSet<Entry> {
&self.selected_entries
}
fn toggle_selection(&mut self, entry: &Entry) {
if self.selected_entries.contains(entry) {
self.selected_entries.remove(entry);
} else {
self.selected_entries.insert(entry.clone());
}
}
fn result_count(&self) -> u32 {
self.matcher.matched_item_count
}
fn total_count(&self) -> u32 {
self.matcher.total_item_count
}
fn running(&self) -> bool {
self.matcher.status.running
}
fn shutdown(&self) {}
fn supports_preview(&self) -> bool {
true
}
}

View File

@ -1,190 +0,0 @@
use crate::channels::entry::{Entry, PreviewType};
use crate::channels::{OnAir, TelevisionChannel};
use crate::matcher::{config::Config, injector::Injector, Matcher};
use crate::utils::files::{get_default_num_threads, walk_builder};
use devicons::FileIcon;
use rustc_hash::{FxBuildHasher, FxHashSet};
use std::collections::HashSet;
use std::path::PathBuf;
pub struct Channel {
matcher: Matcher<String>,
crawl_handle: tokio::task::JoinHandle<()>,
// PERF: cache results (to make deleting characters smoother) with
// a shallow stack of sub-patterns as keys (e.g. "a", "ab", "abc")
selected_entries: FxHashSet<Entry>,
}
impl Channel {
pub fn new(paths: Vec<PathBuf>) -> Self {
let matcher = Matcher::new(Config::default().match_paths(true));
// start loading files in the background
let crawl_handle = tokio::spawn(load_files(paths, matcher.injector()));
Channel {
matcher,
crawl_handle,
selected_entries: HashSet::with_hasher(FxBuildHasher),
}
}
}
impl Default for Channel {
fn default() -> Self {
Self::new(vec![std::env::current_dir().unwrap()])
}
}
impl From<&mut TelevisionChannel> for Channel {
fn from(value: &mut TelevisionChannel) -> Self {
match value {
c @ TelevisionChannel::GitRepos(_) => {
let entries = if c.selected_entries().is_empty() {
c.results(c.result_count(), 0)
} else {
c.selected_entries().iter().cloned().collect()
};
Self::new(
entries
.iter()
.map(|entry| PathBuf::from(entry.name.clone()))
.collect(),
)
}
c @ TelevisionChannel::Files(_) => {
let entries = if c.selected_entries().is_empty() {
c.results(c.result_count(), 0)
} else {
c.selected_entries().iter().cloned().collect()
};
Self::new(
entries
.iter()
.map(|entry| PathBuf::from(entry.name.clone()))
.collect(),
)
}
c @ TelevisionChannel::Text(_) => {
let entries = if c.selected_entries().is_empty() {
c.results(c.result_count(), 0)
} else {
c.selected_entries().iter().cloned().collect()
};
Self::new(
entries
.iter()
.map(|entry| PathBuf::from(&entry.name))
.collect::<FxHashSet<_>>()
.into_iter()
.collect(),
)
}
c @ TelevisionChannel::Dirs(_) => {
let entries = c.results(c.result_count(), 0);
Self::new(
entries
.iter()
.map(|entry| PathBuf::from(&entry.name))
.collect::<FxHashSet<_>>()
.into_iter()
.collect(),
)
}
_ => unreachable!(),
}
}
}
impl OnAir for Channel {
fn find(&mut self, pattern: &str) {
self.matcher.find(pattern);
}
fn results(&mut self, num_entries: u32, offset: u32) -> Vec<Entry> {
self.matcher.tick();
self.matcher
.results(num_entries, offset)
.into_iter()
.map(|item| {
let path = item.matched_string;
Entry::new(path.clone(), PreviewType::Files)
.with_name_match_indices(&item.match_indices)
.with_icon(FileIcon::from(&path))
})
.collect()
}
fn get_result(&self, index: u32) -> Option<Entry> {
self.matcher.get_result(index).map(|item| {
let path = item.matched_string;
Entry::new(path.clone(), PreviewType::Files)
.with_icon(FileIcon::from(&path))
})
}
fn selected_entries(&self) -> &FxHashSet<Entry> {
&self.selected_entries
}
fn toggle_selection(&mut self, entry: &Entry) {
if self.selected_entries.contains(entry) {
self.selected_entries.remove(entry);
} else {
self.selected_entries.insert(entry.clone());
}
}
fn result_count(&self) -> u32 {
self.matcher.matched_item_count
}
fn total_count(&self) -> u32 {
self.matcher.total_item_count
}
fn running(&self) -> bool {
self.matcher.status.running || !self.crawl_handle.is_finished()
}
fn shutdown(&self) {
self.crawl_handle.abort();
}
fn supports_preview(&self) -> bool {
true
}
}
#[allow(clippy::unused_async)]
async fn load_files(paths: Vec<PathBuf>, injector: Injector<String>) {
if paths.is_empty() {
return;
}
let current_dir = std::env::current_dir().unwrap();
let mut builder =
walk_builder(&paths[0], get_default_num_threads(), None, None);
paths[1..].iter().for_each(|path| {
builder.add(path);
});
let walker = builder.build_parallel();
walker.run(|| {
let injector = injector.clone();
let current_dir = current_dir.clone();
Box::new(move |result| {
if let Ok(entry) = result {
if entry.file_type().unwrap().is_file() {
let file_path = &entry
.path()
.strip_prefix(&current_dir)
.unwrap_or(entry.path())
.to_string_lossy();
let () =
injector.push(file_path.to_string(), |e, cols| {
cols[0] = e.to_string().into();
});
}
}
ignore::WalkState::Continue
})
});
}

View File

@ -1,204 +0,0 @@
use devicons::FileIcon;
use directories::BaseDirs;
use ignore::overrides::OverrideBuilder;
use rustc_hash::{FxBuildHasher, FxHashSet};
use std::collections::HashSet;
use std::path::PathBuf;
use tokio::task::JoinHandle;
use tracing::debug;
use crate::channels::entry::{Entry, PreviewCommand, PreviewType};
use crate::channels::OnAir;
use crate::matcher::{config::Config, injector::Injector, Matcher};
use crate::utils::files::{get_default_num_threads, walk_builder};
pub struct Channel {
matcher: Matcher<String>,
icon: FileIcon,
crawl_handle: JoinHandle<()>,
selected_entries: FxHashSet<Entry>,
preview_command: PreviewCommand,
}
impl Channel {
pub fn new() -> Self {
let matcher = Matcher::new(Config::default().match_paths(true));
let base_dirs = BaseDirs::new().unwrap();
let crawl_handle = tokio::spawn(crawl_for_repos(
base_dirs.home_dir().to_path_buf(),
matcher.injector(),
));
let preview_command = PreviewCommand {
command: String::from(
"cd {} && git log -n 200 --pretty=medium --all --graph --color",
),
delimiter: ":".to_string(),
};
Channel {
matcher,
icon: FileIcon::from("git"),
crawl_handle,
selected_entries: HashSet::with_hasher(FxBuildHasher),
preview_command,
}
}
}
impl Default for Channel {
fn default() -> Self {
Self::new()
}
}
impl OnAir for Channel {
fn find(&mut self, pattern: &str) {
self.matcher.find(pattern);
}
fn results(&mut self, num_entries: u32, offset: u32) -> Vec<Entry> {
self.matcher.tick();
self.matcher
.results(num_entries, offset)
.into_iter()
.map(|item| {
let path = item.matched_string;
Entry::new(
path,
PreviewType::Command(self.preview_command.clone()),
)
.with_name_match_indices(&item.match_indices)
.with_icon(self.icon)
})
.collect()
}
fn get_result(&self, index: u32) -> Option<Entry> {
self.matcher.get_result(index).map(|item| {
let path = item.matched_string;
Entry::new(
path,
PreviewType::Command(self.preview_command.clone()),
)
.with_icon(self.icon)
})
}
fn selected_entries(&self) -> &FxHashSet<Entry> {
&self.selected_entries
}
fn toggle_selection(&mut self, entry: &Entry) {
if self.selected_entries.contains(entry) {
self.selected_entries.remove(entry);
} else {
self.selected_entries.insert(entry.clone());
}
}
fn result_count(&self) -> u32 {
self.matcher.matched_item_count
}
fn total_count(&self) -> u32 {
self.matcher.total_item_count
}
fn running(&self) -> bool {
self.matcher.status.running || !self.crawl_handle.is_finished()
}
fn shutdown(&self) {
debug!("Shutting down git repos channel");
self.crawl_handle.abort();
}
fn supports_preview(&self) -> bool {
true
}
}
fn get_ignored_paths() -> Vec<PathBuf> {
let mut ignored_paths = Vec::new();
if let Some(base_dirs) = BaseDirs::new() {
let home = base_dirs.home_dir();
#[cfg(target_os = "macos")]
{
ignored_paths.push(home.join("Library"));
ignored_paths.push(home.join("Applications"));
ignored_paths.push(home.join("Music"));
ignored_paths.push(home.join("Pictures"));
ignored_paths.push(home.join("Movies"));
ignored_paths.push(home.join("Downloads"));
ignored_paths.push(home.join("Public"));
}
#[cfg(target_os = "linux")]
{
ignored_paths.push(home.join(".cache"));
ignored_paths.push(home.join(".config"));
ignored_paths.push(home.join(".local"));
ignored_paths.push(home.join(".thumbnails"));
ignored_paths.push(home.join("Downloads"));
ignored_paths.push(home.join("Public"));
ignored_paths.push(home.join("snap"));
ignored_paths.push(home.join(".snap"));
}
#[cfg(target_os = "windows")]
{
ignored_paths.push(home.join("AppData"));
ignored_paths.push(home.join("Downloads"));
ignored_paths.push(home.join("Documents"));
ignored_paths.push(home.join("Music"));
ignored_paths.push(home.join("Pictures"));
ignored_paths.push(home.join("Videos"));
}
// Common paths to ignore for all platforms
ignored_paths.push(home.join("node_modules"));
ignored_paths.push(home.join("venv"));
ignored_paths.push(PathBuf::from("/tmp"));
}
ignored_paths
}
#[allow(clippy::unused_async)]
async fn crawl_for_repos(starting_point: PathBuf, injector: Injector<String>) {
let mut walker_overrides_builder = OverrideBuilder::new(&starting_point);
walker_overrides_builder.add(".git").unwrap();
let walker = walk_builder(
&starting_point,
get_default_num_threads(),
Some(walker_overrides_builder.build().unwrap()),
Some(get_ignored_paths()),
)
.build_parallel();
walker.run(|| {
let injector = injector.clone();
Box::new(move |result| {
if let Ok(entry) = result {
if entry.file_type().unwrap().is_dir() {
// if the entry is a .git directory, add its parent to the list of git repos
if entry.path().ends_with(".git") {
let parent_path =
&entry.path().parent().unwrap().to_string_lossy();
debug!("Found git repo: {:?}", parent_path);
let () = injector.push(
parent_path.to_string(),
|e, cols| {
cols[0] = e.clone().into();
},
);
return ignore::WalkState::Skip;
}
}
}
ignore::WalkState::Continue
})
});
}

View File

@ -1,18 +1,12 @@
use crate::channels::entry::Entry; use crate::channels::entry::Entry;
use anyhow::Result; use anyhow::Result;
use rustc_hash::FxHashSet; use rustc_hash::FxHashSet;
use television_derive::{Broadcast, ToCliChannel, ToUnitChannel}; use television_derive::Broadcast;
pub mod alias;
pub mod cable; pub mod cable;
pub mod dirs;
pub mod entry; pub mod entry;
pub mod env;
pub mod files;
pub mod git_repos;
pub mod remote_control; pub mod remote_control;
pub mod stdin; pub mod stdin;
pub mod text;
/// The interface that all television channels must implement. /// The interface that all television channels must implement.
/// ///
@ -118,56 +112,22 @@ pub trait OnAir: Send {
/// of carrying the actual channel instances around. It also generates the necessary /// of carrying the actual channel instances around. It also generates the necessary
/// glue code to automatically create a channel instance from the selected enum variant. /// glue code to automatically create a channel instance from the selected enum variant.
#[allow(dead_code, clippy::module_name_repetitions)] #[allow(dead_code, clippy::module_name_repetitions)]
#[derive(ToUnitChannel, ToCliChannel, Broadcast)] #[derive(Broadcast)]
pub enum TelevisionChannel { pub enum TelevisionChannel {
/// The environment variables channel.
///
/// This channel allows to search through environment variables.
Env(env::Channel),
/// The files channel.
///
/// This channel allows to search through files.
Files(files::Channel),
/// The git repositories channel.
///
/// This channel allows to search through git repositories.
GitRepos(git_repos::Channel),
/// The dirs channel.
///
/// This channel allows to search through directories.
Dirs(dirs::Channel),
/// The text channel.
///
/// This channel allows to search through the contents of text files.
Text(text::Channel),
/// The standard input channel. /// The standard input channel.
/// ///
/// This channel allows to search through whatever is passed through stdin. /// This channel allows to search through whatever is passed through stdin.
#[exclude_from_cli]
Stdin(stdin::Channel), Stdin(stdin::Channel),
/// The alias channel.
///
/// This channel allows to search through aliases.
Alias(alias::Channel),
/// The remote control channel. /// The remote control channel.
/// ///
/// This channel allows to switch between different channels. /// This channel allows to switch between different channels.
#[exclude_from_unit]
#[exclude_from_cli]
RemoteControl(remote_control::RemoteControl), RemoteControl(remote_control::RemoteControl),
/// A custom channel. /// A custom channel.
/// ///
/// This channel allows to search through custom data. /// This channel allows to search through custom data.
#[exclude_from_cli]
Cable(cable::Channel), Cable(cable::Channel),
} }
impl From<&Entry> for TelevisionChannel {
fn from(entry: &Entry) -> Self {
UnitChannel::try_from(entry.name.as_str()).unwrap().into()
}
}
impl TelevisionChannel { impl TelevisionChannel {
pub fn zap(&self, channel_name: &str) -> Result<TelevisionChannel> { pub fn zap(&self, channel_name: &str) -> Result<TelevisionChannel> {
match self { match self {
@ -182,130 +142,7 @@ impl TelevisionChannel {
match self { match self {
TelevisionChannel::Cable(channel) => channel.name.clone(), TelevisionChannel::Cable(channel) => channel.name.clone(),
TelevisionChannel::Stdin(_) => String::from("Stdin"), TelevisionChannel::Stdin(_) => String::from("Stdin"),
_ => UnitChannel::from(self).to_string(), TelevisionChannel::RemoteControl(_) => String::from("Remote"),
} }
} }
} }
macro_rules! variant_to_module {
(Files) => {
files::Channel
};
(Text) => {
text::Channel
};
(Dirs) => {
dirs::Channel
};
(GitRepos) => {
git_repos::Channel
};
(Env) => {
env::Channel
};
(Stdin) => {
stdin::Channel
};
(Alias) => {
alias::Channel
};
(RemoteControl) => {
remote_control::RemoteControl
};
}
/// A macro that generates two methods for the `TelevisionChannel` enum based on
/// the transitions defined in the macro call.
///
/// The first method `available_transitions` returns a list of possible transitions
/// from the current channel.
///
/// The second method `transition_to` transitions from the current channel to the
/// target channel.
///
/// # Example
/// The following example defines transitions from the `Files` channel to the `Text`
/// channel and from the `GitRepos` channel to the `Files` and `Text` channels.
/// ```ignore
/// define_transitions! {
/// // The `Files` channel can transition to the `Text` channel.
/// Files => [Text],
/// // The `GitRepos` channel can transition to the `Files` and `Text` channels.
/// GitRepos => [Files, Text],
/// }
/// ```
/// This will generate the following methods for the `TelevisionChannel` enum:
/// ```ignore
/// impl TelevisionChannel {
/// pub fn available_transitions(&self) -> Vec<UnitChannel> {
/// match self {
/// TelevisionChannel::Files(_) => vec![UnitChannel::Text],
/// TelevisionChannel::GitRepos(_) => vec![UnitChannel::Files, UnitChannel::Text],
/// _ => Vec::new(),
/// }
/// }
///
/// pub fn transition_to(self, target: UnitChannel) -> TelevisionChannel {
/// match (self, target) {
/// (tv_channel @ TelevisionChannel::Files(_), UnitChannel::Text) => {
/// TelevisionChannel::Text(text::Channel::from(tv_channel))
/// },
/// (tv_channel @ TelevisionChannel::GitRepos(_), UnitChannel::Files) => {
/// TelevisionChannel::Files(files::Channel::from(tv_channel))
/// },
/// (tv_channel @ TelevisionChannel::GitRepos(_), UnitChannel::Text) => {
/// TelevisionChannel::Text(text::Channel::from(tv_channel))
/// },
/// _ => unreachable!(),
/// }
/// }
/// }
///
///
macro_rules! define_transitions {
(
$(
$from_variant:ident => [ $($to_variant:ident),* $(,)? ],
)*
) => {
impl TelevisionChannel {
pub fn available_transitions(&self) -> Vec<UnitChannel> {
match self {
$(
TelevisionChannel::$from_variant(_) => vec![
$( UnitChannel::$to_variant ),*
],
)*
_ => Vec::new(),
}
}
pub fn transition_to(&mut self, target: UnitChannel) -> TelevisionChannel {
match (self, target) {
$(
$(
(tv_channel @ TelevisionChannel::$from_variant(_), UnitChannel::$to_variant) => {
TelevisionChannel::$to_variant(
<variant_to_module!($to_variant)>::from(tv_channel)
)
},
)*
)*
_ => unreachable!(),
}
}
}
}
}
// Define the transitions between the different channels.
//
// This is where the transitions between the different channels are defined.
// The transitions are defined as a list of tuples where the first element
// is the source channel and the second element is a list of potential target channels.
define_transitions! {
Text => [Files, Text],
Files => [Files, Text],
Dirs => [Files, Text, Dirs],
GitRepos => [Files, Text, Dirs],
}

View File

@ -1,61 +1,33 @@
use std::collections::HashSet; use std::collections::HashSet;
use std::fmt::Display;
use crate::channels::cable::{CableChannelPrototype, CableChannels}; use crate::channels::cable::CableChannels;
use crate::channels::entry::{Entry, PreviewType}; use crate::channels::entry::{Entry, PreviewType};
use crate::channels::{CliTvChannel, OnAir, TelevisionChannel, UnitChannel}; use crate::channels::{OnAir, TelevisionChannel};
use crate::matcher::{config::Config, Matcher}; use crate::matcher::{config::Config, Matcher};
use anyhow::Result; use anyhow::Result;
use clap::ValueEnum;
use devicons::FileIcon; use devicons::FileIcon;
use rustc_hash::{FxBuildHasher, FxHashSet}; use rustc_hash::{FxBuildHasher, FxHashSet};
use super::cable; use super::cable;
pub struct RemoteControl { pub struct RemoteControl {
matcher: Matcher<RCButton>, matcher: Matcher<String>,
cable_channels: Option<CableChannels>, cable_channels: Option<CableChannels>,
selected_entries: FxHashSet<Entry>, selected_entries: FxHashSet<Entry>,
} }
#[derive(Clone)]
pub enum RCButton {
Channel(UnitChannel),
CableChannel(CableChannelPrototype),
}
impl Display for RCButton {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RCButton::Channel(channel) => write!(f, "{channel}"),
RCButton::CableChannel(prototype) => write!(f, "{prototype}"),
}
}
}
const NUM_THREADS: usize = 1; const NUM_THREADS: usize = 1;
impl RemoteControl { impl RemoteControl {
pub fn new( pub fn new(cable_channels: Option<CableChannels>) -> Self {
builtin_channels: Vec<UnitChannel>,
cable_channels: Option<CableChannels>,
) -> Self {
let matcher = Matcher::new(Config::default().n_threads(NUM_THREADS)); let matcher = Matcher::new(Config::default().n_threads(NUM_THREADS));
let injector = matcher.injector(); let injector = matcher.injector();
let buttons = for c in cable_channels
builtin_channels.into_iter().map(RCButton::Channel).chain( .as_ref()
cable_channels .unwrap_or(&CableChannels::default())
.as_ref() .keys()
.map(|channels| { {
channels.iter().map(|(_, prototype)| { let () = injector.push(c.clone(), |e, cols| {
RCButton::CableChannel(prototype.clone())
})
})
.into_iter()
.flatten(),
);
for button in buttons {
let () = injector.push(button.clone(), |e, cols| {
cols[0] = e.to_string().into(); cols[0] = e.to_string().into();
}); });
} }
@ -66,12 +38,6 @@ impl RemoteControl {
} }
} }
pub fn with_transitions_from(
television_channel: &TelevisionChannel,
) -> Self {
Self::new(television_channel.available_transitions(), None)
}
pub fn zap(&self, channel_name: &str) -> Result<TelevisionChannel> { pub fn zap(&self, channel_name: &str) -> Result<TelevisionChannel> {
match self match self
.cable_channels .cable_channels
@ -81,47 +47,20 @@ impl RemoteControl {
Some(prototype) => { Some(prototype) => {
Ok(TelevisionChannel::Cable(cable::Channel::from(prototype))) Ok(TelevisionChannel::Cable(cable::Channel::from(prototype)))
} }
None => match UnitChannel::try_from(channel_name) { None => Err(anyhow::anyhow!(
Ok(channel) => Ok(channel.into()), "No channel or cable channel prototype found for {}",
Err(_) => Err(anyhow::anyhow!( channel_name
"No channel or cable channel prototype found for {}", )),
channel_name
)),
},
} }
} }
} }
impl Default for RemoteControl { impl Default for RemoteControl {
fn default() -> Self { fn default() -> Self {
Self::new( Self::new(None)
CliTvChannel::value_variants()
.iter()
.flat_map(|v| UnitChannel::try_from(v.to_string().as_str()))
.collect(),
None,
)
} }
} }
pub fn load_builtin_channels(
filter_out_cable_names: Option<&[&String]>,
) -> Vec<UnitChannel> {
let mut value_variants = CliTvChannel::value_variants()
.iter()
.map(std::string::ToString::to_string)
.collect::<Vec<_>>();
if let Some(f) = filter_out_cable_names {
value_variants.retain(|v| !f.iter().any(|c| *c == v));
}
value_variants
.iter()
.flat_map(|v| UnitChannel::try_from(v.as_str()))
.collect()
}
const TV_ICON: FileIcon = FileIcon { const TV_ICON: FileIcon = FileIcon {
icon: '📺', icon: '📺',
color: "#000000", color: "#000000",
@ -146,10 +85,7 @@ impl OnAir for RemoteControl {
let path = item.matched_string; let path = item.matched_string;
Entry::new(path, PreviewType::Basic) Entry::new(path, PreviewType::Basic)
.with_name_match_indices(&item.match_indices) .with_name_match_indices(&item.match_indices)
.with_icon(match item.inner { .with_icon(CABLE_ICON)
RCButton::Channel(_) => TV_ICON,
RCButton::CableChannel(_) => CABLE_ICON,
})
}) })
.collect() .collect()
} }

View File

@ -1,387 +0,0 @@
use super::{OnAir, TelevisionChannel};
use crate::channels::entry::{Entry, PreviewType};
use crate::matcher::{config::Config, injector::Injector, Matcher};
use crate::utils::files::{get_default_num_threads, walk_builder};
use crate::utils::strings::{
proportion_of_printable_ascii_characters, PRINTABLE_ASCII_THRESHOLD,
};
use devicons::FileIcon;
use ignore::WalkState;
use rustc_hash::{FxBuildHasher, FxHashSet};
use std::{
collections::HashSet,
fs::File,
io::{BufRead, Read, Seek},
path::{Path, PathBuf},
sync::{atomic::AtomicUsize, Arc},
};
use tracing::{debug, trace, warn};
#[derive(Debug, Clone)]
struct CandidateLine {
path: PathBuf,
line: String,
line_number: usize,
}
impl CandidateLine {
fn new(path: PathBuf, line: String, line_number: usize) -> Self {
CandidateLine {
path,
line,
line_number,
}
}
}
#[allow(clippy::module_name_repetitions)]
pub struct Channel {
matcher: Matcher<CandidateLine>,
crawl_handle: tokio::task::JoinHandle<()>,
selected_entries: FxHashSet<Entry>,
}
impl Channel {
pub fn new(directories: Vec<PathBuf>) -> Self {
let matcher = Matcher::new(Config::default());
// start loading files in the background
let crawl_handle = tokio::spawn(crawl_for_candidates(
directories,
matcher.injector(),
));
Channel {
matcher,
crawl_handle,
selected_entries: HashSet::with_hasher(FxBuildHasher),
}
}
fn from_file_paths(file_paths: Vec<PathBuf>) -> Self {
let matcher = Matcher::new(Config::default());
let injector = matcher.injector();
let current_dir = std::env::current_dir().unwrap();
let crawl_handle = tokio::spawn(async move {
let mut lines_in_mem = 0;
for path in file_paths {
if lines_in_mem > MAX_LINES_IN_MEM {
break;
}
if let Some(injected_lines) =
try_inject_lines(&injector, &current_dir, &path)
{
lines_in_mem += injected_lines;
}
}
});
Channel {
matcher,
crawl_handle,
selected_entries: HashSet::with_hasher(FxBuildHasher),
}
}
fn from_text_entries(entries: Vec<Entry>) -> Self {
let matcher = Matcher::new(Config::default());
let injector = matcher.injector();
let load_handle = tokio::spawn(async move {
for entry in entries.into_iter().take(MAX_LINES_IN_MEM) {
let v = entry.value.unwrap();
injector.push(
CandidateLine::new(
entry.name.into(),
v,
entry.line_number.unwrap(),
),
|e, cols| {
cols[0] = e.line.clone().into();
},
);
}
});
Channel {
matcher,
crawl_handle: load_handle,
selected_entries: HashSet::with_hasher(FxBuildHasher),
}
}
}
impl Default for Channel {
fn default() -> Self {
Self::new(vec![std::env::current_dir().unwrap()])
}
}
/// Since we're limiting the number of lines in memory, it makes sense to also limit the number of files
/// we're willing to search in when piping from the `Files` channel.
/// This prevents blocking the UI for too long when piping from a channel with a lot of files.
///
/// This should be calculated based on the number of lines we're willing to keep in memory:
/// `MAX_LINES_IN_MEM / 100` (assuming 100 lines per file on average).
const MAX_PIPED_FILES: usize = MAX_LINES_IN_MEM / 200;
impl From<&mut TelevisionChannel> for Channel {
fn from(value: &mut TelevisionChannel) -> Self {
match value {
c @ TelevisionChannel::Files(_) => {
let entries = if c.selected_entries().is_empty() {
c.results(
c.result_count().min(
u32::try_from(MAX_PIPED_FILES).unwrap_or(u32::MAX),
),
0,
)
} else {
c.selected_entries().iter().cloned().collect()
};
Self::from_file_paths(
entries
.iter()
.flat_map(|entry| {
PathBuf::from(entry.name.clone()).canonicalize()
})
.collect(),
)
}
c @ TelevisionChannel::GitRepos(_) => {
let entries = if c.selected_entries().is_empty() {
c.results(c.result_count(), 0)
} else {
c.selected_entries().iter().cloned().collect()
};
Self::new(
entries
.iter()
.flat_map(|entry| {
PathBuf::from(entry.name.clone()).canonicalize()
})
.collect(),
)
}
c @ TelevisionChannel::Text(_) => {
let entries = if c.selected_entries().is_empty() {
c.results(c.result_count(), 0)
} else {
c.selected_entries().iter().cloned().collect()
};
Self::from_text_entries(entries)
}
c @ TelevisionChannel::Dirs(_) => {
let entries = if c.selected_entries().is_empty() {
c.results(c.result_count(), 0)
} else {
c.selected_entries().iter().cloned().collect()
};
Self::new(
entries
.iter()
.map(|entry| PathBuf::from(&entry.name))
.collect(),
)
}
_ => unreachable!(),
}
}
}
impl OnAir for Channel {
fn find(&mut self, pattern: &str) {
self.matcher.find(pattern);
}
fn results(&mut self, num_entries: u32, offset: u32) -> Vec<Entry> {
self.matcher.tick();
self.matcher
.results(num_entries, offset)
.into_iter()
.map(|item| {
let line = item.matched_string;
let display_path =
item.inner.path.to_string_lossy().to_string();
Entry::new(display_path, PreviewType::Files)
.with_value(line)
.with_value_match_indices(&item.match_indices)
.with_icon(FileIcon::from(item.inner.path.as_path()))
.with_line_number(item.inner.line_number)
})
.collect()
}
fn get_result(&self, index: u32) -> Option<Entry> {
self.matcher.get_result(index).map(|item| {
let display_path = item.inner.path.to_string_lossy().to_string();
Entry::new(display_path, PreviewType::Files)
.with_icon(FileIcon::from(item.inner.path.as_path()))
.with_line_number(item.inner.line_number)
})
}
fn selected_entries(&self) -> &FxHashSet<Entry> {
&self.selected_entries
}
fn toggle_selection(&mut self, entry: &Entry) {
if self.selected_entries.contains(entry) {
self.selected_entries.remove(entry);
} else {
self.selected_entries.insert(entry.clone());
}
}
fn result_count(&self) -> u32 {
self.matcher.matched_item_count
}
fn total_count(&self) -> u32 {
self.matcher.total_item_count
}
fn running(&self) -> bool {
self.matcher.status.running || !self.crawl_handle.is_finished()
}
fn shutdown(&self) {
self.crawl_handle.abort();
}
fn supports_preview(&self) -> bool {
true
}
}
/// The maximum file size we're willing to search in.
///
/// This is to prevent taking humongous amounts of memory when searching in
/// a lot of files (e.g. starting tv in $HOME).
const MAX_FILE_SIZE: u64 = 4 * 1024 * 1024;
/// The maximum number of lines we're willing to keep in memory.
///
/// TODO: this should be configurable by the user depending on the amount of
/// memory they have/are willing to use.
///
/// This is to prevent taking humongous amounts of memory when searching in
/// a lot of files (e.g. starting tv in $HOME).
///
/// This is a soft limit, we might go over it a bit.
///
/// A typical line should take somewhere around 100 bytes in memory (for utf8 english text),
/// so this should take around 100 x `10_000_000` = 1GB of memory.
const MAX_LINES_IN_MEM: usize = 10_000_000;
#[allow(clippy::unused_async)]
async fn crawl_for_candidates(
directories: Vec<PathBuf>,
injector: Injector<CandidateLine>,
) {
if directories.is_empty() {
return;
}
let current_dir = std::env::current_dir().unwrap();
let mut walker =
walk_builder(&directories[0], get_default_num_threads(), None, None);
directories[1..].iter().for_each(|path| {
walker.add(path);
});
let lines_in_mem = Arc::new(AtomicUsize::new(0));
walker.build_parallel().run(|| {
let injector = injector.clone();
let current_dir = current_dir.clone();
let lines_in_mem = lines_in_mem.clone();
Box::new(move |result| {
if lines_in_mem.load(std::sync::atomic::Ordering::Relaxed)
> MAX_LINES_IN_MEM
{
return WalkState::Quit;
}
if let Ok(entry) = result {
if entry.file_type().unwrap().is_file() {
if let Ok(m) = entry.metadata() {
if m.len() > MAX_FILE_SIZE {
return WalkState::Continue;
}
}
// try to inject the lines of the file
if let Some(injected_lines) =
try_inject_lines(&injector, &current_dir, entry.path())
{
lines_in_mem.fetch_add(
injected_lines,
std::sync::atomic::Ordering::Relaxed,
);
}
}
}
WalkState::Continue
})
});
}
fn try_inject_lines(
injector: &Injector<CandidateLine>,
current_dir: &PathBuf,
path: &Path,
) -> Option<usize> {
match File::open(path) {
Ok(file) => {
// is the file a text-based file?
let mut reader = std::io::BufReader::new(&file);
let mut buffer = [0u8; 128];
match reader.read(&mut buffer) {
Ok(bytes_read) => {
if bytes_read == 0
|| proportion_of_printable_ascii_characters(
&buffer[..bytes_read],
) < PRINTABLE_ASCII_THRESHOLD
{
debug!("Skipping non-text file {:?}", path);
return None;
}
reader.seek(std::io::SeekFrom::Start(0)).unwrap();
}
Err(e) => {
warn!("Error reading file {:?}: {:?}", path, e);
return None;
}
}
// read the lines of the file
let mut line_number = 0;
let mut injected_lines = 0;
for maybe_line in reader.lines() {
match maybe_line {
Ok(l) => {
line_number += 1;
if l.is_empty() {
trace!("Empty line");
continue;
}
let candidate = CandidateLine::new(
path.strip_prefix(current_dir)
.unwrap_or(path)
.to_path_buf(),
l.clone(),
line_number,
);
let () = injector.push(candidate, |e, cols| {
cols[0] = e.line.clone().into();
});
injected_lines += 1;
}
Err(e) => {
warn!("Error reading line: {:?}", e);
break;
}
}
}
Some(injected_lines)
}
Err(e) => {
warn!("Error opening file {:?}: {:?}", path, e);
None
}
}
}

View File

@ -5,9 +5,7 @@ use anyhow::{anyhow, Result};
use tracing::debug; use tracing::debug;
use crate::channels::cable::{parse_preview_kind, PreviewKind}; use crate::channels::cable::{parse_preview_kind, PreviewKind};
use crate::channels::{ use crate::channels::{cable::CableChannelPrototype, entry::PreviewCommand};
cable::CableChannelPrototype, entry::PreviewCommand, CliTvChannel,
};
use crate::cli::args::{Cli, Command}; use crate::cli::args::{Cli, Command};
use crate::config::KeyBindings; use crate::config::KeyBindings;
use crate::{ use crate::{
@ -20,7 +18,7 @@ pub mod args;
#[allow(clippy::struct_excessive_bools)] #[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct PostProcessedCli { pub struct PostProcessedCli {
pub channel: ParsedCliChannel, pub channel: CableChannelPrototype,
pub preview_kind: PreviewKind, pub preview_kind: PreviewKind,
pub no_preview: bool, pub no_preview: bool,
pub tick_rate: Option<f64>, pub tick_rate: Option<f64>,
@ -40,7 +38,7 @@ pub struct PostProcessedCli {
impl Default for PostProcessedCli { impl Default for PostProcessedCli {
fn default() -> Self { fn default() -> Self {
Self { Self {
channel: ParsedCliChannel::Builtin(CliTvChannel::Files), channel: CableChannelPrototype::default(),
preview_kind: PreviewKind::None, preview_kind: PreviewKind::None,
no_preview: false, no_preview: false,
tick_rate: None, tick_rate: None,
@ -85,7 +83,7 @@ impl From<Cli> for PostProcessedCli {
.unwrap() .unwrap()
}); });
let channel: ParsedCliChannel; let channel: CableChannelPrototype;
let working_directory: Option<String>; let working_directory: Option<String>;
match parse_channel(&cli.channel) { match parse_channel(&cli.channel) {
@ -99,7 +97,7 @@ impl From<Cli> for PostProcessedCli {
if cli.working_directory.is_none() if cli.working_directory.is_none()
&& Path::new(&cli.channel).exists() && Path::new(&cli.channel).exists()
{ {
channel = ParsedCliChannel::Builtin(CliTvChannel::Files); channel = CableChannelPrototype::default();
working_directory = Some(cli.channel.clone()); working_directory = Some(cli.channel.clone());
} else { } else {
unknown_channel_exit(&cli.channel); unknown_channel_exit(&cli.channel);
@ -138,21 +136,6 @@ fn unknown_channel_exit(channel: &str) {
std::process::exit(1); std::process::exit(1);
} }
#[derive(Debug, Clone, PartialEq)]
pub enum ParsedCliChannel {
Builtin(CliTvChannel),
Cable(CableChannelPrototype),
}
impl ParsedCliChannel {
pub fn name(&self) -> String {
match self {
Self::Builtin(c) => c.to_string(),
Self::Cable(c) => c.name.clone(),
}
}
}
const CLI_KEYBINDINGS_DELIMITER: char = ';'; const CLI_KEYBINDINGS_DELIMITER: char = ';';
/// Parse a keybindings literal into a `KeyBindings` struct. /// Parse a keybindings literal into a `KeyBindings` struct.
@ -174,45 +157,20 @@ fn parse_keybindings_literal(
toml::from_str(&toml_definition).map_err(|e| anyhow!(e)) toml::from_str(&toml_definition).map_err(|e| anyhow!(e))
} }
pub fn parse_channel(channel: &str) -> Result<ParsedCliChannel> { pub fn parse_channel(channel: &str) -> Result<CableChannelPrototype> {
let cable_channels = cable::load_cable_channels().unwrap_or_default(); let cable_channels = cable::load_cable_channels().unwrap_or_default();
// try to parse the channel as a cable channel // try to parse the channel as a cable channel
cable_channels match cable_channels
.iter() .iter()
.find(|(k, _)| k.to_lowercase() == channel) .find(|(k, _)| k.to_lowercase() == channel)
.map_or_else( {
|| { Some((_, v)) => Ok(v.clone()),
// try to parse the channel as a builtin channel None => Err(anyhow!("Unknown channel: {channel}")),
CliTvChannel::try_from(channel) }
.map(ParsedCliChannel::Builtin)
.map_err(|_| anyhow!("Unknown channel: '{}'", channel))
},
|(_, v)| Ok(ParsedCliChannel::Cable(v.clone())),
)
}
pub fn list_cable_channels() -> Vec<String> {
cable::load_cable_channels()
.unwrap_or_default()
.iter()
.map(|(k, _)| k.clone())
.collect()
}
pub fn list_builtin_channels() -> Vec<String> {
CliTvChannel::all_channels()
.iter()
.map(std::string::ToString::to_string)
.collect()
} }
pub fn list_channels() { pub fn list_channels() {
println!("\x1b[4mBuiltin channels:\x1b[0m"); for c in cable::load_cable_channels().unwrap_or_default().keys() {
for c in list_builtin_channels() {
println!("\t{c}");
}
println!("\n\x1b[4mCustom channels:\x1b[0m");
for c in list_cable_channels().iter().map(|c| c.to_lowercase()) {
println!("\t{c}"); println!("\t{c}");
} }
} }
@ -243,8 +201,8 @@ pub fn list_channels() {
pub fn guess_channel_from_prompt( pub fn guess_channel_from_prompt(
prompt: &str, prompt: &str,
command_mapping: &FxHashMap<String, String>, command_mapping: &FxHashMap<String, String>,
fallback_channel: ParsedCliChannel, fallback_channel: CableChannelPrototype,
) -> Result<ParsedCliChannel> { ) -> Result<CableChannelPrototype> {
debug!("Guessing channel from prompt: {}", prompt); debug!("Guessing channel from prompt: {}", prompt);
// git checkout -qf // git checkout -qf
// --- -------- --- <--------- // --- -------- --- <---------
@ -338,7 +296,7 @@ mod tests {
assert_eq!( assert_eq!(
post_processed_cli.channel, post_processed_cli.channel,
ParsedCliChannel::Builtin(CliTvChannel::Files) CableChannelPrototype::default(),
); );
assert_eq!( assert_eq!(
post_processed_cli.preview_kind, post_processed_cli.preview_kind,
@ -368,7 +326,7 @@ mod tests {
assert_eq!( assert_eq!(
post_processed_cli.channel, post_processed_cli.channel,
ParsedCliChannel::Builtin(CliTvChannel::Files) CableChannelPrototype::default(),
); );
assert_eq!( assert_eq!(
post_processed_cli.working_directory, post_processed_cli.working_directory,
@ -436,15 +394,16 @@ mod tests {
assert_eq!(post_processed_cli.keybindings, Some(expected)); assert_eq!(post_processed_cli.keybindings, Some(expected));
} }
/// Returns a tuple containing a command mapping and a fallback channel.
fn guess_channel_from_prompt_setup( fn guess_channel_from_prompt_setup(
) -> (FxHashMap<String, String>, ParsedCliChannel) { ) -> (FxHashMap<String, String>, CableChannelPrototype) {
let mut command_mapping = FxHashMap::default(); let mut command_mapping = FxHashMap::default();
command_mapping.insert("vim".to_string(), "files".to_string()); command_mapping.insert("vim".to_string(), "files".to_string());
command_mapping.insert("export".to_string(), "env".to_string()); command_mapping.insert("export".to_string(), "env".to_string());
( (
command_mapping, command_mapping,
ParsedCliChannel::Builtin(CliTvChannel::Env), CableChannelPrototype::new("env", "", false, None, None),
) )
} }
@ -458,7 +417,7 @@ mod tests {
guess_channel_from_prompt(prompt, &command_mapping, fallback) guess_channel_from_prompt(prompt, &command_mapping, fallback)
.unwrap(); .unwrap();
assert_eq!(channel.name(), "files"); assert_eq!(channel.name, "files");
} }
#[test] #[test]

View File

@ -109,7 +109,6 @@ pub struct Theme {
// modes // modes
pub channel_mode_fg: Color, pub channel_mode_fg: Color,
pub remote_control_mode_fg: Color, pub remote_control_mode_fg: Color,
pub send_to_channel_mode_fg: Color,
} }
impl Theme { impl Theme {
@ -180,7 +179,6 @@ struct Inner {
//modes //modes
channel_mode_fg: String, channel_mode_fg: String,
remote_control_mode_fg: String, remote_control_mode_fg: String,
send_to_channel_mode_fg: String,
} }
impl<'de> Deserialize<'de> for Theme { impl<'de> Deserialize<'de> for Theme {
@ -308,15 +306,6 @@ impl<'de> Deserialize<'de> for Theme {
&inner.remote_control_mode_fg &inner.remote_control_mode_fg
)) ))
})?, })?,
send_to_channel_mode_fg: Color::from_str(
&inner.send_to_channel_mode_fg,
)
.ok_or_else(|| {
serde::de::Error::custom(format!(
"invalid color {}",
&inner.send_to_channel_mode_fg
))
})?,
}) })
} }
} }
@ -439,7 +428,6 @@ impl Into<ModeColorscheme> for &Theme {
ModeColorscheme { ModeColorscheme {
channel: (&self.channel_mode_fg).into(), channel: (&self.channel_mode_fg).into(),
remote_control: (&self.remote_control_mode_fg).into(), remote_control: (&self.remote_control_mode_fg).into(),
send_to_channel: (&self.send_to_channel_mode_fg).into(),
} }
} }
} }
@ -466,7 +454,6 @@ mod tests {
preview_title_fg = "bright-white" preview_title_fg = "bright-white"
channel_mode_fg = "bright-white" channel_mode_fg = "bright-white"
remote_control_mode_fg = "bright-white" remote_control_mode_fg = "bright-white"
send_to_channel_mode_fg = "bright-white"
"##; "##;
let theme: Theme = toml::from_str(theme_content).unwrap(); let theme: Theme = toml::from_str(theme_content).unwrap();
assert_eq!( assert_eq!(
@ -496,10 +483,6 @@ mod tests {
theme.remote_control_mode_fg, theme.remote_control_mode_fg,
Color::Ansi(ANSIColor::BrightWhite) Color::Ansi(ANSIColor::BrightWhite)
); );
assert_eq!(
theme.send_to_channel_mode_fg,
Color::Ansi(ANSIColor::BrightWhite)
);
} }
#[test] #[test]
@ -519,7 +502,6 @@ mod tests {
preview_title_fg = "bright-white" preview_title_fg = "bright-white"
channel_mode_fg = "bright-white" channel_mode_fg = "bright-white"
remote_control_mode_fg = "bright-white" remote_control_mode_fg = "bright-white"
send_to_channel_mode_fg = "bright-white"
"##; "##;
let theme: Theme = toml::from_str(theme_content).unwrap(); let theme: Theme = toml::from_str(theme_content).unwrap();
assert_eq!(theme.background, None); assert_eq!(theme.background, None);
@ -549,9 +531,5 @@ mod tests {
theme.remote_control_mode_fg, theme.remote_control_mode_fg,
Color::Ansi(ANSIColor::BrightWhite) Color::Ansi(ANSIColor::BrightWhite)
); );
assert_eq!(
theme.send_to_channel_mode_fg,
Color::Ansi(ANSIColor::BrightWhite)
);
} }
} }

View File

@ -16,8 +16,7 @@ use television::channels::{
}; };
use television::cli::{ use television::cli::{
args::{Cli, Command}, args::{Cli, Command},
guess_channel_from_prompt, list_channels, ParsedCliChannel, guess_channel_from_prompt, list_channels, PostProcessedCli,
PostProcessedCli,
}; };
use television::config::{merge_keybindings, Config, ConfigEnv}; use television::config::{merge_keybindings, Config, ConfigEnv};
@ -167,26 +166,17 @@ pub fn determine_channel(
parse_channel(&config.shell_integration.fallback_channel)?, parse_channel(&config.shell_integration.fallback_channel)?,
)?; )?;
debug!("Using guessed channel: {:?}", channel); debug!("Using guessed channel: {:?}", channel);
match channel { Ok(TelevisionChannel::Cable(channel.into()))
ParsedCliChannel::Builtin(c) => Ok(c.to_channel()),
ParsedCliChannel::Cable(c) => {
Ok(TelevisionChannel::Cable(c.into()))
}
}
} else { } else {
debug!("Using {:?} channel", args.channel); debug!("Using {:?} channel", args.channel);
match args.channel { Ok(TelevisionChannel::Cable(args.channel.into()))
ParsedCliChannel::Builtin(c) => Ok(c.to_channel()),
ParsedCliChannel::Cable(c) => {
Ok(TelevisionChannel::Cable(c.into()))
}
}
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use rustc_hash::FxHashMap; use rustc_hash::FxHashMap;
use television::channels::cable::CableChannelPrototype;
use super::*; use super::*;
@ -199,8 +189,9 @@ mod tests {
let channel = let channel =
determine_channel(args.clone(), config, readable_stdin).unwrap(); determine_channel(args.clone(), config, readable_stdin).unwrap();
assert!( assert_eq!(
channel.name() == expected_channel.name(), channel.name(),
expected_channel.name(),
"Expected {:?} but got {:?}", "Expected {:?} but got {:?}",
expected_channel.name(), expected_channel.name(),
channel.name() channel.name()
@ -208,10 +199,9 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
/// Test that the channel is stdin when stdin is readable
async fn test_determine_channel_readable_stdin() { async fn test_determine_channel_readable_stdin() {
let channel = television::cli::ParsedCliChannel::Builtin( let channel = CableChannelPrototype::default();
television::channels::CliTvChannel::Env,
);
let args = PostProcessedCli { let args = PostProcessedCli {
channel, channel,
..Default::default() ..Default::default()
@ -228,8 +218,9 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_determine_channel_autocomplete_prompt() { async fn test_determine_channel_autocomplete_prompt() {
let autocomplete_prompt = Some("cd".to_string()); let autocomplete_prompt = Some("cd".to_string());
let expected_channel = television::channels::TelevisionChannel::Dirs( let expected_channel = TelevisionChannel::Cable(
television::channels::dirs::Channel::default(), CableChannelPrototype::new("dirs", "ls {}", false, None, None)
.into(),
); );
let args = PostProcessedCli { let args = PostProcessedCli {
autocomplete_prompt, autocomplete_prompt,
@ -256,9 +247,8 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_determine_channel_standard_case() { async fn test_determine_channel_standard_case() {
let channel = television::cli::ParsedCliChannel::Builtin( let channel =
television::channels::CliTvChannel::Dirs, CableChannelPrototype::new("dirs", "", false, None, None);
);
let args = PostProcessedCli { let args = PostProcessedCli {
channel, channel,
..Default::default() ..Default::default()
@ -268,8 +258,9 @@ mod tests {
&args, &args,
&config, &config,
false, false,
&TelevisionChannel::Dirs( &TelevisionChannel::Cable(
television::channels::dirs::Channel::default(), CableChannelPrototype::new("dirs", "", false, None, None)
.into(),
), ),
); );
} }

View File

@ -107,7 +107,7 @@ impl Preview {
} }
} }
#[derive(Debug, Default, Clone, PartialEq, Hash)] #[derive(Debug, Clone, PartialEq, Hash)]
pub struct PreviewState { pub struct PreviewState {
pub enabled: bool, pub enabled: bool,
pub preview: Arc<Preview>, pub preview: Arc<Preview>,
@ -115,6 +115,17 @@ pub struct PreviewState {
pub target_line: Option<u16>, pub target_line: Option<u16>,
} }
impl Default for PreviewState {
fn default() -> Self {
PreviewState {
enabled: false,
preview: Arc::new(Preview::default()),
scroll: 0,
target_line: None,
}
}
}
const PREVIEW_MIN_SCROLL_LINES: u16 = 3; const PREVIEW_MIN_SCROLL_LINES: u16 = 3;
impl PreviewState { impl PreviewState {

View File

@ -51,5 +51,4 @@ pub struct InputColorscheme {
pub struct ModeColorscheme { pub struct ModeColorscheme {
pub channel: Color, pub channel: Color,
pub remote_control: Color, pub remote_control: Color,
pub send_to_channel: Color,
} }

View File

@ -56,13 +56,6 @@ impl KeyBindings {
&[Action::CopyEntryToClipboard], &[Action::CopyEntryToClipboard],
), ),
), ),
(
DisplayableAction::SendToChannel,
serialized_keys_for_actions(
self,
&[Action::ToggleSendToChannel],
),
),
( (
DisplayableAction::ToggleRemoteControl, DisplayableAction::ToggleRemoteControl,
serialized_keys_for_actions( serialized_keys_for_actions(
@ -101,41 +94,12 @@ impl KeyBindings {
), ),
]); ]);
// send to channel mode keybindings
let send_to_channel_bindings: FxHashMap<
DisplayableAction,
Vec<String>,
> = FxHashMap::from_iter(vec![
(
DisplayableAction::ResultsNavigation,
serialized_keys_for_actions(
self,
&[Action::SelectPrevEntry, Action::SelectNextEntry],
),
),
(
DisplayableAction::SelectEntry,
serialized_keys_for_actions(self, &[Action::ConfirmSelection]),
),
(
DisplayableAction::Cancel,
serialized_keys_for_actions(
self,
&[Action::ToggleSendToChannel],
),
),
]);
FxHashMap::from_iter(vec![ FxHashMap::from_iter(vec![
(Mode::Channel, DisplayableKeybindings::new(channel_bindings)), (Mode::Channel, DisplayableKeybindings::new(channel_bindings)),
( (
Mode::RemoteControl, Mode::RemoteControl,
DisplayableKeybindings::new(remote_control_bindings), DisplayableKeybindings::new(remote_control_bindings),
), ),
(
Mode::SendToChannel,
DisplayableKeybindings::new(send_to_channel_bindings),
),
]) ])
} }
} }
@ -167,7 +131,6 @@ pub enum DisplayableAction {
PreviewNavigation, PreviewNavigation,
SelectEntry, SelectEntry,
CopyEntryToClipboard, CopyEntryToClipboard,
SendToChannel,
ToggleRemoteControl, ToggleRemoteControl,
Cancel, Cancel,
Quit, Quit,
@ -183,7 +146,6 @@ impl Display for DisplayableAction {
DisplayableAction::CopyEntryToClipboard => { DisplayableAction::CopyEntryToClipboard => {
"Copy entry to clipboard" "Copy entry to clipboard"
} }
DisplayableAction::SendToChannel => "Send to channel",
DisplayableAction::ToggleRemoteControl => "Toggle Remote control", DisplayableAction::ToggleRemoteControl => "Toggle Remote control",
DisplayableAction::Cancel => "Cancel", DisplayableAction::Cancel => "Cancel",
DisplayableAction::Quit => "Quit", DisplayableAction::Quit => "Quit",
@ -207,12 +169,6 @@ pub fn build_keybindings_table<'a>(
&keybindings[&mode], &keybindings[&mode],
colorscheme, colorscheme,
), ),
Mode::SendToChannel => {
build_keybindings_table_for_channel_transitions(
&keybindings[&mode],
colorscheme,
)
}
} }
} }
@ -268,18 +224,6 @@ fn build_keybindings_table_for_channel<'a>(
colorscheme.mode.channel, colorscheme.mode.channel,
)); ));
// Send to channel
let send_to_channel_keys = keybindings
.bindings
.get(&DisplayableAction::SendToChannel)
.unwrap();
let send_to_channel_row = Row::new(build_cells_for_group(
"Send results to",
send_to_channel_keys,
colorscheme.help.metadata_field_name_fg,
colorscheme.mode.channel,
));
// Switch channels // Switch channels
let switch_channels_keys = keybindings let switch_channels_keys = keybindings
.bindings .bindings
@ -300,7 +244,6 @@ fn build_keybindings_table_for_channel<'a>(
preview_row, preview_row,
select_entry_row, select_entry_row,
copy_entry_row, copy_entry_row,
send_to_channel_row,
switch_channels_row, switch_channels_row,
], ],
widths, widths,
@ -353,52 +296,6 @@ fn build_keybindings_table_for_channel_selection<'a>(
) )
} }
fn build_keybindings_table_for_channel_transitions<'a>(
keybindings: &'a DisplayableKeybindings,
colorscheme: &'a Colorscheme,
) -> Table<'a> {
// Results navigation
let results_navigation_keys = keybindings
.bindings
.get(&DisplayableAction::ResultsNavigation)
.unwrap();
let results_row = Row::new(build_cells_for_group(
"Browse channels",
results_navigation_keys,
colorscheme.help.metadata_field_name_fg,
colorscheme.mode.send_to_channel,
));
// Select entry
let select_entry_keys = keybindings
.bindings
.get(&DisplayableAction::SelectEntry)
.unwrap();
let select_entry_row = Row::new(build_cells_for_group(
"Send to channel",
select_entry_keys,
colorscheme.help.metadata_field_name_fg,
colorscheme.mode.send_to_channel,
));
// Cancel
let cancel_keys = keybindings
.bindings
.get(&DisplayableAction::Cancel)
.unwrap();
let cancel_row = Row::new(build_cells_for_group(
"Cancel",
cancel_keys,
colorscheme.help.metadata_field_name_fg,
colorscheme.mode.send_to_channel,
));
Table::new(
vec![results_row, select_entry_row, cancel_row],
vec![Constraint::Fill(1), Constraint::Fill(2)],
)
}
fn build_cells_for_group<'a>( fn build_cells_for_group<'a>(
group_name: &str, group_name: &str,
keys: &'a [String], keys: &'a [String],

View File

@ -15,7 +15,6 @@ impl Display for Mode {
match self { match self {
Mode::Channel => write!(f, "Channel"), Mode::Channel => write!(f, "Channel"),
Mode::RemoteControl => write!(f, "Remote Control"), Mode::RemoteControl => write!(f, "Remote Control"),
Mode::SendToChannel => write!(f, "Send to Channel"),
} }
} }
} }

View File

@ -6,6 +6,5 @@ pub fn mode_color(mode: Mode, colorscheme: &ModeColorscheme) -> Color {
match mode { match mode {
Mode::Channel => colorscheme.channel, Mode::Channel => colorscheme.channel,
Mode::RemoteControl => colorscheme.remote_control, Mode::RemoteControl => colorscheme.remote_control,
Mode::SendToChannel => colorscheme.send_to_channel,
} }
} }

View File

@ -1,9 +1,8 @@
use crate::action::Action; use crate::action::Action;
use crate::cable::load_cable_channels; use crate::cable::load_cable_channels;
use crate::channels::entry::{Entry, ENTRY_PLACEHOLDER}; use crate::channels::entry::{Entry, PreviewType, ENTRY_PLACEHOLDER};
use crate::channels::{ use crate::channels::{
remote_control::{load_builtin_channels, RemoteControl}, remote_control::RemoteControl, OnAir, TelevisionChannel,
OnAir, TelevisionChannel, UnitChannel,
}; };
use crate::config::{Config, Theme}; use crate::config::{Config, Theme};
use crate::draw::{ChannelState, Ctx, TvState}; use crate::draw::{ChannelState, Ctx, TvState};
@ -28,7 +27,6 @@ use tokio::sync::mpsc::UnboundedSender;
pub enum Mode { pub enum Mode {
Channel, Channel,
RemoteControl, RemoteControl,
SendToChannel,
} }
#[derive(PartialEq, Copy, Clone, Hash, Eq, Debug, Serialize, Deserialize)] #[derive(PartialEq, Copy, Clone, Hash, Eq, Debug, Serialize, Deserialize)]
@ -75,9 +73,6 @@ impl Television {
} }
let previewer = Previewer::new(Some(config.previewers.clone().into())); let previewer = Previewer::new(Some(config.previewers.clone().into()));
let cable_channels = load_cable_channels().unwrap_or_default(); let cable_channels = load_cable_channels().unwrap_or_default();
let builtin_channels = load_builtin_channels(Some(
&cable_channels.keys().collect::<Vec<_>>(),
));
let app_metadata = AppMetadata::new( let app_metadata = AppMetadata::new(
env!("CARGO_PKG_VERSION").to_string(), env!("CARGO_PKG_VERSION").to_string(),
@ -101,10 +96,9 @@ impl Television {
let remote_control = if no_remote { let remote_control = if no_remote {
None None
} else { } else {
Some(TelevisionChannel::RemoteControl(RemoteControl::new( Some(TelevisionChannel::RemoteControl(RemoteControl::new(Some(
builtin_channels, cable_channels,
Some(cable_channels), ))))
)))
}; };
if no_help { if no_help {
@ -146,11 +140,8 @@ impl Television {
pub fn init_remote_control(&mut self) { pub fn init_remote_control(&mut self) {
let cable_channels = load_cable_channels().unwrap_or_default(); let cable_channels = load_cable_channels().unwrap_or_default();
let builtin_channels = load_builtin_channels(Some(
&cable_channels.keys().collect::<Vec<_>>(),
));
self.remote_control = Some(TelevisionChannel::RemoteControl( self.remote_control = Some(TelevisionChannel::RemoteControl(
RemoteControl::new(builtin_channels, Some(cable_channels)), RemoteControl::new(Some(cable_channels)),
)); ));
} }
@ -182,12 +173,13 @@ impl Television {
) )
} }
pub fn current_channel(&self) -> UnitChannel { pub fn current_channel(&self) -> String {
UnitChannel::from(&self.channel) self.channel.name()
} }
pub fn change_channel(&mut self, channel: TelevisionChannel) { pub fn change_channel(&mut self, channel: TelevisionChannel) {
self.preview_state.reset(); self.preview_state.reset();
self.preview_state.enabled = channel.supports_preview();
self.reset_picker_selection(); self.reset_picker_selection();
self.reset_picker_input(); self.reset_picker_input();
self.current_pattern = EMPTY_STRING.to_string(); self.current_pattern = EMPTY_STRING.to_string();
@ -203,7 +195,7 @@ impl Television {
.as_str(), .as_str(),
); );
} }
Mode::RemoteControl | Mode::SendToChannel => { Mode::RemoteControl => {
self.remote_control.as_mut().unwrap().find(pattern); self.remote_control.as_mut().unwrap().find(pattern);
} }
} }
@ -233,7 +225,7 @@ impl Television {
} }
None None
} }
Mode::RemoteControl | Mode::SendToChannel => { Mode::RemoteControl => {
if let Some(i) = self.rc_picker.selected() { if let Some(i) = self.rc_picker.selected() {
return self return self
.remote_control .remote_control
@ -268,7 +260,7 @@ impl Television {
Mode::Channel => { Mode::Channel => {
(self.channel.result_count(), &mut self.results_picker) (self.channel.result_count(), &mut self.results_picker)
} }
Mode::RemoteControl | Mode::SendToChannel => ( Mode::RemoteControl => (
self.remote_control.as_ref().unwrap().total_count(), self.remote_control.as_ref().unwrap().total_count(),
&mut self.rc_picker, &mut self.rc_picker,
), ),
@ -288,7 +280,7 @@ impl Television {
Mode::Channel => { Mode::Channel => {
(self.channel.result_count(), &mut self.results_picker) (self.channel.result_count(), &mut self.results_picker)
} }
Mode::RemoteControl | Mode::SendToChannel => ( Mode::RemoteControl => (
self.remote_control.as_ref().unwrap().total_count(), self.remote_control.as_ref().unwrap().total_count(),
&mut self.rc_picker, &mut self.rc_picker,
), ),
@ -306,7 +298,7 @@ impl Television {
fn reset_picker_selection(&mut self) { fn reset_picker_selection(&mut self) {
match self.mode { match self.mode {
Mode::Channel => self.results_picker.reset_selection(), Mode::Channel => self.results_picker.reset_selection(),
Mode::RemoteControl | Mode::SendToChannel => { Mode::RemoteControl => {
self.rc_picker.reset_selection(); self.rc_picker.reset_selection();
} }
} }
@ -315,7 +307,7 @@ impl Television {
fn reset_picker_input(&mut self) { fn reset_picker_input(&mut self) {
match self.mode { match self.mode {
Mode::Channel => self.results_picker.reset_input(), Mode::Channel => self.results_picker.reset_input(),
Mode::RemoteControl | Mode::SendToChannel => { Mode::RemoteControl => {
self.rc_picker.reset_input(); self.rc_picker.reset_input();
} }
} }
@ -376,7 +368,9 @@ impl Television {
&mut self, &mut self,
selected_entry: &Entry, selected_entry: &Entry,
) -> Result<()> { ) -> Result<()> {
if self.config.ui.show_preview_panel && self.channel.supports_preview() if self.config.ui.show_preview_panel
&& self.channel.supports_preview()
&& !matches!(selected_entry.preview_type, PreviewType::None)
{ {
// preview content // preview content
if let Some(preview) = self if let Some(preview) = self
@ -453,9 +447,7 @@ impl Television {
pub fn handle_input_action(&mut self, action: &Action) { pub fn handle_input_action(&mut self, action: &Action) {
let input = match self.mode { let input = match self.mode {
Mode::Channel => &mut self.results_picker.input, Mode::Channel => &mut self.results_picker.input,
Mode::RemoteControl | Mode::SendToChannel => { Mode::RemoteControl => &mut self.rc_picker.input,
&mut self.rc_picker.input
}
}; };
input.handle(convert_action_to_input_request(action).unwrap()); input.handle(convert_action_to_input_request(action).unwrap());
match action { match action {
@ -493,27 +485,6 @@ impl Television {
self.reset_picker_selection(); self.reset_picker_selection();
self.mode = Mode::Channel; self.mode = Mode::Channel;
} }
Mode::SendToChannel => {}
}
}
pub fn handle_toggle_send_to_channel(&mut self) {
if self.remote_control.is_none() {
return;
}
match self.mode {
Mode::Channel | Mode::RemoteControl => {
self.mode = Mode::SendToChannel;
self.remote_control = Some(TelevisionChannel::RemoteControl(
RemoteControl::with_transitions_from(&self.channel),
));
}
Mode::SendToChannel => {
self.reset_picker_input();
self.remote_control.as_mut().unwrap().find(EMPTY_STRING);
self.reset_picker_selection();
self.mode = Mode::Channel;
}
} }
} }
@ -550,18 +521,6 @@ impl Television {
self.change_channel(new_channel); self.change_channel(new_channel);
} }
} }
Mode::SendToChannel => {
if let Some(entry) = self.get_selected_entry(None) {
let new_channel = self
.channel
.transition_to(entry.name.as_str().try_into()?);
self.reset_picker_selection();
self.reset_picker_input();
self.remote_control.as_mut().unwrap().find(EMPTY_STRING);
self.mode = Mode::Channel;
self.change_channel(new_channel);
}
}
} }
Ok(()) Ok(())
} }
@ -644,9 +603,6 @@ impl Television {
Action::CopyEntryToClipboard => { Action::CopyEntryToClipboard => {
self.handle_copy_entry_to_clipboard(); self.handle_copy_entry_to_clipboard();
} }
Action::ToggleSendToChannel => {
self.handle_toggle_send_to_channel();
}
Action::ToggleHelp => { Action::ToggleHelp => {
if self.no_help { if self.no_help {
return Ok(()); return Ok(());

View File

@ -3,7 +3,7 @@ use std::{collections::HashSet, path::PathBuf, time::Duration};
use television::{ use television::{
action::Action, action::Action,
app::{App, AppOptions}, app::{App, AppOptions},
channels::TelevisionChannel, channels::{cable::CableChannelPrototype, TelevisionChannel},
config::default_config_from_file, config::default_config_from_file,
}; };
use tokio::{task::JoinHandle, time::timeout}; use tokio::{task::JoinHandle, time::timeout};
@ -33,9 +33,7 @@ fn setup_app(
.join("tests") .join("tests")
.join("target_dir"); .join("target_dir");
std::env::set_current_dir(&target_dir).unwrap(); std::env::set_current_dir(&target_dir).unwrap();
TelevisionChannel::Files(television::channels::files::Channel::new( TelevisionChannel::Cable(CableChannelPrototype::default().into())
vec![target_dir],
))
}); });
let mut config = default_config_from_file().unwrap(); let mut config = default_config_from_file().unwrap();
// this speeds up the tests // this speeds up the tests