refactor!(previews): drop builtin previewers and all related code and dependencies (#495)

BREAKING CHANGE: No more builtin previews which means channels currently using `:files:` and other builtins will now need to rely on external tools (examples to come).
This commit is contained in:
Alexandre Pasmantier 2025-05-06 00:07:50 +02:00 committed by GitHub
parent 1086899ba7
commit 58d73dbeba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 650 additions and 4033 deletions

View File

@ -61,15 +61,6 @@ orientation = "landscape"
# directory in your configuration directory (see the `config.toml` location above).
theme = "default"
# Previewers settings
# ----------------------------------------------------------------------------
[previewers.file]
# The theme to use for syntax highlighting.
# Bulitin syntax highlighting uses the same syntax highlighting engine as bat.
# To get a list of your currently available themes, run `bat --list-themes`
# Note that setting the BAT_THEME environment variable will override this setting.
theme = "TwoDark"
# Keybindings
# ----------------------------------------------------------------------------
#

1094
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -48,19 +48,13 @@ ratatui = { version = "0.29", features = ["serde", "macros"] }
better-panic = "0.3"
signal-hook = "0.3"
human-panic = "2.0"
strum = { version = "0.26", features = ["derive"] }
regex = "1.11"
parking_lot = "0.12"
nom = "7.1"
thiserror = "2.0"
simdutf8 = { version = "0.1", optional = true }
smallvec = { version = "1.13", features = ["const_generics"] }
gag = "1.0"
nucleo = "0.5"
toml = "0.8"
image = "0.25"
syntect = { package = "syntect", version = "5.2", default-features = false }
bat = { package = "bat", version = "0.25", default-features = false }
lazy-regex = { version = "3.4.1", features = [
"lite",
], default-features = false }
ansi-to-tui = "7.0.0"
# target specific dependencies
@ -79,15 +73,6 @@ clipboard-win = "5.4.0"
criterion = { version = "0.5", features = ["async_tokio"] }
tempfile = "3.16.0"
[features]
simd = ["dep:simdutf8"]
zero-copy = []
# Use fancy-regex for aarch64 Linux
fancy = ["syntect/regex-fancy", "bat/regex-fancy"]
# Use oniguruma for other platforms
onig = ["syntect/regex-onig", "bat/regex-onig"]
default = ["zero-copy", "simd", "onig"]
[build-dependencies]
clap = { version = "4.5", features = ["derive", "cargo"] }

View File

@ -8,13 +8,13 @@ use ratatui::prelude::{Line, Style};
use ratatui::style::Color;
use ratatui::widgets::{Block, BorderType, Borders, ListDirection, Padding};
use ratatui::Terminal;
use television::channels::cable::prototypes::CableChannelPrototypes;
use television::{
action::Action,
channels::{
cable::prototypes::CableChannelPrototype,
entry::{into_ranges, Entry},
preview::PreviewType,
OnAir, TelevisionChannel,
OnAir,
},
config::{Config, ConfigEnv},
screen::{colors::ResultsColorscheme, results::build_results_list},
@ -37,7 +37,6 @@ pub fn draw_results_list(c: &mut Criterion) {
color: "#7e8e91",
}),
line_number: None,
preview_type: PreviewType::Files,
},
Entry {
name: "typeshed/README.md".to_string(),
@ -49,7 +48,6 @@ pub fn draw_results_list(c: &mut Criterion) {
color: "#dddddd",
}),
line_number: None,
preview_type: PreviewType::Files,
},
Entry {
name: "typeshed/stdlib/re.pyi".to_string(),
@ -61,7 +59,6 @@ pub fn draw_results_list(c: &mut Criterion) {
color: "#ffbc03",
}),
line_number: None,
preview_type: PreviewType::Files,
},
Entry {
name: "typeshed/stdlib/io.pyi".to_string(),
@ -73,7 +70,6 @@ pub fn draw_results_list(c: &mut Criterion) {
color: "#ffbc03",
}),
line_number: None,
preview_type: PreviewType::Files,
},
Entry {
name: "typeshed/stdlib/gc.pyi".to_string(),
@ -85,7 +81,6 @@ pub fn draw_results_list(c: &mut Criterion) {
color: "#ffbc03",
}),
line_number: None,
preview_type: PreviewType::Files,
},
Entry {
name: "typeshed/stdlib/uu.pyi".to_string(),
@ -97,7 +92,6 @@ pub fn draw_results_list(c: &mut Criterion) {
color: "#ffbc03",
}),
line_number: None,
preview_type: PreviewType::Files,
},
Entry {
name: "typeshed/stdlib/nt.pyi".to_string(),
@ -109,7 +103,6 @@ pub fn draw_results_list(c: &mut Criterion) {
color: "#ffbc03",
}),
line_number: None,
preview_type: PreviewType::Files,
},
Entry {
name: "typeshed/stdlib/dis.pyi".to_string(),
@ -121,7 +114,6 @@ pub fn draw_results_list(c: &mut Criterion) {
color: "#ffbc03",
}),
line_number: None,
preview_type: PreviewType::Files,
},
Entry {
name: "typeshed/stdlib/imp.pyi".to_string(),
@ -133,7 +125,6 @@ pub fn draw_results_list(c: &mut Criterion) {
color: "#ffbc03",
}),
line_number: None,
preview_type: PreviewType::Files,
},
Entry {
name: "typeshed/stdlib/bdb.pyi".to_string(),
@ -145,7 +136,6 @@ pub fn draw_results_list(c: &mut Criterion) {
color: "#ffbc03",
}),
line_number: None,
preview_type: PreviewType::Files,
},
Entry {
name: "typeshed/stdlib/abc.pyi".to_string(),
@ -157,7 +147,6 @@ pub fn draw_results_list(c: &mut Criterion) {
color: "#ffbc03",
}),
line_number: None,
preview_type: PreviewType::Files,
},
Entry {
name: "typeshed/stdlib/cgi.pyi".to_string(),
@ -169,7 +158,6 @@ pub fn draw_results_list(c: &mut Criterion) {
color: "#ffbc03",
}),
line_number: None,
preview_type: PreviewType::Files,
},
Entry {
name: "typeshed/stdlib/bz2.pyi".to_string(),
@ -181,7 +169,6 @@ pub fn draw_results_list(c: &mut Criterion) {
color: "#ffbc03",
}),
line_number: None,
preview_type: PreviewType::Files,
},
Entry {
name: "typeshed/stdlib/grp.pyi".to_string(),
@ -193,7 +180,6 @@ pub fn draw_results_list(c: &mut Criterion) {
color: "#ffbc03",
}),
line_number: None,
preview_type: PreviewType::Files,
},
Entry {
name: "typeshed/stdlib/ast.pyi".to_string(),
@ -205,7 +191,6 @@ pub fn draw_results_list(c: &mut Criterion) {
color: "#ffbc03",
}),
line_number: None,
preview_type: PreviewType::Files,
},
Entry {
name: "typeshed/stdlib/csv.pyi".to_string(),
@ -217,7 +202,6 @@ pub fn draw_results_list(c: &mut Criterion) {
color: "#ffbc03",
}),
line_number: None,
preview_type: PreviewType::Files,
},
Entry {
name: "typeshed/stdlib/pdb.pyi".to_string(),
@ -229,7 +213,6 @@ pub fn draw_results_list(c: &mut Criterion) {
color: "#ffbc03",
}),
line_number: None,
preview_type: PreviewType::Files,
},
Entry {
name: "typeshed/stdlib/pwd.pyi".to_string(),
@ -241,7 +224,6 @@ pub fn draw_results_list(c: &mut Criterion) {
color: "#ffbc03",
}),
line_number: None,
preview_type: PreviewType::Files,
},
Entry {
name: "typeshed/stdlib/ssl.pyi".to_string(),
@ -253,7 +235,6 @@ pub fn draw_results_list(c: &mut Criterion) {
color: "#ffbc03",
}),
line_number: None,
preview_type: PreviewType::Files,
},
Entry {
name: "typeshed/stdlib/tty.pyi".to_string(),
@ -265,7 +246,6 @@ pub fn draw_results_list(c: &mut Criterion) {
color: "#ffbc03",
}),
line_number: None,
preview_type: PreviewType::Files,
},
Entry {
name: "typeshed/stdlib/nis.pyi".to_string(),
@ -277,7 +257,6 @@ pub fn draw_results_list(c: &mut Criterion) {
color: "#ffbc03",
}),
line_number: None,
preview_type: PreviewType::Files,
},
Entry {
name: "typeshed/stdlib/pty.pyi".to_string(),
@ -289,7 +268,6 @@ pub fn draw_results_list(c: &mut Criterion) {
color: "#ffbc03",
}),
line_number: None,
preview_type: PreviewType::Files,
},
Entry {
name: "typeshed/stdlib/cmd.pyi".to_string(),
@ -301,7 +279,6 @@ pub fn draw_results_list(c: &mut Criterion) {
color: "#ffbc03",
}),
line_number: None,
preview_type: PreviewType::Files,
},
Entry {
name: "typeshed/tests/utils.py".to_string(),
@ -313,7 +290,6 @@ pub fn draw_results_list(c: &mut Criterion) {
color: "#ffbc03",
}),
line_number: None,
preview_type: PreviewType::Files,
},
Entry {
name: "typeshed/pyproject.toml".to_string(),
@ -325,7 +301,6 @@ pub fn draw_results_list(c: &mut Criterion) {
color: "#9c4221",
}),
line_number: None,
preview_type: PreviewType::Files,
},
Entry {
name: "typeshed/MAINTAINERS.md".to_string(),
@ -337,7 +312,6 @@ pub fn draw_results_list(c: &mut Criterion) {
color: "#dddddd",
}),
line_number: None,
preview_type: PreviewType::Files,
},
Entry {
name: "typeshed/stdlib/enum.pyi".to_string(),
@ -349,7 +323,6 @@ pub fn draw_results_list(c: &mut Criterion) {
color: "#ffbc03",
}),
line_number: None,
preview_type: PreviewType::Files,
},
Entry {
name: "typeshed/stdlib/hmac.pyi".to_string(),
@ -361,7 +334,6 @@ pub fn draw_results_list(c: &mut Criterion) {
color: "#ffbc03",
}),
line_number: None,
preview_type: PreviewType::Files,
},
Entry {
name: "typeshed/stdlib/uuid.pyi".to_string(),
@ -373,7 +345,6 @@ pub fn draw_results_list(c: &mut Criterion) {
color: "#ffbc03",
}),
line_number: None,
preview_type: PreviewType::Files,
},
Entry {
name: "typeshed/stdlib/glob.pyi".to_string(),
@ -385,7 +356,6 @@ pub fn draw_results_list(c: &mut Criterion) {
color: "#ffbc03",
}),
line_number: None,
preview_type: PreviewType::Files,
},
Entry {
name: "typeshed/stdlib/_ast.pyi".to_string(),
@ -397,7 +367,6 @@ pub fn draw_results_list(c: &mut Criterion) {
color: "#ffbc03",
}),
line_number: None,
preview_type: PreviewType::Files,
},
Entry {
name: "typeshed/stdlib/_csv.pyi".to_string(),
@ -409,7 +378,6 @@ pub fn draw_results_list(c: &mut Criterion) {
color: "#ffbc03",
}),
line_number: None,
preview_type: PreviewType::Files,
},
Entry {
name: "typeshed/stdlib/code.pyi".to_string(),
@ -421,7 +389,6 @@ pub fn draw_results_list(c: &mut Criterion) {
color: "#ffbc03",
}),
line_number: None,
preview_type: PreviewType::Files,
},
Entry {
name: "typeshed/stdlib/spwd.pyi".to_string(),
@ -433,7 +400,6 @@ pub fn draw_results_list(c: &mut Criterion) {
color: "#ffbc03",
}),
line_number: None,
preview_type: PreviewType::Files,
},
Entry {
name: "typeshed/stdlib/_msi.pyi".to_string(),
@ -445,7 +411,6 @@ pub fn draw_results_list(c: &mut Criterion) {
color: "#ffbc03",
}),
line_number: None,
preview_type: PreviewType::Files,
},
Entry {
name: "typeshed/stdlib/time.pyi".to_string(),
@ -456,7 +421,6 @@ pub fn draw_results_list(c: &mut Criterion) {
color: "#ffbc03",
}),
line_number: None,
preview_type: PreviewType::Files,
name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
value_match_ranges: None,
},
@ -508,14 +472,19 @@ pub fn draw(c: &mut Criterion) {
let backend = TestBackend::new(width, height);
let terminal = Terminal::new(backend).unwrap();
let (tx, _) = tokio::sync::mpsc::unbounded_channel();
let mut channel = TelevisionChannel::Cable(
CableChannelPrototype::default().into(),
);
channel.find("television");
let channel = CableChannelPrototype::default();
// Wait for the channel to finish loading
let mut tv = Television::new(
tx, channel, config, None, false, false, false,
tx,
channel,
config,
None,
false,
false,
false,
CableChannelPrototypes::default(),
);
tv.find("television");
for _ in 0..5 {
// tick the matcher
let _ = tv.channel.results(10, 0);

View File

@ -2,7 +2,7 @@
[[cable_channel]]
name = "files"
source_command = "fd -t f"
preview_command = ":files:"
preview_command = "bat -n --color=always {}"
# Text
[[cable_channel]]
@ -72,7 +72,7 @@ preview_command = "aws s3 ls s3://{0}"
[[cable_channel]]
name = "my-dotfiles"
source_command = "fd -t f . $HOME/.config"
preview_command = ":files:"
preview_command = "bat -n --color=always {}"
# Shell history
[[cable_channel]]

View File

@ -2,13 +2,13 @@
[[cable_channel]]
name = "files"
source_command = "Get-ChildItem -Recurse -File | Select-Object -ExpandProperty FullName"
preview_command = ":files:"
preview_command = "bat -n --color=always {}"
# Directories
[[cable_channel]]
name = "dirs"
source_command = "Get-ChildItem -Recurse -Directory | Select-Object -ExpandProperty FullName"
preview_command = "ls -l {0}"
preview_command = "ls -l {}"
# Environment variables
[[cable_channel]]
@ -29,7 +29,7 @@ preview_command = "cd '{}' ; git log -n 200 --pretty=medium --all --graph --colo
[[cable_channel]]
name = "git-diff"
source_command = "git diff --name-only"
preview_command = "git diff --color=always {0}"
preview_command = "git diff --color=always {}"
[[cable_channel]]
name = "git-reflog"
@ -56,7 +56,7 @@ preview_command = "docker image inspect {0} | jq -C"
[[cable_channel]]
name = "my-dotfiles"
source_command = "Get-ChildItem -Recurse -File -Path \"$env:USERPROFILE\\AppData\\Roaming\\\""
preview_command = ":files:"
preview_command = "bat -n --color=always {}"
# Shell history
[[cable_channel]]

View File

@ -4,9 +4,10 @@ use anyhow::Result;
use tokio::sync::mpsc;
use tracing::{debug, trace};
use crate::channels::{
entry::Entry, preview::PreviewType, OnAir, TelevisionChannel,
use crate::channels::cable::prototypes::{
CableChannelPrototype, CableChannelPrototypes,
};
use crate::channels::{entry::Entry, OnAir};
use crate::config::{default_tick_rate, Config};
use crate::keymap::Keymap;
use crate::render::UiState;
@ -123,7 +124,6 @@ impl From<ActionOutcome> for AppOutput {
ActionOutcome::Input(input) => Self {
selected_entries: Some(FxHashSet::from_iter([Entry::new(
input,
PreviewType::None,
)])),
},
ActionOutcome::None => Self {
@ -138,10 +138,11 @@ const ACTION_BUF_SIZE: usize = 8;
impl App {
pub fn new(
channel: TelevisionChannel,
channel_prototype: CableChannelPrototype,
config: Config,
input: Option<String>,
options: AppOptions,
cable_channels: &CableChannelPrototypes,
) -> Self {
let (action_tx, action_rx) = mpsc::unbounded_channel();
let (render_tx, render_rx) = mpsc::unbounded_channel();
@ -153,12 +154,13 @@ impl App {
let (ui_state_tx, ui_state_rx) = mpsc::unbounded_channel();
let television = Television::new(
action_tx.clone(),
channel,
channel_prototype,
config,
input,
options.no_remote,
options.no_help,
options.exact,
CableChannelPrototypes((*cable_channels).clone()),
);
Self {
@ -201,7 +203,7 @@ impl App {
// Event loop
if !headless {
debug!("Starting backend event loop");
let event_loop = EventLoop::new(self.options.tick_rate, true);
let event_loop = EventLoop::new(self.options.tick_rate);
self.event_rx = event_loop.rx;
self.event_abort_tx = event_loop.abort_tx;
}
@ -436,32 +438,3 @@ impl App {
None
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::channels::stdin::Channel as StdinChannel;
#[test]
fn test_maybe_select_1() {
let mut app = App::new(
TelevisionChannel::Stdin(StdinChannel::new(PreviewType::None)),
Config::default(),
None,
AppOptions::default(),
);
app.television
.results_picker
.entries
.push(Entry::new("test".to_string(), PreviewType::None));
let outcome = app.maybe_select_1();
assert!(outcome.is_some());
assert_eq!(
outcome.unwrap(),
ActionOutcome::Entries(FxHashSet::from_iter([Entry::new(
"test".to_string(),
PreviewType::None
)]))
);
}
}

View File

@ -6,7 +6,9 @@ use anyhow::Result;
use tracing::{debug, error};
use crate::{
channels::cable::prototypes::{CableChannelPrototype, CableChannels},
channels::cable::prototypes::{
CableChannelPrototype, CableChannelPrototypes,
},
config::get_config_dir,
};
@ -40,7 +42,7 @@ const DEFAULT_CABLE_CHANNELS: &str =
/// ├── my_channels.toml
/// └── windows_channels.toml
/// ```
pub fn load_cable_channels() -> Result<CableChannels> {
pub fn load_cable_channels() -> Result<CableChannelPrototypes> {
let config_dir = get_config_dir();
// list all files in the config directory
@ -95,7 +97,7 @@ pub fn load_cable_channels() -> Result<CableChannels> {
{
cable_channels.insert(prototype.name.clone(), prototype);
}
Ok(CableChannels(cable_channels))
Ok(CableChannelPrototypes(cable_channels))
}
fn is_cable_file_format<P>(p: P) -> bool

View File

@ -6,12 +6,7 @@ use prototypes::{CableChannelPrototype, DEFAULT_DELIMITER};
use rustc_hash::{FxBuildHasher, FxHashSet};
use tracing::debug;
use crate::channels::preview::parse_preview_type;
use crate::channels::{
entry::Entry,
preview::{PreviewCommand, PreviewType},
OnAir,
};
use crate::channels::{entry::Entry, preview::PreviewCommand, OnAir};
use crate::matcher::Matcher;
use crate::matcher::{config::Config, injector::Injector};
use crate::utils::command::shell_command;
@ -23,7 +18,7 @@ pub struct Channel {
pub name: String,
matcher: Matcher<String>,
entries_command: String,
preview_type: PreviewType,
pub preview_command: Option<PreviewCommand>,
selected_entries: FxHashSet<Entry>,
crawl_handle: tokio::task::JoinHandle<()>,
}
@ -34,7 +29,7 @@ impl Default for Channel {
"files",
"find . -type f",
false,
Some(PreviewCommand::new("cat {}", ":")),
Some(PreviewCommand::new("cat {}", ":", None)),
)
}
}
@ -51,6 +46,7 @@ impl From<CableChannelPrototype> for Channel {
&prototype
.preview_delimiter
.unwrap_or(DEFAULT_DELIMITER.to_string()),
prototype.preview_offset,
)),
None => None,
},
@ -72,19 +68,10 @@ impl Channel {
interactive,
injector,
));
let preview_kind = match preview_command {
Some(command) => {
parse_preview_type(&command).unwrap_or_else(|_| {
panic!("Invalid preview command: {command}")
})
}
None => PreviewType::None,
};
debug!("Preview kind: {:?}", preview_kind);
Self {
matcher,
entries_command: entries_command.to_string(),
preview_type: preview_kind,
preview_command,
name: name.to_string(),
selected_entries: HashSet::with_hasher(FxBuildHasher),
crawl_handle,
@ -149,8 +136,7 @@ impl OnAir for Channel {
.into_iter()
.map(|item| {
let path = item.matched_string;
Entry::new(path, self.preview_type.clone())
.with_name_match_indices(&item.match_indices)
Entry::new(path).with_name_match_indices(&item.match_indices)
})
.collect()
}
@ -158,7 +144,7 @@ impl OnAir for Channel {
fn get_result(&self, index: u32) -> Option<Entry> {
self.matcher.get_result(index).map(|item| {
let path = item.matched_string;
Entry::new(path, self.preview_type.clone())
Entry::new(path)
})
}
@ -189,6 +175,6 @@ impl OnAir for Channel {
fn shutdown(&self) {}
fn supports_preview(&self) -> bool {
self.preview_type != PreviewType::None
self.preview_command.is_some()
}
}

View File

@ -4,7 +4,9 @@ use std::{
ops::Deref,
};
use crate::cable::SerializedChannelPrototypes;
use crate::{
cable::SerializedChannelPrototypes, channels::preview::PreviewCommand,
};
/// A prototype for a cable channel.
///
@ -35,7 +37,7 @@ use crate::cable::SerializedChannelPrototypes;
/// [[cable_channel]]
/// name = "files"
/// source_command = "fd -t f"
/// preview_command = ":files:"
/// preview_command = "cat {}"
/// ```
#[derive(Clone, Debug, serde::Deserialize, PartialEq)]
pub struct CableChannelPrototype {
@ -49,6 +51,9 @@ pub struct CableChannelPrototype {
pub preview_offset: Option<String>,
}
const STDIN_CHANNEL_NAME: &str = "stdin";
const STDIN_SOURCE_COMMAND: &str = "cat";
impl CableChannelPrototype {
pub fn new(
name: &str,
@ -67,23 +72,42 @@ impl CableChannelPrototype {
preview_offset,
}
}
pub fn stdin(preview: Option<PreviewCommand>) -> Self {
match preview {
Some(PreviewCommand {
command,
delimiter,
offset_expr,
}) => Self {
name: STDIN_CHANNEL_NAME.to_string(),
source_command: STDIN_SOURCE_COMMAND.to_string(),
interactive: false,
preview_command: Some(command),
preview_delimiter: Some(delimiter),
preview_offset: offset_expr,
},
None => Self {
name: STDIN_CHANNEL_NAME.to_string(),
source_command: STDIN_SOURCE_COMMAND.to_string(),
interactive: false,
preview_command: None,
preview_delimiter: None,
preview_offset: None,
},
}
}
}
const DEFAULT_PROTOTYPE_NAME: &str = "files";
const DEFAULT_SOURCE_COMMAND: &str = "fd -t f";
const DEFAULT_PREVIEW_COMMAND: &str = ":files:";
pub const DEFAULT_DELIMITER: &str = " ";
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()),
preview_offset: None,
}
CableChannelPrototypes::default()
.get(DEFAULT_PROTOTYPE_NAME)
.cloned()
.unwrap()
}
}
@ -106,9 +130,11 @@ impl Display for CableChannelPrototype {
/// in a way that facilitates answering questions like "what's the prototype
/// for `files`?" or "does this channel exist?".
#[derive(Debug, serde::Deserialize)]
pub struct CableChannels(pub FxHashMap<String, CableChannelPrototype>);
pub struct CableChannelPrototypes(
pub FxHashMap<String, CableChannelPrototype>,
);
impl Deref for CableChannels {
impl Deref for CableChannelPrototypes {
type Target = FxHashMap<String, CableChannelPrototype>;
fn deref(&self) -> &Self::Target {
@ -125,20 +151,20 @@ const DEFAULT_CABLE_CHANNELS_FILE: &str =
/// application.
#[cfg(not(unix))]
const DEFAULT_CABLE_CHANNELS_FILE: &str =
include_str!("../../cable/windows-channels.toml");
include_str!("../../../cable/windows-channels.toml");
impl Default for CableChannels {
impl Default for CableChannelPrototypes {
/// Fallback to the default cable channels specification (the template file
/// included in the repo).
fn default() -> Self {
let pts = toml::from_str::<SerializedChannelPrototypes>(
let s = toml::from_str::<SerializedChannelPrototypes>(
DEFAULT_CABLE_CHANNELS_FILE,
)
.expect("Unable to parse default cable channels");
let mut channels = FxHashMap::default();
for prototype in pts.prototypes {
channels.insert(prototype.name.clone(), prototype);
let mut prototypes = FxHashMap::default();
for prototype in s.prototypes {
prototypes.insert(prototype.name.clone(), prototype);
}
CableChannels(channels)
CableChannelPrototypes(prototypes)
}
}

View File

@ -2,8 +2,6 @@ use std::hash::{Hash, Hasher};
use devicons::FileIcon;
use crate::channels::preview::PreviewType;
// NOTE: having an enum for entry types would be nice since it would allow
// having a nicer implementation for transitions between channels. This would
// permit implementing `From<EntryType>` for channels which would make the
@ -24,8 +22,6 @@ pub struct Entry {
pub icon: Option<FileIcon>,
/// The optional line number associated with the entry.
pub line_number: Option<usize>,
/// The type of preview associated with the entry.
pub preview_type: PreviewType,
}
impl Hash for Entry {
@ -85,10 +81,10 @@ impl Entry {
///
/// Additional fields can be set using the builder pattern.
/// ```
/// use television::channels::{entry::Entry, preview::PreviewType};
/// use television::channels::entry::Entry;
/// use devicons::FileIcon;
///
/// let entry = Entry::new("name".to_string(), PreviewType::EnvVar)
/// let entry = Entry::new("name".to_string())
/// .with_value("value".to_string())
/// .with_name_match_indices(&vec![0])
/// .with_value_match_indices(&vec![0])
@ -98,12 +94,11 @@ impl Entry {
///
/// # Arguments
/// * `name` - The name of the entry.
/// * `preview_type` - The type of preview associated with the entry.
///
/// # Returns
/// A new entry with the given name and preview type.
/// The other fields are set to `None` by default.
pub fn new(name: String, preview_type: PreviewType) -> Self {
pub fn new(name: String) -> Self {
Self {
name,
value: None,
@ -111,7 +106,6 @@ impl Entry {
value_match_ranges: None,
icon: None,
line_number: None,
preview_type,
}
}
@ -149,16 +143,6 @@ impl Entry {
}
}
pub const ENTRY_PLACEHOLDER: Entry = Entry {
name: String::new(),
value: None,
name_match_ranges: None,
value_match_ranges: None,
icon: None,
line_number: None,
preview_type: PreviewType::EnvVar,
};
#[cfg(test)]
mod tests {
use super::*;
@ -196,7 +180,6 @@ mod tests {
value_match_ranges: None,
icon: None,
line_number: None,
preview_type: PreviewType::Basic,
};
assert_eq!(entry.stdout_repr(), "test name with spaces");
}
@ -210,7 +193,6 @@ mod tests {
value_match_ranges: None,
icon: None,
line_number: Some(a),
preview_type: PreviewType::Basic,
};
assert_eq!(entry.stdout_repr(), "test_file_name.rs:10");
}

View File

@ -1,5 +1,6 @@
use crate::channels::entry::Entry;
use anyhow::Result;
use cable::prototypes::CableChannelPrototype;
use rustc_hash::FxHashSet;
use television_derive::Broadcast;
@ -7,7 +8,6 @@ pub mod cable;
pub mod entry;
pub mod preview;
pub mod remote_control;
pub mod stdin;
/// The interface that all television channels must implement.
///
@ -115,10 +115,6 @@ pub trait OnAir: Send {
#[allow(dead_code, clippy::module_name_repetitions)]
#[derive(Broadcast)]
pub enum TelevisionChannel {
/// The standard input channel.
///
/// This channel allows to search through whatever is passed through stdin.
Stdin(stdin::Channel),
/// The remote control channel.
///
/// This channel allows to switch between different channels.
@ -130,19 +126,18 @@ pub enum TelevisionChannel {
}
impl TelevisionChannel {
pub fn zap(&self, channel_name: &str) -> Result<TelevisionChannel> {
pub fn zap(&self, channel_name: &str) -> Result<CableChannelPrototype> {
match self {
TelevisionChannel::RemoteControl(remote_control) => {
remote_control.zap(channel_name)
}
_ => unreachable!(),
TelevisionChannel::Cable(_) => unreachable!(),
}
}
pub fn name(&self) -> String {
match self {
TelevisionChannel::Cable(channel) => channel.name.clone(),
TelevisionChannel::Stdin(_) => String::from("Stdin"),
TelevisionChannel::RemoteControl(_) => String::from("Remote"),
}
}

View File

@ -1,21 +1,86 @@
use std::fmt::Display;
use anyhow::Result;
use regex::Regex;
use strum::EnumString;
use super::{cable::prototypes::DEFAULT_DELIMITER, entry::Entry};
use crate::channels::cable::prototypes::CableChannelPrototype;
use lazy_regex::{regex, Lazy, Regex};
use tracing::debug;
static CMD_RE: &Lazy<Regex> = regex!(r"\{(\d+)\}");
#[derive(Debug, Clone, Eq, PartialEq, Hash, Default)]
pub struct PreviewCommand {
pub command: String,
pub delimiter: String,
pub offset_expr: Option<String>,
}
impl PreviewCommand {
pub fn new(command: &str, delimiter: &str) -> Self {
pub fn new(
command: &str,
delimiter: &str,
offset_expr: Option<String>,
) -> Self {
Self {
command: command.to_string(),
delimiter: delimiter.to_string(),
offset_expr,
}
}
/// Format the command with the entry name and provided placeholders.
///
/// # Example
/// ```
/// use television::channels::{preview::PreviewCommand, entry::Entry};
///
/// let command = PreviewCommand {
/// command: "something {} {2} {0}".to_string(),
/// delimiter: ":".to_string(),
/// offset_expr: None,
/// };
/// let entry = Entry::new("a:given:entry:to:preview".to_string());
///
/// let formatted_command = command.format_with(&entry);
///
/// assert_eq!(formatted_command, "something 'a:given:entry:to:preview' 'entry' 'a'");
/// ```
pub fn format_with(&self, entry: &Entry) -> String {
let parts = entry.name.split(&self.delimiter).collect::<Vec<&str>>();
let mut formatted_command = self
.command
.replace("{}", format!("'{}'", entry.name).as_str());
debug!("FORMATTED_COMMAND: {formatted_command}");
debug!("PARTS: {parts:?}");
formatted_command = CMD_RE
.replace_all(&formatted_command, |caps: &regex::Captures| {
let index =
caps.get(1).unwrap().as_str().parse::<usize>().unwrap();
format!("'{}'", parts[index])
})
.to_string();
formatted_command
}
}
impl From<&CableChannelPrototype> for Option<PreviewCommand> {
fn from(value: &CableChannelPrototype) -> Self {
if let Some(command) = value.preview_command.as_ref() {
let delimiter = value
.preview_delimiter
.as_ref()
.map_or(DEFAULT_DELIMITER, |v| v);
let offset_expr = value.preview_offset.clone();
// FIXME: handle offset here (side note: we don't want to reparse the offset
// expression for each entry, so maybe just parse it once and try to store it
// as some sort of function we can call later on
Some(PreviewCommand::new(command, delimiter, offset_expr))
} else {
None
}
}
}
@ -26,38 +91,63 @@ impl Display for PreviewCommand {
}
}
#[derive(Debug, Clone, Eq, PartialEq, Hash, Default, EnumString)]
#[strum(serialize_all = "snake_case")]
pub enum PreviewType {
Basic,
EnvVar,
Files,
#[strum(disabled)]
Command(PreviewCommand),
#[default]
None,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::channels::entry::Entry;
/// Parses the preview command to determine the preview type.
///
/// This checks if the command matches the builtin pattern `:{preview_type}:`
/// and then falls back to the command type if it doesn't.
///
/// # Example:
/// ```
/// use television::channels::preview::{parse_preview_type, PreviewCommand, PreviewType};
///
/// let command = PreviewCommand::new("cat {0}", ":");
/// let preview_type = parse_preview_type(&command).unwrap();
/// assert_eq!(preview_type, PreviewType::Command(command));
/// ```
pub fn parse_preview_type(command: &PreviewCommand) -> Result<PreviewType> {
debug!("Parsing preview kind for command: {:?}", command);
let re = Regex::new(r"^\:(\w+)\:$").unwrap();
if let Some(captures) = re.captures(&command.command) {
let preview_type = PreviewType::try_from(&captures[1])?;
Ok(preview_type)
} else {
Ok(PreviewType::Command(command.clone()))
#[test]
fn test_format_command() {
let command = PreviewCommand {
command: "something {} {2} {0}".to_string(),
delimiter: ":".to_string(),
offset_expr: None,
};
let entry = Entry::new("an:entry:to:preview".to_string());
let formatted_command = command.format_with(&entry);
assert_eq!(
formatted_command,
"something 'an:entry:to:preview' 'to' 'an'"
);
}
#[test]
fn test_format_command_no_placeholders() {
let command = PreviewCommand {
command: "something".to_string(),
delimiter: ":".to_string(),
offset_expr: None,
};
let entry = Entry::new("an:entry:to:preview".to_string());
let formatted_command = command.format_with(&entry);
assert_eq!(formatted_command, "something");
}
#[test]
fn test_format_command_with_global_placeholder_only() {
let command = PreviewCommand {
command: "something {}".to_string(),
delimiter: ":".to_string(),
offset_expr: None,
};
let entry = Entry::new("an:entry:to:preview".to_string());
let formatted_command = command.format_with(&entry);
assert_eq!(formatted_command, "something 'an:entry:to:preview'");
}
#[test]
fn test_format_command_with_positional_placeholders_only() {
let command = PreviewCommand {
command: "something {0} -t {2}".to_string(),
delimiter: ":".to_string(),
offset_expr: None,
};
let entry = Entry::new("an:entry:to:preview".to_string());
let formatted_command = command.format_with(&entry);
assert_eq!(formatted_command, "something 'an' -t 'to'");
}
}

View File

@ -1,30 +1,30 @@
use std::collections::HashSet;
use crate::channels::cable::prototypes::CableChannels;
use crate::channels::{entry::Entry, preview::PreviewType};
use crate::channels::{OnAir, TelevisionChannel};
use crate::channels::cable::prototypes::CableChannelPrototypes;
use crate::channels::entry::Entry;
use crate::channels::OnAir;
use crate::matcher::{config::Config, Matcher};
use anyhow::Result;
use devicons::FileIcon;
use rustc_hash::{FxBuildHasher, FxHashSet};
use super::cable;
use super::cable::prototypes::CableChannelPrototype;
pub struct RemoteControl {
matcher: Matcher<String>,
cable_channels: Option<CableChannels>,
cable_channels: Option<CableChannelPrototypes>,
selected_entries: FxHashSet<Entry>,
}
const NUM_THREADS: usize = 1;
impl RemoteControl {
pub fn new(cable_channels: Option<CableChannels>) -> Self {
pub fn new(cable_channels: Option<CableChannelPrototypes>) -> Self {
let matcher = Matcher::new(Config::default().n_threads(NUM_THREADS));
let injector = matcher.injector();
for c in cable_channels
.as_ref()
.unwrap_or(&CableChannels::default())
.unwrap_or(&CableChannelPrototypes::default())
.keys()
{
let () = injector.push(c.clone(), |e, cols| {
@ -38,15 +38,13 @@ impl RemoteControl {
}
}
pub fn zap(&self, channel_name: &str) -> Result<TelevisionChannel> {
pub fn zap(&self, channel_name: &str) -> Result<CableChannelPrototype> {
match self
.cable_channels
.as_ref()
.and_then(|channels| channels.get(channel_name).cloned())
{
Some(prototype) => {
Ok(TelevisionChannel::Cable(cable::Channel::from(prototype)))
}
Some(prototype) => Ok(prototype),
None => Err(anyhow::anyhow!(
"No channel or cable channel prototype found for {}",
channel_name
@ -83,7 +81,7 @@ impl OnAir for RemoteControl {
.into_iter()
.map(|item| {
let path = item.matched_string;
Entry::new(path, PreviewType::Basic)
Entry::new(path)
.with_name_match_indices(&item.match_indices)
.with_icon(CABLE_ICON)
})
@ -93,7 +91,7 @@ impl OnAir for RemoteControl {
fn get_result(&self, index: u32) -> Option<Entry> {
self.matcher.get_result(index).map(|item| {
let path = item.matched_string;
Entry::new(path, PreviewType::Basic).with_icon(TV_ICON)
Entry::new(path).with_icon(TV_ICON)
})
}

View File

@ -1,156 +0,0 @@
use std::{
collections::HashSet,
io::{stdin, BufRead},
thread::spawn,
};
use rustc_hash::{FxBuildHasher, FxHashSet};
use tracing::debug;
use super::OnAir;
use crate::channels::{entry::Entry, preview::PreviewType};
use crate::matcher::{config::Config, injector::Injector, Matcher};
pub struct Channel {
matcher: Matcher<String>,
preview_type: PreviewType,
selected_entries: FxHashSet<Entry>,
instream_handle: std::thread::JoinHandle<()>,
}
impl Channel {
pub fn new(preview_type: PreviewType) -> Self {
let matcher = Matcher::new(Config::default());
let injector = matcher.injector();
let instream_handle = spawn(move || stream_from_stdin(&injector));
Self {
matcher,
preview_type,
selected_entries: HashSet::with_hasher(FxBuildHasher),
instream_handle,
}
}
}
impl Default for Channel {
fn default() -> Self {
Self::new(PreviewType::default())
}
}
impl<E> From<E> for Channel
where
E: AsRef<Vec<String>>,
{
fn from(entries: E) -> Self {
let matcher = Matcher::new(Config::default());
let injector = matcher.injector();
let entries = entries.as_ref().clone();
let instream_handle = spawn(move || {
for entry in entries {
injector.push(entry.clone(), |e, cols| {
cols[0] = e.to_string().into();
});
}
});
Self {
matcher,
preview_type: PreviewType::default(),
selected_entries: HashSet::with_hasher(FxBuildHasher),
instream_handle,
}
}
}
const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
fn stream_from_stdin(injector: &Injector<String>) {
let mut stdin = stdin().lock();
let mut buffer = String::new();
let instant = std::time::Instant::now();
loop {
match stdin.read_line(&mut buffer) {
Ok(c) if c > 0 => {
let trimmed = buffer.trim();
if !trimmed.is_empty() {
injector.push(trimmed.to_owned(), |e, cols| {
cols[0] = e.clone().into();
});
}
buffer.clear();
}
Ok(0) => {
debug!("EOF");
break;
}
_ => {
debug!("Error reading from stdin");
if instant.elapsed() > TIMEOUT {
break;
}
}
}
}
}
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| {
// NOTE: we're passing `PreviewType::Basic` here just as a placeholder
// to avoid storing the preview command multiple times for each item.
Entry::new(item.matched_string, PreviewType::Basic)
.with_name_match_indices(&item.match_indices)
})
.collect()
}
fn get_result(&self, index: u32) -> Option<Entry> {
self.matcher.get_result(index).map(|item| {
Entry::new(item.matched_string, self.preview_type.clone())
})
}
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.instream_handle.is_finished()
}
fn shutdown(&self) {}
fn supports_preview(&self) -> bool {
self.preview_type != PreviewType::None
}
}

View File

@ -12,12 +12,11 @@ pub struct Cli {
#[arg(value_enum, index = 1, verbatim_doc_comment)]
pub channel: Option<String>,
/// A preview command to use with the stdin channel.
/// A preview command to use with the current channel.
///
/// If provided, the preview command will be executed and formatted using
/// the entry.
/// Example: "bat -n --color=always {}" (where {} will be replaced with
/// the entry)
/// Example: "cat {}" (where {} will be replaced with the entry)
///
/// Parts of the entry can be extracted positionally using the `delimiter`
/// option.

View File

@ -5,11 +5,9 @@ use anyhow::{anyhow, Result};
use tracing::debug;
use crate::channels::cable::prototypes::{
CableChannelPrototype, CableChannels,
};
use crate::channels::preview::{
parse_preview_type, PreviewCommand, PreviewType,
CableChannelPrototype, CableChannelPrototypes,
};
use crate::channels::preview::PreviewCommand;
use crate::cli::args::{Cli, Command};
use crate::config::{KeyBindings, DEFAULT_CHANNEL};
use crate::{
@ -23,7 +21,7 @@ pub mod args;
#[derive(Debug, Clone)]
pub struct PostProcessedCli {
pub channel: CableChannelPrototype,
pub preview_kind: PreviewType,
pub preview_command: Option<PreviewCommand>,
pub no_preview: bool,
pub tick_rate: Option<f64>,
pub frame_rate: Option<f64>,
@ -44,7 +42,7 @@ impl Default for PostProcessedCli {
fn default() -> Self {
Self {
channel: CableChannelPrototype::default(),
preview_kind: PreviewType::None,
preview_command: None,
no_preview: false,
tick_rate: None,
frame_rate: None,
@ -75,21 +73,14 @@ impl From<Cli> for PostProcessedCli {
});
// parse the preview command if provided
let preview_kind = cli
.preview
.map(|preview| PreviewCommand {
command: preview,
delimiter: cli.delimiter.clone(),
})
.map_or(PreviewType::None, |preview_command| {
parse_preview_type(&preview_command)
.map_err(|e| {
cli_parsing_error_exit(&e.to_string());
})
.unwrap()
});
let preview_command = cli.preview.map(|preview| PreviewCommand {
command: preview,
delimiter: cli.delimiter.clone(),
// TODO: add the --preview-offset option to the CLI
offset_expr: None,
});
let channel: CableChannelPrototype;
let mut channel: CableChannelPrototype;
let working_directory: Option<String>;
let cable_channels = cable::load_cable_channels().unwrap_or_default();
@ -127,9 +118,15 @@ impl From<Cli> for PostProcessedCli {
}
}
if let Some(preview_cmd) = &preview_command {
channel.preview_command = Some(preview_cmd.command.clone());
channel.preview_delimiter = Some(preview_cmd.delimiter.clone());
channel.preview_offset.clone_from(&preview_cmd.offset_expr);
}
Self {
channel,
preview_kind,
preview_command,
no_preview: cli.no_preview,
tick_rate: cli.tick_rate,
frame_rate: cli.frame_rate,
@ -181,7 +178,7 @@ fn parse_keybindings_literal(
pub fn parse_channel(
channel: &str,
cable_channels: &CableChannels,
cable_channels: &CableChannelPrototypes,
) -> Result<CableChannelPrototype> {
// try to parse the channel as a cable channel
match cable_channels
@ -226,7 +223,7 @@ pub fn guess_channel_from_prompt(
prompt: &str,
command_mapping: &FxHashMap<String, String>,
fallback_channel: &str,
cable_channels: &CableChannels,
cable_channels: &CableChannelPrototypes,
) -> Result<CableChannelPrototype> {
debug!("Guessing channel from prompt: {}", prompt);
// git checkout -qf
@ -303,10 +300,7 @@ Data directory: {data_dir_path}"
#[cfg(test)]
mod tests {
use crate::{
action::Action, channels::preview::PreviewType, config::Binding,
event::Key,
};
use crate::{action::Action, config::Binding, event::Key};
use super::*;
@ -323,15 +317,18 @@ mod tests {
let post_processed_cli: PostProcessedCli = cli.into();
let expected = CableChannelPrototype {
preview_delimiter: Some(":".to_string()),
..Default::default()
};
assert_eq!(post_processed_cli.channel, expected,);
assert_eq!(
post_processed_cli.channel,
CableChannelPrototype::default(),
);
assert_eq!(
post_processed_cli.preview_kind,
PreviewType::Command(PreviewCommand {
post_processed_cli.preview_command,
Some(PreviewCommand {
command: "bat -n --color=always {}".to_string(),
delimiter: ":".to_string()
delimiter: ":".to_string(),
offset_expr: None,
})
);
assert_eq!(post_processed_cli.tick_rate, None);
@ -364,34 +361,6 @@ mod tests {
assert_eq!(post_processed_cli.command, None);
}
#[test]
fn test_builtin_previewer_files() {
let cli = Cli {
channel: Some("files".to_string()),
preview: Some(":files:".to_string()),
delimiter: ":".to_string(),
..Default::default()
};
let post_processed_cli: PostProcessedCli = cli.into();
assert_eq!(post_processed_cli.preview_kind, PreviewType::Files);
}
#[test]
fn test_builtin_previewer_env() {
let cli = Cli {
channel: Some("files".to_string()),
preview: Some(":env_var:".to_string()),
delimiter: ":".to_string(),
..Default::default()
};
let post_processed_cli: PostProcessedCli = cli.into();
assert_eq!(post_processed_cli.preview_kind, PreviewType::EnvVar);
}
#[test]
fn test_custom_keybindings() {
let cli = Cli {
@ -419,7 +388,7 @@ mod tests {
/// Returns a tuple containing a command mapping and a fallback channel.
fn guess_channel_from_prompt_setup<'a>(
) -> (FxHashMap<String, String>, &'a str, CableChannels) {
) -> (FxHashMap<String, String>, &'a str, CableChannelPrototypes) {
let mut command_mapping = FxHashMap::default();
command_mapping.insert("vim".to_string(), "files".to_string());
command_mapping.insert("export".to_string(), "env".to_string());

View File

@ -9,7 +9,6 @@ use anyhow::{Context, Result};
use directories::ProjectDirs;
pub use keybindings::merge_keybindings;
pub use keybindings::{parse_key, Binding, KeyBindings};
use previewers::PreviewersConfig;
use serde::{Deserialize, Serialize};
use shell_integration::ShellIntegrationConfig;
pub use themes::Theme;
@ -17,7 +16,6 @@ use tracing::{debug, warn};
pub use ui::UiConfig;
mod keybindings;
mod previewers;
pub mod shell_integration;
mod themes;
mod ui;
@ -58,7 +56,6 @@ impl Hash for AppConfig {
#[allow(dead_code)]
#[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq, Hash)]
#[serde(deny_unknown_fields)]
pub struct Config {
/// General application configuration
#[allow(clippy::struct_field_names)]
@ -70,9 +67,6 @@ pub struct Config {
/// UI configuration
#[serde(default)]
pub ui: UiConfig,
/// Previewers configuration
#[serde(default)]
pub previewers: PreviewersConfig,
/// Shell integration configuration
#[serde(default)]
pub shell_integration: ShellIntegrationConfig,
@ -201,7 +195,6 @@ impl Config {
application: user.application,
keybindings: user.keybindings,
ui: user.ui,
previewers: user.previewers,
shell_integration: user.shell_integration,
}
}
@ -330,7 +323,6 @@ mod tests {
assert_eq!(config.application, default_config.application);
assert_eq!(config.keybindings, default_config.keybindings);
assert_eq!(config.ui, default_config.ui);
assert_eq!(config.previewers, default_config.previewers);
// backwards compatibility
assert_eq!(
config.shell_integration.commands,
@ -350,7 +342,7 @@ mod tests {
theme = "television"
[previewers.file]
theme = "Visual Studio Dark"
theme = "something"
[keybindings]
toggle_help = ["ctrl-a", "ctrl-b"]
@ -384,8 +376,6 @@ mod tests {
default_config.application.frame_rate = 30.0;
default_config.ui.ui_scale = 40;
default_config.ui.theme = "television".to_string();
default_config.previewers.file.theme =
"Visual Studio Dark".to_string();
default_config.keybindings.extend({
let mut map = FxHashMap::default();
map.insert(
@ -408,7 +398,6 @@ mod tests {
assert_eq!(config.application, default_config.application);
assert_eq!(config.keybindings, default_config.keybindings);
assert_eq!(config.ui, default_config.ui);
assert_eq!(config.previewers, default_config.previewers);
assert_eq!(
config.shell_integration.commands,
[(&String::from("git add"), &String::from("git-diff"))]

View File

@ -1,40 +0,0 @@
use crate::preview::{previewers, PreviewerConfig};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, Serialize, Default, PartialEq, Hash)]
pub struct PreviewersConfig {
#[serde(default)]
pub basic: BasicPreviewerConfig,
pub file: FilePreviewerConfig,
#[serde(default)]
pub env_var: EnvVarPreviewerConfig,
}
impl From<PreviewersConfig> for PreviewerConfig {
fn from(val: PreviewersConfig) -> Self {
PreviewerConfig::default()
.file(previewers::files::FilePreviewerConfig::new(val.file.theme))
}
}
#[derive(Clone, Debug, Deserialize, Serialize, Default, PartialEq, Hash)]
pub struct BasicPreviewerConfig {}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Hash)]
#[serde(default)]
pub struct FilePreviewerConfig {
//pub max_file_size: u64,
pub theme: String,
}
impl Default for FilePreviewerConfig {
fn default() -> Self {
Self {
//max_file_size: 1024 * 1024,
theme: String::from("TwoDark"),
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize, Default, PartialEq, Hash)]
pub struct EnvVarPreviewerConfig {}

View File

@ -115,9 +115,7 @@ impl Display for Key {
#[allow(clippy::module_name_repetitions)]
pub struct EventLoop {
pub rx: mpsc::UnboundedReceiver<Event<Key>>,
//tx: mpsc::UnboundedSender<Event<Key>>,
pub abort_tx: mpsc::UnboundedSender<()>,
//tick_rate: std::time::Duration,
}
struct PollFuture {
@ -162,8 +160,7 @@ fn flush_existing_events() {
}
impl EventLoop {
// FIXME: this init parameter doesn't seem to be used anymore
pub fn new(tick_rate: f64, init: bool) -> Self {
pub fn new(tick_rate: f64) -> Self {
let (tx, rx) = mpsc::unbounded_channel();
let tick_interval = Duration::from_secs_f64(1.0 / tick_rate);
@ -171,55 +168,52 @@ impl EventLoop {
flush_existing_events();
if init {
//let mut reader = crossterm::event::EventStream::new();
tokio::spawn(async move {
loop {
let delay = tokio::time::sleep(tick_interval);
let event_available = poll_event(tick_interval);
tokio::spawn(async move {
loop {
let delay = tokio::time::sleep(tick_interval);
let event_available = poll_event(tick_interval);
tokio::select! {
// if we receive a message on the abort channel, stop the event loop
_ = abort_recv.recv() => {
tx.send(Event::Closed).unwrap_or_else(|_| warn!("Unable to send Closed event"));
tx.send(Event::Tick).unwrap_or_else(|_| warn!("Unable to send Tick event"));
break;
},
_ = signal::ctrl_c() => {
debug!("Received SIGINT");
tx.send(Event::Input(Key::Ctrl('c'))).unwrap_or_else(|_| warn!("Unable to send Ctrl-C event"));
},
// if `delay` completes, pass to the next event "frame"
() = delay => {
tx.send(Event::Tick).unwrap_or_else(|_| warn!("Unable to send Tick event"));
},
// if the receiver dropped the channel, stop the event loop
() = tx.closed() => break,
// if an event was received, process it
_ = event_available => {
let maybe_event = crossterm::event::read();
match maybe_event {
Ok(crossterm::event::Event::Key(key)) => {
let key = convert_raw_event_to_key(key);
tx.send(Event::Input(key)).unwrap_or_else(|_| warn!("Unable to send {:?} event", key));
},
Ok(crossterm::event::Event::FocusLost) => {
tx.send(Event::FocusLost).unwrap_or_else(|_| warn!("Unable to send FocusLost event"));
},
Ok(crossterm::event::Event::FocusGained) => {
tx.send(Event::FocusGained).unwrap_or_else(|_| warn!("Unable to send FocusGained event"));
},
Ok(crossterm::event::Event::Resize(x, y)) => {
let (_, (new_x, new_y)) = flush_resize_events((x, y));
tx.send(Event::Resize(new_x, new_y)).unwrap_or_else(|_| warn!("Unable to send Resize event"));
},
_ => {}
}
tokio::select! {
// if we receive a message on the abort channel, stop the event loop
_ = abort_recv.recv() => {
tx.send(Event::Closed).unwrap_or_else(|_| warn!("Unable to send Closed event"));
tx.send(Event::Tick).unwrap_or_else(|_| warn!("Unable to send Tick event"));
break;
},
_ = signal::ctrl_c() => {
debug!("Received SIGINT");
tx.send(Event::Input(Key::Ctrl('c'))).unwrap_or_else(|_| warn!("Unable to send Ctrl-C event"));
},
// if `delay` completes, pass to the next event "frame"
() = delay => {
tx.send(Event::Tick).unwrap_or_else(|_| warn!("Unable to send Tick event"));
},
// if the receiver dropped the channel, stop the event loop
() = tx.closed() => break,
// if an event was received, process it
_ = event_available => {
let maybe_event = crossterm::event::read();
match maybe_event {
Ok(crossterm::event::Event::Key(key)) => {
let key = convert_raw_event_to_key(key);
tx.send(Event::Input(key)).unwrap_or_else(|_| warn!("Unable to send {:?} event", key));
},
Ok(crossterm::event::Event::FocusLost) => {
tx.send(Event::FocusLost).unwrap_or_else(|_| warn!("Unable to send FocusLost event"));
},
Ok(crossterm::event::Event::FocusGained) => {
tx.send(Event::FocusGained).unwrap_or_else(|_| warn!("Unable to send FocusGained event"));
},
Ok(crossterm::event::Event::Resize(x, y)) => {
let (_, (new_x, new_y)) = flush_resize_events((x, y));
tx.send(Event::Resize(new_x, new_y)).unwrap_or_else(|_| warn!("Unable to send Resize event"));
},
_ => {}
}
}
}
});
}
}
});
Self {
//tx,

View File

@ -5,16 +5,14 @@ use std::process::exit;
use anyhow::Result;
use clap::Parser;
use crossterm::terminal::enable_raw_mode;
use television::cable;
use television::channels::cable::prototypes::CableChannels;
use television::channels::cable::prototypes::{
CableChannelPrototype, CableChannelPrototypes,
};
use television::utils::clipboard::CLIPBOARD;
use tracing::{debug, error, info};
use television::app::{App, AppOptions};
use television::channels::{
stdin::Channel as StdinChannel, TelevisionChannel,
};
use television::cli::{
args::{Cli, Command},
guess_channel_from_prompt, list_channels, PostProcessedCli,
@ -53,8 +51,6 @@ async fn main() -> Result<()> {
.as_ref()
.map(|x| handle_subcommands(x, &config));
enable_raw_mode()?;
// optionally change the working directory
args.working_directory.as_ref().map(set_current_dir);
@ -81,7 +77,8 @@ async fn main() -> Result<()> {
args.no_help,
config.application.tick_rate,
);
let mut app = App::new(channel, config, args.input, options);
let mut app =
App::new(channel, config, args.input, options, &cable_channels);
stdout().flush()?;
debug!("Running application...");
let output = app.run(stdout().is_terminal(), false).await?;
@ -157,26 +154,24 @@ pub fn determine_channel(
args: PostProcessedCli,
config: &Config,
readable_stdin: bool,
cable_channels: &CableChannels,
) -> Result<TelevisionChannel> {
cable_channels: &CableChannelPrototypes,
) -> Result<CableChannelPrototype> {
if readable_stdin {
debug!("Using stdin channel");
Ok(TelevisionChannel::Stdin(StdinChannel::new(
args.preview_kind,
)))
Ok(CableChannelPrototype::stdin(args.preview_command))
} else if let Some(prompt) = args.autocomplete_prompt {
debug!("Using autocomplete prompt: {:?}", prompt);
let channel = guess_channel_from_prompt(
let channel_prototype = guess_channel_from_prompt(
&prompt,
&config.shell_integration.commands,
&config.shell_integration.fallback_channel,
cable_channels,
)?;
debug!("Using guessed channel: {:?}", channel);
Ok(TelevisionChannel::Cable(channel.into()))
debug!("Using guessed channel: {:?}", channel_prototype);
Ok(channel_prototype)
} else {
debug!("Using {:?} channel", args.channel);
Ok(TelevisionChannel::Cable(args.channel.into()))
Ok(args.channel)
}
}
@ -185,9 +180,7 @@ mod tests {
use rustc_hash::FxHashMap;
use television::{
cable::load_cable_channels,
channels::{
cable::prototypes::CableChannelPrototype, preview::PreviewType,
},
channels::cable::prototypes::CableChannelPrototype,
};
use super::*;
@ -196,21 +189,19 @@ mod tests {
args: &PostProcessedCli,
config: &Config,
readable_stdin: bool,
expected_channel: &TelevisionChannel,
cable_channels: Option<CableChannels>,
expected_channel: &CableChannelPrototype,
cable_channels: Option<CableChannelPrototypes>,
) {
let channels: CableChannels = cable_channels
let channels: CableChannelPrototypes = cable_channels
.unwrap_or_else(|| load_cable_channels().unwrap_or_default());
let channel =
determine_channel(args.clone(), config, readable_stdin, &channels)
.unwrap();
assert_eq!(
channel.name(),
expected_channel.name(),
channel.name, expected_channel.name,
"Expected {:?} but got {:?}",
expected_channel.name(),
channel.name()
expected_channel.name, channel.name
);
}
@ -227,7 +218,9 @@ mod tests {
&args,
&config,
true,
&TelevisionChannel::Stdin(StdinChannel::new(PreviewType::None)),
&CableChannelPrototype::new(
"stdin", "cat", false, None, None, None,
),
None,
);
}
@ -235,11 +228,8 @@ mod tests {
#[tokio::test]
async fn test_determine_channel_autocomplete_prompt() {
let autocomplete_prompt = Some("cd".to_string());
let expected_channel = TelevisionChannel::Cable(
CableChannelPrototype::new(
"dirs", "ls {}", false, None, None, None,
)
.into(),
let expected_channel = CableChannelPrototype::new(
"dirs", "ls {}", false, None, None, None,
);
let args = PostProcessedCli {
autocomplete_prompt,
@ -283,12 +273,7 @@ mod tests {
&args,
&config,
false,
&TelevisionChannel::Cable(
CableChannelPrototype::new(
"dirs", "", false, None, None, None,
)
.into(),
),
&CableChannelPrototype::new("dirs", "", false, None, None, None),
None,
);
}

View File

@ -1,34 +0,0 @@
#![allow(unused_imports)]
//! This module provides a way to parse ansi escape codes and convert them to ratatui objects.
//!
//! This code is a modified version of [ansi_to_tui](https://github.com/ratatui/ansi-to-tui).
// mod ansi;
pub mod code;
pub mod error;
pub mod parser;
pub use error::Error;
use ratatui::text::Text;
/// `IntoText` will convert any type that has a `AsRef<[u8]>` to a Text.
pub trait IntoText {
/// Convert the type to a Text.
#[allow(clippy::wrong_self_convention)]
fn into_text(&self) -> Result<Text<'static>, Error>;
/// Convert the type to a Text while trying to copy as less as possible
#[cfg(feature = "zero-copy")]
fn to_text(&self) -> Result<Text<'_>, Error>;
}
impl<T> IntoText for T
where
T: AsRef<[u8]>,
{
fn into_text(&self) -> Result<Text<'static>, Error> {
Ok(parser::text(self.as_ref())?.1)
}
#[cfg(feature = "zero-copy")]
fn to_text(&self) -> Result<Text<'_>, Error> {
Ok(parser::text_fast(self.as_ref())?.1)
}
}

View File

@ -1,7 +0,0 @@
Copyright 2021 Uttarayan Mondal
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@ -1,140 +0,0 @@
use ratatui::style::Color;
/// This enum stores most types of ansi escape sequences
///
/// You can turn an escape sequence to this enum variant using
/// `AnsiCode::from(code: u8)`
/// This doesn't support all of them but does support most of them.
#[derive(Debug, PartialEq, Clone)]
#[non_exhaustive]
pub enum AnsiCode {
/// Reset the terminal
Reset,
/// Set font to bold
Bold,
/// Set font to faint
Faint,
/// Set font to italic
Italic,
/// Set font to underline
Underline,
/// Set cursor to slowblink
SlowBlink,
/// Set cursor to rapidblink
RapidBlink,
/// Invert the colors
Reverse,
/// Conceal text
Conceal,
/// Display crossed out text
CrossedOut,
/// Choose primary font
PrimaryFont,
/// Choose alternate font
AlternateFont,
/// Choose alternate fonts 1-9
#[allow(dead_code)]
AlternateFonts(u8), // = 11..19, // from 11 to 19
/// Fraktur ? No clue
Fraktur,
/// Turn off bold
BoldOff,
/// Set text to normal
Normal,
/// Turn off Italic
NotItalic,
/// Turn off underline
UnderlineOff,
/// Turn off blinking
BlinkOff,
// 26 ?
/// Don't invert colors
InvertOff,
/// Reveal text
Reveal,
/// Turn off Crossedout text
CrossedOutOff,
/// Set foreground color (4-bit)
ForegroundColor(Color), //, 31..37//Issue 60553 https://github.com/rust-lang/rust/issues/60553
/// Set foreground color (8-bit and 24-bit)
SetForegroundColor,
/// Default foreground color
DefaultForegroundColor,
/// Set background color (4-bit)
BackgroundColor(Color), // 41..47
/// Set background color (8-bit and 24-bit)
SetBackgroundColor,
/// Default background color
DefaultBackgroundColor, // 49
/// Other / non supported escape codes
Code(Vec<u8>),
}
impl From<u8> for AnsiCode {
fn from(code: u8) -> Self {
match code {
0 => AnsiCode::Reset,
1 => AnsiCode::Bold,
2 => AnsiCode::Faint,
3 => AnsiCode::Italic,
4 => AnsiCode::Underline,
5 => AnsiCode::SlowBlink,
6 => AnsiCode::RapidBlink,
7 => AnsiCode::Reverse,
8 => AnsiCode::Conceal,
9 => AnsiCode::CrossedOut,
10 => AnsiCode::PrimaryFont,
11 => AnsiCode::AlternateFont,
// AnsiCode::// AlternateFont = 11..19, // from 11 to 19
20 => AnsiCode::Fraktur,
21 => AnsiCode::BoldOff,
22 => AnsiCode::Normal,
23 => AnsiCode::NotItalic,
24 => AnsiCode::UnderlineOff,
25 => AnsiCode::BlinkOff,
// 26 ?
27 => AnsiCode::InvertOff,
28 => AnsiCode::Reveal,
29 => AnsiCode::CrossedOutOff,
30 => AnsiCode::ForegroundColor(Color::Black),
31 => AnsiCode::ForegroundColor(Color::Red),
32 => AnsiCode::ForegroundColor(Color::Green),
33 => AnsiCode::ForegroundColor(Color::Yellow),
34 => AnsiCode::ForegroundColor(Color::Blue),
35 => AnsiCode::ForegroundColor(Color::Magenta),
36 => AnsiCode::ForegroundColor(Color::Cyan),
37 => AnsiCode::ForegroundColor(Color::Gray),
38 => AnsiCode::SetForegroundColor,
39 => AnsiCode::DefaultForegroundColor,
40 => AnsiCode::BackgroundColor(Color::Black),
41 => AnsiCode::BackgroundColor(Color::Red),
42 => AnsiCode::BackgroundColor(Color::Green),
43 => AnsiCode::BackgroundColor(Color::Yellow),
44 => AnsiCode::BackgroundColor(Color::Blue),
45 => AnsiCode::BackgroundColor(Color::Magenta),
46 => AnsiCode::BackgroundColor(Color::Cyan),
47 => AnsiCode::BackgroundColor(Color::Gray),
48 => AnsiCode::SetBackgroundColor,
49 => AnsiCode::DefaultBackgroundColor,
90 => AnsiCode::ForegroundColor(Color::DarkGray),
91 => AnsiCode::ForegroundColor(Color::LightRed),
92 => AnsiCode::ForegroundColor(Color::LightGreen),
93 => AnsiCode::ForegroundColor(Color::LightYellow),
94 => AnsiCode::ForegroundColor(Color::LightBlue),
95 => AnsiCode::ForegroundColor(Color::LightMagenta),
96 => AnsiCode::ForegroundColor(Color::LightCyan),
#[allow(clippy::match_same_arms)]
97 => AnsiCode::ForegroundColor(Color::White),
100 => AnsiCode::BackgroundColor(Color::DarkGray),
101 => AnsiCode::BackgroundColor(Color::LightRed),
102 => AnsiCode::BackgroundColor(Color::LightGreen),
103 => AnsiCode::BackgroundColor(Color::LightYellow),
104 => AnsiCode::BackgroundColor(Color::LightBlue),
105 => AnsiCode::BackgroundColor(Color::LightMagenta),
106 => AnsiCode::BackgroundColor(Color::LightCyan),
107 => AnsiCode::ForegroundColor(Color::White),
code => AnsiCode::Code(vec![code]),
}
}
}

View File

@ -1,24 +0,0 @@
/// This enum stores the error types
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum Error {
/// Stack is empty (should never happen)
#[error("Internal error: stack is empty")]
NomError(String),
/// Error parsing the input as utf-8
#[cfg(feature = "simd")]
/// Cannot determine the foreground or background
#[error("{0:?}")]
Utf8Error(#[from] simdutf8::basic::Utf8Error),
#[cfg(not(feature = "simd"))]
/// Cannot determine the foreground or background
#[error("{0:?}")]
Utf8Error(#[from] std::string::FromUtf8Error),
}
impl From<nom::Err<nom::error::Error<&[u8]>>> for Error {
fn from(e: nom::Err<nom::error::Error<&[u8]>>) -> Self {
Self::NomError(format!("{:?}", e))
}
}

View File

@ -1,461 +0,0 @@
use crate::preview::ansi::code::AnsiCode;
use nom::{
branch::alt,
bytes::complete::{tag, take, take_till, take_while},
character::{
complete::{char, i64, not_line_ending, u8},
is_alphabetic,
},
combinator::{map_res, opt},
multi::fold_many0,
sequence::{delimited, preceded, tuple},
IResult, Parser,
};
use ratatui::{
style::{Color, Modifier, Style, Stylize},
text::{Line, Span, Text},
};
use smallvec::SmallVec;
use std::str::FromStr;
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
enum ColorType {
/// Eight Bit color
EightBit,
/// 24-bit color or true color
TrueColor,
}
#[derive(Debug, Clone, PartialEq)]
struct AnsiItem {
code: AnsiCode,
color: Option<Color>,
}
#[derive(Debug, Clone, PartialEq)]
struct AnsiStates {
pub items: SmallVec<[AnsiItem; 2]>,
pub style: Style,
}
impl From<AnsiStates> for Style {
fn from(states: AnsiStates) -> Self {
let mut style = states.style;
for item in states.items {
match item.code {
AnsiCode::Bold => style = style.add_modifier(Modifier::BOLD),
AnsiCode::Faint => style = style.add_modifier(Modifier::DIM),
AnsiCode::Normal => {
style = style
.remove_modifier(Modifier::BOLD)
.remove_modifier(Modifier::DIM);
}
AnsiCode::Italic => {
style = style.add_modifier(Modifier::ITALIC);
}
AnsiCode::Underline => {
style = style.add_modifier(Modifier::UNDERLINED);
}
AnsiCode::SlowBlink => {
style = style.add_modifier(Modifier::SLOW_BLINK);
}
AnsiCode::RapidBlink => {
style = style.add_modifier(Modifier::RAPID_BLINK);
}
AnsiCode::Reverse => {
style = style.add_modifier(Modifier::REVERSED);
}
AnsiCode::Conceal => {
style = style.add_modifier(Modifier::HIDDEN);
}
AnsiCode::CrossedOut => {
style = style.add_modifier(Modifier::CROSSED_OUT);
}
AnsiCode::DefaultForegroundColor => {
style = style.fg(Color::Reset);
}
AnsiCode::SetForegroundColor => {
if let Some(color) = item.color {
style = style.fg(color);
}
}
AnsiCode::ForegroundColor(color) => style = style.fg(color),
AnsiCode::Reset => style = style.fg(Color::Reset),
_ => (),
}
}
style
}
}
#[allow(clippy::unnecessary_wraps)]
pub(crate) fn text(mut s: &[u8]) -> IResult<&[u8], Text<'static>> {
let mut lines = Vec::new();
let mut last_style = Style::new();
while let Ok((remaining, (line, style))) = line(last_style)(s) {
lines.push(line);
last_style = style;
s = remaining;
if s.is_empty() {
break;
}
}
Ok((s, Text::from(lines)))
}
#[cfg(feature = "zero-copy")]
#[allow(clippy::unnecessary_wraps)]
pub(crate) fn text_fast(mut s: &[u8]) -> IResult<&[u8], Text<'_>> {
let mut lines = Vec::new();
let mut last = Style::new();
while let Ok((c, (line, style))) = line_fast(last)(s) {
lines.push(line);
last = style;
s = c;
if s.is_empty() {
break;
}
}
Ok((s, Text::from(lines)))
}
fn line(
style: Style,
) -> impl Fn(&[u8]) -> IResult<&[u8], (Line<'static>, Style)> {
// let style_: Style = Default::default();
move |s: &[u8]| -> IResult<&[u8], (Line<'static>, Style)> {
// consume s until a line ending is found
let (s, mut text) = not_line_ending(s)?;
// discard the line ending
let (s, _) = opt(alt((tag("\r\n"), tag("\n"))))(s)?;
let mut spans = Vec::new();
// carry over the style from the previous line (passed in as an argument)
let mut last_style = style;
// parse spans from the given text
while let Ok((remaining, span)) = span(last_style)(text) {
// Since reset now tracks separately we can skip the reset check
last_style = last_style.patch(span.style);
if !span.content.is_empty() {
spans.push(span);
}
text = remaining;
if text.is_empty() {
break;
}
}
// NOTE: what is last_style here
Ok((s, (Line::from(spans), last_style)))
}
}
#[cfg(feature = "zero-copy")]
fn line_fast(
style: Style,
) -> impl Fn(&[u8]) -> IResult<&[u8], (Line<'_>, Style)> {
// let style_: Style = Default::default();
move |s: &[u8]| -> IResult<&[u8], (Line<'_>, Style)> {
let (s, mut text) = not_line_ending(s)?;
let (s, _) = opt(alt((tag("\r\n"), tag("\n"))))(s)?;
let mut spans = Vec::new();
let mut last = style;
while let Ok((s, span)) = span_fast(last)(text) {
last = last.patch(span.style);
// If the spans is empty then it might be possible that the style changes
// but there is no text change
if !span.content.is_empty() {
spans.push(span);
}
text = s;
if text.is_empty() {
break;
}
}
Ok((s, (Line::from(spans), last)))
}
}
// fn span(s: &[u8]) -> IResult<&[u8], tui::text::Span> {
fn span(
last: Style,
) -> impl Fn(&[u8]) -> IResult<&[u8], Span<'static>, nom::error::Error<&[u8]>>
{
move |s: &[u8]| -> IResult<&[u8], Span<'static>> {
let mut last_style = last;
// optionally consume a style
let (s, maybe_style) = opt(style(last_style))(s)?;
// consume until an escape sequence is found
#[cfg(feature = "simd")]
let (s, text) = map_res(take_while(|c| c != b'\x1b'), |t| {
simdutf8::basic::from_utf8(t)
})(s)?;
#[cfg(not(feature = "simd"))]
let (s, text) =
map_res(take_while(|c| c != b'\x1b'), |t| std::str::from_utf8(t))(
s,
)?;
// if a style was found, patch the last style with it
if let Some(st) = maybe_style.flatten() {
last_style = last_style.patch(st);
}
Ok((s, Span::styled(text.to_owned(), last_style)))
}
}
#[cfg(feature = "zero-copy")]
fn span_fast(
last: Style,
) -> impl Fn(&[u8]) -> IResult<&[u8], Span<'_>, nom::error::Error<&[u8]>> {
move |s: &[u8]| -> IResult<&[u8], Span<'_>> {
let mut last = last;
let (s, style) = opt(style(last))(s)?;
#[cfg(feature = "simd")]
let (s, text) = map_res(take_while(|c| c != b'\x1b'), |t| {
simdutf8::basic::from_utf8(t)
})(s)?;
#[cfg(not(feature = "simd"))]
let (s, text) =
map_res(take_while(|c| c != b'\x1b'), |t| std::str::from_utf8(t))(
s,
)?;
if let Some(style) = style.flatten() {
last = last.patch(style);
}
Ok((s, Span::styled(text, last)))
}
}
#[allow(clippy::type_complexity)]
fn style(
style: Style,
) -> impl Fn(&[u8]) -> IResult<&[u8], Option<Style>, nom::error::Error<&[u8]>>
{
move |s: &[u8]| -> IResult<&[u8], Option<Style>> {
let (s, r) = match opt(ansi_sgr_code)(s)? {
(s, Some(r)) => {
// This would correspond to an implicit reset code (\x1b[m)
if r.is_empty() {
let mut sv = SmallVec::<[AnsiItem; 2]>::new();
sv.push(AnsiItem {
code: AnsiCode::Reset,
color: None,
});
(s, Some(sv))
} else {
(s, Some(r))
}
}
(s, None) => {
let (s, _) = any_escape_sequence(s)?;
(s, None)
}
};
Ok((s, r.map(|r| Style::from(AnsiStates { style, items: r }))))
}
}
/// A complete ANSI SGR code
fn ansi_sgr_code(
s: &[u8],
) -> IResult<&[u8], smallvec::SmallVec<[AnsiItem; 2]>, nom::error::Error<&[u8]>>
{
delimited(
tag("\x1b["),
fold_many0(
ansi_sgr_item,
smallvec::SmallVec::new,
|mut items, item| {
items.push(item);
items
},
),
char('m'),
)(s)
}
fn any_escape_sequence(s: &[u8]) -> IResult<&[u8], Option<&[u8]>> {
// Attempt to consume most escape codes, including a single escape char.
//
// Most escape codes begin with ESC[ and are terminated by an alphabetic character,
// but OSC codes begin with ESC] and are terminated by an ascii bell (\x07)
// and a truncated/invalid code may just be a standalone ESC or not be terminated.
//
// We should try to consume as much of it as possible to match behavior of most terminals;
// where we fail at that we should at least consume the escape char to avoid infinitely looping
let (input, garbage) = preceded(
char('\x1b'),
opt(alt((
delimited(char('['), take_till(is_alphabetic), opt(take(1u8))),
delimited(char(']'), take_till(|c| c == b'\x07'), opt(take(1u8))),
))),
)(s)?;
Ok((input, garbage))
}
/// An ANSI SGR attribute
fn ansi_sgr_item(s: &[u8]) -> IResult<&[u8], AnsiItem> {
let (s, c) = u8(s)?;
let code = AnsiCode::from(c);
let (s, color) = match code {
AnsiCode::SetForegroundColor | AnsiCode::SetBackgroundColor => {
let (s, _) = opt(tag(";"))(s)?;
let (s, color) = color(s)?;
(s, Some(color))
}
_ => (s, None),
};
let (s, _) = opt(tag(";"))(s)?;
Ok((s, AnsiItem { code, color }))
}
fn color(s: &[u8]) -> IResult<&[u8], Color> {
let (s, c_type) = color_type(s)?;
let (s, _) = opt(tag(";"))(s)?;
match c_type {
ColorType::TrueColor => {
let (s, (r, _, g, _, b)) =
tuple((u8, tag(";"), u8, tag(";"), u8))(s)?;
Ok((s, Color::Rgb(r, g, b)))
}
ColorType::EightBit => {
let (s, index) = u8(s)?;
Ok((s, Color::Indexed(index)))
}
}
}
fn color_type(s: &[u8]) -> IResult<&[u8], ColorType> {
let (s, t) = i64(s)?;
// NOTE: This isn't opt because a color type must always be followed by a color
// let (s, _) = opt(tag(";"))(s)?;
let (s, _) = tag(";")(s)?;
match t {
2 => Ok((s, ColorType::TrueColor)),
5 => Ok((s, ColorType::EightBit)),
_ => Err(nom::Err::Error(nom::error::Error::new(
s,
nom::error::ErrorKind::Alt,
))),
}
}
#[test]
fn color_test() {
let c = color(b"2;255;255;255").unwrap();
assert_eq!(c.1, Color::Rgb(255, 255, 255));
let c = color(b"5;255").unwrap();
assert_eq!(c.1, Color::Indexed(255));
let err = color(b"10;255");
assert_ne!(err, Ok(c));
}
#[test]
fn test_color_reset() {
let t = text(b"\x1b[33msome arbitrary text\x1b[0m\nmore text")
.unwrap()
.1;
assert_eq!(
t,
Text::from(vec![
Line::from(vec![Span::styled(
"some arbitrary text",
Style::default().fg(Color::Yellow)
),]),
Line::from(Span::from("more text").fg(Color::Reset)),
])
);
}
#[test]
fn test_color_reset_implicit_escape() {
let t = text(b"\x1b[33msome arbitrary text\x1b[m\nmore text")
.unwrap()
.1;
assert_eq!(
t,
Text::from(vec![
Line::from(vec![Span::styled(
"some arbitrary text",
Style::default().fg(Color::Yellow)
),]),
Line::from(Span::from("more text").fg(Color::Reset)),
])
);
}
#[test]
fn ansi_items_test() {
let sc = Style::default();
let t = style(sc)(b"\x1b[38;2;3;3;3m").unwrap().1.unwrap();
assert_eq!(
t,
Style::from(AnsiStates {
style: sc,
items: vec![AnsiItem {
code: AnsiCode::SetForegroundColor,
color: Some(Color::Rgb(3, 3, 3))
}]
.into()
})
);
assert_eq!(
style(sc)(b"\x1b[38;5;3m").unwrap().1.unwrap(),
Style::from(AnsiStates {
style: sc,
items: vec![AnsiItem {
code: AnsiCode::SetForegroundColor,
color: Some(Color::Indexed(3))
}]
.into()
})
);
assert_eq!(
style(sc)(b"\x1b[38;5;3;48;5;3m").unwrap().1.unwrap(),
Style::from(AnsiStates {
style: sc,
items: vec![
AnsiItem {
code: AnsiCode::SetForegroundColor,
color: Some(Color::Indexed(3))
},
AnsiItem {
code: AnsiCode::SetBackgroundColor,
color: Some(Color::Indexed(3))
}
]
.into()
})
);
assert_eq!(
style(sc)(b"\x1b[38;5;3;48;5;3;1m").unwrap().1.unwrap(),
Style::from(AnsiStates {
style: sc,
items: vec![
AnsiItem {
code: AnsiCode::SetForegroundColor,
color: Some(Color::Indexed(3))
},
AnsiItem {
code: AnsiCode::SetBackgroundColor,
color: Some(Color::Indexed(3))
},
AnsiItem {
code: AnsiCode::Bold,
color: None
}
]
.into()
})
);
}

View File

@ -1,33 +1,12 @@
use crate::preview::{Preview, PreviewContent};
use std::sync::Arc;
pub fn not_supported(title: &str) -> Arc<Preview> {
Arc::new(Preview::new(
title.to_string(),
PreviewContent::NotSupported,
None,
None,
1,
))
}
pub fn file_too_large(title: &str) -> Arc<Preview> {
Arc::new(Preview::new(
title.to_string(),
PreviewContent::FileTooLarge,
None,
None,
1,
))
}
#[allow(dead_code)]
pub fn loading(title: &str) -> Arc<Preview> {
Arc::new(Preview::new(
title.to_string(),
PreviewContent::Loading,
None,
None,
1,
))
}
@ -37,7 +16,6 @@ pub fn timeout(title: &str) -> Arc<Preview> {
title.to_string(),
PreviewContent::Timeout,
None,
None,
1,
))
}

View File

@ -1,55 +1,25 @@
use std::sync::Arc;
use crate::channels::{entry::Entry, preview::PreviewType};
use devicons::FileIcon;
use ratatui::layout::Rect;
pub mod ansi;
pub mod cache;
pub mod previewers;
// previewer types
use crate::utils::cache::RingSet;
use crate::utils::image::ImagePreviewWidget;
use crate::utils::syntax::HighlightedLines;
pub use previewers::basic::BasicPreviewer;
pub use previewers::basic::BasicPreviewerConfig;
pub use previewers::command::CommandPreviewer;
pub use previewers::command::CommandPreviewerConfig;
pub use previewers::env::EnvVarPreviewer;
pub use previewers::env::EnvVarPreviewerConfig;
pub use previewers::files::FilePreviewer;
pub use previewers::files::FilePreviewerConfig;
pub mod meta;
pub mod previewer;
#[derive(Clone, Debug, PartialEq, Hash)]
pub enum PreviewContent {
Empty,
FileTooLarge,
SyntectHighlightedText(HighlightedLines),
Loading,
Timeout,
NotSupported,
PlainText(Vec<String>),
PlainTextWrapped(String),
AnsiText(String),
Image(ImagePreviewWidget),
}
impl PreviewContent {
pub fn total_lines(&self) -> u16 {
match self {
PreviewContent::SyntectHighlightedText(hl_lines) => {
hl_lines.lines.len().try_into().unwrap_or(u16::MAX)
}
PreviewContent::PlainText(lines) => {
lines.len().try_into().unwrap_or(u16::MAX)
}
PreviewContent::AnsiText(text) => {
text.lines().count().try_into().unwrap_or(u16::MAX)
}
PreviewContent::Image(image) => {
image.height().try_into().unwrap_or(u16::MAX)
}
_ => 0,
}
}
@ -71,9 +41,6 @@ pub struct Preview {
pub title: String,
pub content: PreviewContent,
pub icon: Option<FileIcon>,
/// If the preview is partial, this field contains the byte offset
/// up to which the preview holds.
pub partial_offset: Option<usize>,
pub total_lines: u16,
}
@ -83,7 +50,6 @@ impl Default for Preview {
title: String::new(),
content: PreviewContent::Empty,
icon: None,
partial_offset: None,
total_lines: 0,
}
}
@ -94,14 +60,12 @@ impl Preview {
title: String,
content: PreviewContent,
icon: Option<FileIcon>,
partial_offset: Option<usize>,
total_lines: u16,
) -> Self {
Preview {
title,
content,
icon,
partial_offset,
total_lines,
}
}
@ -174,110 +138,3 @@ impl PreviewState {
}
}
}
#[derive(Debug, Default)]
pub struct Previewer {
basic: BasicPreviewer,
file: FilePreviewer,
env_var: EnvVarPreviewer,
command: CommandPreviewer,
requests: RingSet<Entry>,
}
#[derive(Debug, Default)]
pub struct PreviewerConfig {
basic: BasicPreviewerConfig,
file: FilePreviewerConfig,
env_var: EnvVarPreviewerConfig,
command: CommandPreviewerConfig,
}
impl PreviewerConfig {
pub fn basic(mut self, config: BasicPreviewerConfig) -> Self {
self.basic = config;
self
}
pub fn file(mut self, config: FilePreviewerConfig) -> Self {
self.file = config;
self
}
pub fn env_var(mut self, config: EnvVarPreviewerConfig) -> Self {
self.env_var = config;
self
}
}
const REQUEST_STACK_SIZE: usize = 10;
impl Previewer {
pub fn new(config: Option<PreviewerConfig>) -> Self {
let config = config.unwrap_or_default();
Previewer {
basic: BasicPreviewer::new(Some(config.basic)),
file: FilePreviewer::new(Some(config.file)),
env_var: EnvVarPreviewer::new(Some(config.env_var)),
command: CommandPreviewer::new(Some(config.command)),
requests: RingSet::with_capacity(REQUEST_STACK_SIZE),
}
}
fn dispatch_request(
&mut self,
entry: &Entry,
preview_window: Option<Rect>,
) -> Option<Arc<Preview>> {
match &entry.preview_type {
PreviewType::Basic => Some(self.basic.preview(entry)),
PreviewType::EnvVar => Some(self.env_var.preview(entry)),
PreviewType::Files => self.file.preview(entry, preview_window),
PreviewType::Command(cmd) => self.command.preview(entry, cmd),
PreviewType::None => Some(Arc::new(Preview::default())),
}
}
fn cached(&self, entry: &Entry) -> Option<Arc<Preview>> {
match &entry.preview_type {
PreviewType::Basic => Some(self.basic.preview(entry)),
PreviewType::EnvVar => Some(self.env_var.preview(entry)),
PreviewType::Files => self.file.cached(entry),
PreviewType::Command(_) => self.command.cached(entry),
PreviewType::None => None,
}
}
// we could use a target scroll here to make the previewer
// faster, but since it's already running in the background and quite
// fast for most standard file sizes, plus we're caching the previews,
// I'm not sure the extra complexity is worth it.
pub fn preview(
&mut self,
entry: &Entry,
preview_window: Option<Rect>,
) -> Option<Arc<Preview>> {
// check if we have a preview for the current request
if let Some(preview) = self.cached(entry) {
return Some(preview);
}
// otherwise, if we haven't acknowledged the request yet, acknowledge it
self.requests.push(entry.clone());
// lookup request stack and return the most recent preview available
for request in self.requests.back_to_front() {
if let Some(preview) =
self.dispatch_request(&request, preview_window)
{
return Some(preview);
}
}
None
}
pub fn set_config(&mut self, config: PreviewerConfig) {
self.basic = BasicPreviewer::new(Some(config.basic));
self.file = FilePreviewer::new(Some(config.file));
self.env_var = EnvVarPreviewer::new(Some(config.env_var));
}
}

View File

@ -0,0 +1,137 @@
use crate::channels::cable::prototypes::CableChannelPrototype;
use crate::preview::{Preview, PreviewContent};
use crate::utils::command::shell_command;
use crate::{
channels::{entry::Entry, preview::PreviewCommand},
preview::cache::PreviewCache,
};
use parking_lot::Mutex;
use rustc_hash::FxHashSet;
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::Arc;
use tracing::debug;
#[allow(dead_code)]
#[derive(Debug)]
pub struct Previewer {
cache: Arc<Mutex<PreviewCache>>,
concurrent_preview_tasks: Arc<AtomicU8>,
in_flight_previews: Arc<Mutex<FxHashSet<String>>>,
command: PreviewCommand,
}
impl Previewer {
// we could use a target scroll here to make the previewer
// faster, but since it's already running in the background and quite
// fast for most standard file sizes, plus we're caching the previews,
// I'm not sure the extra complexity is worth it.
pub fn request(&mut self, entry: &Entry) -> Option<Arc<Preview>> {
// check if we have a preview in cache for the current request
if let Some(preview) = self.cached(entry) {
return Some(preview);
}
// start a background task to compute the preview
self.preview(entry);
None
}
}
const MAX_CONCURRENT_PREVIEW_TASKS: u8 = 3;
impl Previewer {
pub fn new(command: PreviewCommand) -> Self {
Previewer {
cache: Arc::new(Mutex::new(PreviewCache::default())),
concurrent_preview_tasks: Arc::new(AtomicU8::new(0)),
in_flight_previews: Arc::new(Mutex::new(FxHashSet::default())),
command,
}
}
pub fn cached(&self, entry: &Entry) -> Option<Arc<Preview>> {
self.cache.lock().get(&entry.name)
}
pub fn preview(&mut self, entry: &Entry) {
if self.in_flight_previews.lock().contains(&entry.name) {
debug!("Preview already in flight for {:?}", entry.name);
return;
}
if self.concurrent_preview_tasks.load(Ordering::Relaxed)
< MAX_CONCURRENT_PREVIEW_TASKS
{
self.in_flight_previews.lock().insert(entry.name.clone());
self.concurrent_preview_tasks
.fetch_add(1, Ordering::Relaxed);
let cache = self.cache.clone();
let entry_c = entry.clone();
let concurrent_tasks = self.concurrent_preview_tasks.clone();
let command = self.command.clone();
let in_flight_previews = self.in_flight_previews.clone();
tokio::spawn(async move {
try_preview(
&command,
&entry_c,
&cache,
&concurrent_tasks,
&in_flight_previews,
);
});
} else {
debug!(
"Too many concurrent preview tasks, skipping {:?}",
entry.name
);
}
}
}
pub fn try_preview(
command: &PreviewCommand,
entry: &Entry,
cache: &Arc<Mutex<PreviewCache>>,
concurrent_tasks: &Arc<AtomicU8>,
in_flight_previews: &Arc<Mutex<FxHashSet<String>>>,
) {
debug!("Computing preview for {:?}", entry.name);
let command = command.format_with(entry);
debug!("Formatted preview command: {:?}", command);
let child = shell_command(false)
.arg(&command)
.output()
.expect("failed to execute process");
if child.status.success() {
let content = String::from_utf8_lossy(&child.stdout);
let preview = Arc::new(Preview::new(
entry.name.clone(),
PreviewContent::AnsiText(content.to_string()),
None,
u16::try_from(content.lines().count()).unwrap_or(u16::MAX),
));
cache.lock().insert(entry.name.clone(), &preview);
} else {
let content = String::from_utf8_lossy(&child.stderr);
let preview = Arc::new(Preview::new(
entry.name.clone(),
PreviewContent::AnsiText(content.to_string()),
None,
u16::try_from(content.lines().count()).unwrap_or(u16::MAX),
));
cache.lock().insert(entry.name.clone(), &preview);
}
concurrent_tasks.fetch_sub(1, Ordering::Relaxed);
in_flight_previews.lock().remove(&entry.name);
}
impl From<&CableChannelPrototype> for Option<Previewer> {
fn from(value: &CableChannelPrototype) -> Self {
Option::<PreviewCommand>::from(value).map(Previewer::new)
}
}

View File

@ -1,30 +0,0 @@
use std::sync::Arc;
use crate::channels::entry::Entry;
use crate::preview::{Preview, PreviewContent};
#[derive(Debug, Default)]
pub struct BasicPreviewer {
_config: BasicPreviewerConfig,
}
#[derive(Debug, Default)]
pub struct BasicPreviewerConfig {}
impl BasicPreviewer {
pub fn new(config: Option<BasicPreviewerConfig>) -> Self {
BasicPreviewer {
_config: config.unwrap_or_default(),
}
}
pub fn preview(&self, entry: &Entry) -> Arc<Preview> {
Arc::new(Preview {
title: entry.name.clone(),
content: PreviewContent::PlainTextWrapped(entry.name.clone()),
icon: entry.icon,
partial_offset: None,
total_lines: 1,
})
}
}

View File

@ -1,294 +0,0 @@
use crate::preview::{Preview, PreviewContent};
use crate::utils::command::shell_command;
use crate::{
channels::{entry::Entry, preview::PreviewCommand},
preview::cache::PreviewCache,
};
use parking_lot::Mutex;
use regex::Regex;
use rustc_hash::FxHashSet;
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::Arc;
use tracing::debug;
#[allow(dead_code)]
#[derive(Debug)]
pub struct CommandPreviewer {
cache: Arc<Mutex<PreviewCache>>,
config: CommandPreviewerConfig,
concurrent_preview_tasks: Arc<AtomicU8>,
in_flight_previews: Arc<Mutex<FxHashSet<String>>>,
command_re: Regex,
}
impl Default for CommandPreviewer {
fn default() -> Self {
CommandPreviewer::new(None)
}
}
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct CommandPreviewerConfig {
delimiter: String,
}
const DEFAULT_DELIMITER: &str = " ";
impl Default for CommandPreviewerConfig {
fn default() -> Self {
CommandPreviewerConfig {
delimiter: String::from(DEFAULT_DELIMITER),
}
}
}
impl CommandPreviewerConfig {
pub fn new(delimiter: &str) -> Self {
CommandPreviewerConfig {
delimiter: String::from(delimiter),
}
}
}
const MAX_CONCURRENT_PREVIEW_TASKS: u8 = 3;
impl CommandPreviewer {
pub fn new(config: Option<CommandPreviewerConfig>) -> Self {
let config = config.unwrap_or_default();
CommandPreviewer {
cache: Arc::new(Mutex::new(PreviewCache::default())),
config,
concurrent_preview_tasks: Arc::new(AtomicU8::new(0)),
in_flight_previews: Arc::new(Mutex::new(FxHashSet::default())),
command_re: Regex::new(r"\{(\d+)\}").unwrap(),
}
}
pub fn cached(&self, entry: &Entry) -> Option<Arc<Preview>> {
self.cache.lock().get(&entry.name)
}
pub fn preview(
&mut self,
entry: &Entry,
command: &PreviewCommand,
) -> Option<Arc<Preview>> {
if let Some(preview) = self.cached(entry) {
Some(preview)
} else {
// preview is not in cache, spawn a task to compute the preview
debug!("Preview cache miss for {:?}", entry.name);
self.handle_preview_request(entry, command);
None
}
}
pub fn handle_preview_request(
&mut self,
entry: &Entry,
command: &PreviewCommand,
) {
if self.in_flight_previews.lock().contains(&entry.name) {
debug!("Preview already in flight for {:?}", entry.name);
return;
}
if self.concurrent_preview_tasks.load(Ordering::Relaxed)
< MAX_CONCURRENT_PREVIEW_TASKS
{
self.in_flight_previews.lock().insert(entry.name.clone());
self.concurrent_preview_tasks
.fetch_add(1, Ordering::Relaxed);
let cache = self.cache.clone();
let entry_c = entry.clone();
let concurrent_tasks = self.concurrent_preview_tasks.clone();
let command = command.clone();
let in_flight_previews = self.in_flight_previews.clone();
let command_re = self.command_re.clone();
tokio::spawn(async move {
try_preview(
&command,
&entry_c,
&cache,
&concurrent_tasks,
&in_flight_previews,
&command_re,
);
});
} else {
debug!(
"Too many concurrent preview tasks, skipping {:?}",
entry.name
);
}
}
}
/// Format the command with the entry name and provided placeholders
///
/// # Example
/// ```
/// use television::channels::{preview::{PreviewCommand, PreviewType}, entry::Entry};
/// use television::preview::previewers::command::format_command;
///
/// let command = PreviewCommand {
/// command: "something {} {2} {0}".to_string(),
/// delimiter: ":".to_string(),
/// };
/// let entry = Entry::new("a:given:entry:to:preview".to_string(), PreviewType::Command(command.clone()));
/// let formatted_command = format_command(&command, &entry, &regex::Regex::new(r"\{(\d+)\}").unwrap());
///
/// assert_eq!(formatted_command, "something 'a:given:entry:to:preview' 'entry' 'a'");
/// ```
pub fn format_command(
command: &PreviewCommand,
entry: &Entry,
command_re: &Regex,
) -> String {
let parts = entry.name.split(&command.delimiter).collect::<Vec<&str>>();
debug!("Parts: {:?}", parts);
let mut formatted_command = command
.command
.replace("{}", format!("'{}'", entry.name).as_str());
formatted_command = command_re
.replace_all(&formatted_command, |caps: &regex::Captures| {
let index =
caps.get(1).unwrap().as_str().parse::<usize>().unwrap();
format!("'{}'", parts[index])
})
.to_string();
formatted_command
}
pub fn try_preview(
command: &PreviewCommand,
entry: &Entry,
cache: &Arc<Mutex<PreviewCache>>,
concurrent_tasks: &Arc<AtomicU8>,
in_flight_previews: &Arc<Mutex<FxHashSet<String>>>,
command_re: &Regex,
) {
debug!("Computing preview for {:?}", entry.name);
let command = format_command(command, entry, command_re);
debug!("Formatted preview command: {:?}", command);
let child = shell_command(false)
.arg(&command)
.output()
.expect("failed to execute process");
if child.status.success() {
let content = String::from_utf8_lossy(&child.stdout);
let preview = Arc::new(Preview::new(
entry.name.clone(),
PreviewContent::AnsiText(content.to_string()),
None,
None,
u16::try_from(content.lines().count()).unwrap_or(u16::MAX),
));
cache.lock().insert(entry.name.clone(), &preview);
} else {
let content = String::from_utf8_lossy(&child.stderr);
let preview = Arc::new(Preview::new(
entry.name.clone(),
PreviewContent::AnsiText(content.to_string()),
None,
None,
u16::try_from(content.lines().count()).unwrap_or(u16::MAX),
));
cache.lock().insert(entry.name.clone(), &preview);
}
concurrent_tasks.fetch_sub(1, Ordering::Relaxed);
in_flight_previews.lock().remove(&entry.name);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::channels::{entry::Entry, preview::PreviewType};
#[test]
fn test_format_command() {
let command = PreviewCommand {
command: "something {} {2} {0}".to_string(),
delimiter: ":".to_string(),
};
let entry = Entry::new(
"an:entry:to:preview".to_string(),
PreviewType::Command(command.clone()),
);
let formatted_command = format_command(
&command,
&entry,
&Regex::new(r"\{(\d+)\}").unwrap(),
);
assert_eq!(
formatted_command,
"something 'an:entry:to:preview' 'to' 'an'"
);
}
#[test]
fn test_format_command_no_placeholders() {
let command = PreviewCommand {
command: "something".to_string(),
delimiter: ":".to_string(),
};
let entry = Entry::new(
"an:entry:to:preview".to_string(),
PreviewType::Command(command.clone()),
);
let formatted_command = format_command(
&command,
&entry,
&Regex::new(r"\{(\d+)\}").unwrap(),
);
assert_eq!(formatted_command, "something");
}
#[test]
fn test_format_command_with_global_placeholder_only() {
let command = PreviewCommand {
command: "something {}".to_string(),
delimiter: ":".to_string(),
};
let entry = Entry::new(
"an:entry:to:preview".to_string(),
PreviewType::Command(command.clone()),
);
let formatted_command = format_command(
&command,
&entry,
&Regex::new(r"\{(\d+)\}").unwrap(),
);
assert_eq!(formatted_command, "something 'an:entry:to:preview'");
}
#[test]
fn test_format_command_with_positional_placeholders_only() {
let command = PreviewCommand {
command: "something {0} -t {2}".to_string(),
delimiter: ":".to_string(),
};
let entry = Entry::new(
"an:entry:to:preview".to_string(),
PreviewType::Command(command.clone()),
);
let formatted_command = format_command(
&command,
&entry,
&Regex::new(r"\{(\d+)\}").unwrap(),
);
assert_eq!(formatted_command, "something 'an' -t 'to'");
}
}

View File

@ -1,50 +0,0 @@
use std::sync::Arc;
use crate::channels::entry;
use crate::preview::{Preview, PreviewContent};
#[derive(Debug, Default)]
pub struct EnvVarPreviewer {
_config: EnvVarPreviewerConfig,
}
#[derive(Debug, Default)]
pub struct EnvVarPreviewerConfig {}
impl EnvVarPreviewer {
pub fn new(config: Option<EnvVarPreviewerConfig>) -> Self {
EnvVarPreviewer {
_config: config.unwrap_or_default(),
}
}
pub fn preview(&self, entry: &entry::Entry) -> Arc<Preview> {
let content = entry.value.as_ref().map(|preview| {
maybe_add_newline_after_colon(preview, &entry.name)
});
let total_lines = content.as_ref().map_or_else(
|| 1,
|c| u16::try_from(c.lines().count()).unwrap_or(u16::MAX),
);
Arc::new(Preview {
title: entry.name.clone(),
content: match content {
Some(content) => PreviewContent::PlainTextWrapped(content),
None => PreviewContent::Empty,
},
icon: entry.icon,
partial_offset: None,
total_lines,
})
}
}
const PATH: &str = "PATH";
fn maybe_add_newline_after_colon(s: &str, name: &str) -> String {
if name.contains(PATH) {
return s.replace(':', "\n");
}
s.to_string()
}

View File

@ -1,379 +0,0 @@
use crate::utils::files::{read_into_lines_capped, ReadResult};
use crate::utils::syntax::HighlightedLines;
use image::ImageReader;
use parking_lot::Mutex;
use ratatui::layout::Rect;
use rustc_hash::{FxBuildHasher, FxHashSet};
use std::collections::HashSet;
use std::fs::File;
use std::io::{BufRead, BufReader, Seek};
use std::path::PathBuf;
use std::sync::{
atomic::{AtomicU8, Ordering},
Arc,
};
use syntect::{highlighting::Theme, parsing::SyntaxSet};
use tracing::{debug, trace, warn};
use crate::channels::entry;
use crate::preview::cache::PreviewCache;
use crate::preview::{previewers::meta, Preview, PreviewContent};
use crate::utils::image::ImagePreviewWidget;
use crate::utils::{
files::FileType,
strings::preprocess_line,
syntax::{self, load_highlighting_assets, HighlightingAssetsExt},
};
#[derive(Debug, Default)]
pub struct FilePreviewer {
cache: Arc<Mutex<PreviewCache>>,
pub syntax_set: Arc<SyntaxSet>,
pub syntax_theme: Arc<Theme>,
concurrent_preview_tasks: Arc<AtomicU8>,
in_flight_previews: Arc<Mutex<FxHashSet<String>>>,
}
#[derive(Debug, Clone, Default)]
pub struct FilePreviewerConfig {
pub theme: String,
}
impl FilePreviewerConfig {
pub fn new(theme: String) -> Self {
FilePreviewerConfig { theme }
}
}
const MAX_CONCURRENT_PREVIEW_TASKS: u8 = 3;
const BAT_THEME_ENV_VAR: &str = "BAT_THEME";
impl FilePreviewer {
pub fn new(config: Option<FilePreviewerConfig>) -> Self {
let hl_assets = load_highlighting_assets();
let syntax_set = hl_assets.get_syntax_set().unwrap().clone();
let theme_name = match std::env::var(BAT_THEME_ENV_VAR) {
Ok(t) => t,
Err(_) => match config {
Some(c) => c.theme,
// this will error and default back nicely
None => "unknown".to_string(),
},
};
let theme = hl_assets.get_theme_no_output(&theme_name).clone();
FilePreviewer {
cache: Arc::new(Mutex::new(PreviewCache::default())),
syntax_set: Arc::new(syntax_set),
syntax_theme: Arc::new(theme),
concurrent_preview_tasks: Arc::new(AtomicU8::new(0)),
in_flight_previews: Arc::new(Mutex::new(HashSet::with_hasher(
FxBuildHasher,
))),
}
}
pub fn cached(&self, entry: &entry::Entry) -> Option<Arc<Preview>> {
self.cache.lock().get(&entry.name)
}
pub fn preview(
&mut self,
entry: &entry::Entry,
preview_window: Option<Rect>,
) -> Option<Arc<Preview>> {
if let Some(preview) = self.cached(entry) {
trace!("Preview cache hit for {:?}", entry.name);
if preview.partial_offset.is_some() {
// preview is partial, spawn a task to compute the next chunk
// and return the partial preview
debug!("Spawning partial preview task for {:?}", entry.name);
self.handle_preview_request(
entry,
Some(preview.clone()),
preview_window,
);
}
Some(preview)
} else {
// preview is not in cache, spawn a task to compute the preview
trace!("Preview cache miss for {:?}", entry.name);
self.handle_preview_request(entry, None, preview_window);
None
}
}
pub fn handle_preview_request(
&mut self,
entry: &entry::Entry,
partial_preview: Option<Arc<Preview>>,
preview_window: Option<Rect>,
) {
if self.in_flight_previews.lock().contains(&entry.name) {
trace!("Preview already in flight for {:?}", entry.name);
return;
}
if self.concurrent_preview_tasks.load(Ordering::Relaxed)
< MAX_CONCURRENT_PREVIEW_TASKS
{
self.in_flight_previews.lock().insert(entry.name.clone());
self.concurrent_preview_tasks
.fetch_add(1, Ordering::Relaxed);
let cache = self.cache.clone();
let entry_c = entry.clone();
let syntax_set = self.syntax_set.clone();
let syntax_theme = self.syntax_theme.clone();
let concurrent_tasks = self.concurrent_preview_tasks.clone();
let in_flight_previews = self.in_flight_previews.clone();
tokio::spawn(async move {
try_preview(
&entry_c,
partial_preview,
&cache,
&syntax_set,
&syntax_theme,
&concurrent_tasks,
&in_flight_previews,
preview_window,
);
});
}
}
#[allow(dead_code)]
fn cache_preview(&mut self, key: String, preview: &Arc<Preview>) {
self.cache.lock().insert(key, preview);
}
}
/// The size of the buffer used to read the file in bytes.
/// This ends up being the max size of partial previews.
const PARTIAL_BUFREAD_SIZE: usize = 5 * 1024 * 1024;
#[allow(clippy::too_many_arguments)]
pub fn try_preview(
entry: &entry::Entry,
partial_preview: Option<Arc<Preview>>,
cache: &Arc<Mutex<PreviewCache>>,
syntax_set: &Arc<SyntaxSet>,
syntax_theme: &Arc<Theme>,
concurrent_tasks: &Arc<AtomicU8>,
in_flight_previews: &Arc<Mutex<FxHashSet<String>>>,
preview_window: Option<Rect>,
) {
debug!("Computing preview for {:?}", entry.name);
let path = PathBuf::from(&entry.name);
// if we're dealing with a partial preview, no need to re-check for textual content
if partial_preview.is_some()
|| matches!(FileType::from(&path), FileType::Text)
{
debug!("File is text-based: {:?}", entry.name);
match File::open(path) {
Ok(mut file) => {
// if we're dealing with a partial preview, seek to the provided offset
// and use the previous state to compute the next chunk of the preview
let cached_lines = if let Some(p) = partial_preview {
if let PreviewContent::SyntectHighlightedText(hl) =
&p.content
{
let _ = file.seek(std::io::SeekFrom::Start(
// this is always Some in this case
p.partial_offset.unwrap() as u64,
));
Some(hl.clone())
} else {
None
}
} else {
None
};
// compute the highlighted version in the background
match read_into_lines_capped(file, PARTIAL_BUFREAD_SIZE) {
ReadResult::Full(lines) => {
if let Some(content) = compute_highlighted_text_preview(
entry,
&lines
.iter()
.map(|l| preprocess_line(l).0 + "\n")
.collect::<Vec<_>>(),
syntax_set,
syntax_theme,
cached_lines.as_ref(),
) {
let total_lines = content.total_lines();
let preview = Arc::new(Preview::new(
entry.name.clone(),
content,
entry.icon,
None,
total_lines,
));
cache.lock().insert(entry.name.clone(), &preview);
}
}
ReadResult::Partial(p) => {
if let Some(content) = compute_highlighted_text_preview(
entry,
&p.lines
.iter()
.map(|l| preprocess_line(l).0 + "\n")
.collect::<Vec<_>>(),
syntax_set,
syntax_theme,
cached_lines.as_ref(),
) {
let total_lines = content.total_lines();
let preview = Arc::new(Preview::new(
entry.name.clone(),
content,
entry.icon,
Some(p.bytes_read),
total_lines,
));
cache.lock().insert(entry.name.clone(), &preview);
}
}
ReadResult::Error(e) => {
warn!("Error reading file: {:?}", e);
let p = meta::not_supported(&entry.name);
cache.lock().insert(entry.name.clone(), &p);
}
}
}
Err(e) => {
warn!("Error opening file: {:?}", e);
let p = meta::not_supported(&entry.name);
cache.lock().insert(entry.name.clone(), &p);
}
}
} else if matches!(FileType::from(&path), FileType::Image) {
cache.lock().insert(
entry.name.clone(),
&meta::loading(&format!("Loading {}", entry.name)),
);
debug!("File {:?} is an image", entry.name);
let option_image = match ImageReader::open(path) {
Ok(reader) => match reader.with_guessed_format() {
Ok(reader) => match reader.decode() {
Ok(image) => Some(image),
Err(e) => {
warn!(
"Error impossible to decode {}: {:?}",
entry.name, e
);
None
}
},
Err(e) => {
warn!(
"Error impossible to guess the format of {}: {:?}",
entry.name, e
);
None
}
},
Err(e) => {
warn!("Error opening image {}: {:?}", entry.name, e);
None
}
};
if let Some(image) = option_image {
let preview_window_dimension = preview_window.map(|rect| {
(
u32::from(rect.width.saturating_sub(2)),
u32::from(rect.height.saturating_sub(2)),
) // - 2 for the margin
});
let image_preview_widget = ImagePreviewWidget::from_dynamic_image(
image,
preview_window_dimension,
);
let total_lines =
image_preview_widget.height().try_into().unwrap_or(u16::MAX);
let content = PreviewContent::Image(image_preview_widget);
let preview = Arc::new(Preview::new(
entry.name.clone(),
content,
entry.icon,
None,
total_lines,
));
cache.lock().insert(entry.name.clone(), &preview);
} else {
let p = meta::not_supported(&entry.name);
cache.lock().insert(entry.name.clone(), &p);
}
} else {
debug!("File format isn't supported for preview: {:?}", entry.name);
let preview = meta::not_supported(&entry.name);
cache.lock().insert(entry.name.clone(), &preview);
}
concurrent_tasks.fetch_sub(1, Ordering::Relaxed);
in_flight_previews.lock().remove(&entry.name);
}
fn compute_highlighted_text_preview(
entry: &entry::Entry,
lines: &[String],
syntax_set: &SyntaxSet,
syntax_theme: &Theme,
previous_lines: Option<&HighlightedLines>,
) -> Option<PreviewContent> {
debug!(
"Computing highlights in the background for {:?}",
entry.name
);
match syntax::compute_highlights_incremental(
&PathBuf::from(&entry.name),
lines,
syntax_set,
syntax_theme,
previous_lines,
) {
Ok(highlighted_lines) => {
Some(PreviewContent::SyntectHighlightedText(highlighted_lines))
}
Err(e) => {
warn!("Error computing highlights: {:?}", e);
None
}
}
}
/// This should be enough for most terminal sizes
const TEMP_PLAIN_TEXT_PREVIEW_HEIGHT: usize = 200;
#[allow(dead_code)]
fn plain_text_preview(title: &str, reader: BufReader<&File>) -> Arc<Preview> {
debug!("Creating plain text preview for {:?}", title);
let mut lines = Vec::with_capacity(TEMP_PLAIN_TEXT_PREVIEW_HEIGHT);
// PERF: instead of using lines(), maybe check for the length of the first line instead and
// truncate accordingly (since this is just a temp preview)
for maybe_line in reader.lines() {
match maybe_line {
Ok(line) => lines.push(preprocess_line(&line).0),
Err(e) => {
warn!("Error reading file: {:?}", e);
return meta::not_supported(title);
}
}
if lines.len() >= TEMP_PLAIN_TEXT_PREVIEW_HEIGHT {
break;
}
}
let total_lines = u16::try_from(lines.len()).unwrap_or(u16::MAX);
Arc::new(Preview::new(
title.to_string(),
PreviewContent::PlainText(lines),
None,
None,
total_lines,
))
}

View File

@ -1,5 +0,0 @@
pub mod basic;
pub mod command;
pub mod env;
pub mod files;
pub mod meta;

View File

@ -1,20 +1,14 @@
use crate::preview::PreviewState;
use crate::preview::{
ansi::IntoText, PreviewContent, FILE_TOO_LARGE_MSG, LOADING_MSG,
PREVIEW_NOT_SUPPORTED_MSG, TIMEOUT_MSG,
};
use crate::screen::colors::{Colorscheme, PreviewColorscheme};
use crate::utils::image::ImagePreviewWidget;
use crate::preview::{PreviewContent, LOADING_MSG, TIMEOUT_MSG};
use crate::screen::colors::Colorscheme;
use crate::utils::strings::{
replace_non_printable, shrink_with_ellipsis, ReplaceNonPrintableConfig,
EMPTY_STRING,
};
use ansi_to_tui::IntoText;
use anyhow::Result;
use devicons::FileIcon;
use ratatui::buffer::Buffer;
use ratatui::widgets::{
Block, BorderType, Borders, Padding, Paragraph, Widget, Wrap,
};
use ratatui::widgets::{Block, BorderType, Borders, Padding, Paragraph};
use ratatui::Frame;
use ratatui::{
layout::{Alignment, Rect},
@ -26,22 +20,6 @@ use std::str::FromStr;
const FILL_CHAR_SLANTED: char = '';
const FILL_CHAR_EMPTY: char = ' ';
pub enum PreviewWidget<'a> {
Paragraph(Paragraph<'a>),
Image(ImagePreviewWidget),
}
impl Widget for PreviewWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized,
{
match self {
PreviewWidget::Paragraph(p) => p.render(area, buf),
PreviewWidget::Image(image) => image.render(area, buf),
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn draw_preview_content_block(
f: &mut Frame,
@ -59,25 +37,23 @@ pub fn draw_preview_content_block(
use_nerd_font_icons,
)?;
// render the preview content
let rp = build_preview_widget(
let rp = build_preview_paragraph(
inner,
&preview_state.preview.content,
preview_state.target_line,
preview_state.scroll,
colorscheme,
);
f.render_widget(rp, inner);
Ok(())
}
pub fn build_preview_widget<'a>(
pub fn build_preview_paragraph(
inner: Rect,
preview_content: &'a PreviewContent,
target_line: Option<u16>,
preview_content: &PreviewContent,
#[allow(unused_variables)] target_line: Option<u16>,
preview_scroll: u16,
colorscheme: &'a Colorscheme,
) -> PreviewWidget<'a> {
) -> Paragraph<'_> {
let preview_block =
Block::default().style(Style::default()).padding(Padding {
top: 0,
@ -87,75 +63,23 @@ pub fn build_preview_widget<'a>(
});
match preview_content {
PreviewContent::AnsiText(text) => PreviewWidget::Paragraph(
build_ansi_text_paragraph(text, preview_block, preview_scroll),
),
PreviewContent::PlainText(content) => {
PreviewWidget::Paragraph(build_plain_text_paragraph(
content,
preview_block,
target_line,
preview_scroll,
colorscheme.preview,
))
PreviewContent::AnsiText(text) => {
build_ansi_text_paragraph(text, preview_block, preview_scroll)
}
PreviewContent::PlainTextWrapped(content) => PreviewWidget::Paragraph(
build_plain_text_wrapped_paragraph(
content,
preview_block,
colorscheme.preview,
)
.scroll((preview_scroll, 0)),
),
PreviewContent::SyntectHighlightedText(highlighted_lines) => {
PreviewWidget::Paragraph(build_syntect_highlighted_paragraph(
&highlighted_lines.lines,
preview_block,
target_line,
preview_scroll,
colorscheme.preview,
inner.height,
))
}
PreviewContent::Image(image) => PreviewWidget::Image(image.clone()),
// meta
PreviewContent::Loading => PreviewWidget::Paragraph(
PreviewContent::Loading => {
build_meta_preview_paragraph(inner, LOADING_MSG, FILL_CHAR_EMPTY)
.block(preview_block)
.alignment(Alignment::Left)
.style(Style::default().add_modifier(Modifier::ITALIC)),
),
PreviewContent::NotSupported => PreviewWidget::Paragraph(
build_meta_preview_paragraph(
inner,
PREVIEW_NOT_SUPPORTED_MSG,
FILL_CHAR_EMPTY,
)
.block(preview_block)
.alignment(Alignment::Left)
.style(Style::default().add_modifier(Modifier::ITALIC)),
),
PreviewContent::FileTooLarge => PreviewWidget::Paragraph(
build_meta_preview_paragraph(
inner,
FILE_TOO_LARGE_MSG,
FILL_CHAR_EMPTY,
)
.block(preview_block)
.alignment(Alignment::Left)
.style(Style::default().add_modifier(Modifier::ITALIC)),
),
PreviewContent::Timeout => PreviewWidget::Paragraph(
.style(Style::default().add_modifier(Modifier::ITALIC))
}
PreviewContent::Timeout => {
build_meta_preview_paragraph(inner, TIMEOUT_MSG, FILL_CHAR_EMPTY)
.block(preview_block)
.alignment(Alignment::Left)
.style(Style::default().add_modifier(Modifier::ITALIC)),
),
PreviewContent::Empty => {
PreviewWidget::Paragraph(Paragraph::new(Text::raw(EMPTY_STRING)))
.style(Style::default().add_modifier(Modifier::ITALIC))
}
PreviewContent::Empty => Paragraph::new(Text::raw(EMPTY_STRING)),
}
}
@ -194,85 +118,6 @@ fn build_ansi_text_paragraph<'a>(
.scroll((preview_scroll, 0))
}
fn build_plain_text_paragraph<'a>(
text: &'a [String],
preview_block: Block<'a>,
target_line: Option<u16>,
preview_scroll: u16,
colorscheme: PreviewColorscheme,
) -> Paragraph<'a> {
let mut lines = Vec::new();
for (i, line) in text.iter().enumerate() {
lines.push(Line::from(vec![
build_line_number_span(i + 1).style(Style::default().fg(
if matches!(
target_line,
Some(l) if l == u16::try_from(i).unwrap_or(0) + 1
)
{
colorscheme.gutter_selected_fg
} else {
colorscheme.gutter_fg
},
)),
Span::styled("",
Style::default().fg(colorscheme.gutter_fg).dim()),
Span::styled(
line.to_string(),
Style::default().fg(colorscheme.content_fg).bg(
if matches!(target_line, Some(l) if l == u16::try_from(i).unwrap() + 1) {
colorscheme.highlight_bg
} else {
Color::Reset
},
),
),
]));
}
let text = Text::from(lines);
Paragraph::new(text)
.block(preview_block)
.scroll((preview_scroll, 0))
}
fn build_plain_text_wrapped_paragraph<'a>(
text: &'a str,
preview_block: Block<'a>,
colorscheme: PreviewColorscheme,
) -> Paragraph<'a> {
let mut lines = Vec::new();
for line in text.lines() {
lines.push(Line::styled(
line.to_string(),
Style::default().fg(colorscheme.content_fg),
));
}
let text = Text::from(lines);
Paragraph::new(text)
.block(preview_block)
.wrap(Wrap { trim: true })
}
fn build_syntect_highlighted_paragraph<'a>(
highlighted_lines: &'a [Vec<(syntect::highlighting::Style, String)>],
preview_block: Block<'a>,
target_line: Option<u16>,
preview_scroll: u16,
colorscheme: PreviewColorscheme,
height: u16,
) -> Paragraph<'a> {
compute_paragraph_from_highlighted_lines(
highlighted_lines,
target_line.map(|l| l as usize),
preview_scroll,
colorscheme,
height,
)
.block(preview_block)
.alignment(Alignment::Left)
//.scroll((preview_scroll, 0))
}
pub fn build_meta_preview_paragraph<'a>(
inner: Rect,
message: &str,
@ -381,81 +226,3 @@ fn draw_content_outer_block(
f.render_widget(preview_outer_block, rect);
Ok(inner)
}
fn build_line_number_span<'a>(line_number: usize) -> Span<'a> {
Span::from(format!("{line_number:5} "))
}
fn compute_paragraph_from_highlighted_lines(
highlighted_lines: &[Vec<(syntect::highlighting::Style, String)>],
line_specifier: Option<usize>,
preview_scroll: u16,
colorscheme: PreviewColorscheme,
height: u16,
) -> Paragraph<'static> {
let preview_lines: Vec<Line> = highlighted_lines
.iter()
.enumerate()
.skip(preview_scroll.saturating_sub(1).into())
.take(height.into())
.map(|(i, l)| {
let line_number =
build_line_number_span(i + 1).style(Style::default().fg(
if line_specifier.is_some()
&& i == line_specifier.unwrap().saturating_sub(1)
{
colorscheme.gutter_selected_fg
} else {
colorscheme.gutter_fg
},
));
Line::from_iter(
std::iter::once(line_number)
.chain(std::iter::once(Span::styled(
"",
Style::default().fg(colorscheme.gutter_fg).dim(),
)))
.chain(l.iter().cloned().map(|sr| {
convert_syn_region_to_span(
&(sr.0, sr.1),
if line_specifier.is_some()
&& i == line_specifier
.unwrap()
.saturating_sub(1)
{
Some(colorscheme.highlight_bg)
} else {
None
},
)
})),
)
})
.collect();
Paragraph::new(preview_lines)
}
pub fn convert_syn_region_to_span<'a>(
syn_region: &(syntect::highlighting::Style, String),
background: Option<Color>,
) -> Span<'a> {
let mut style = Style::default()
.fg(convert_syn_color_to_ratatui_color(syn_region.0.foreground));
if let Some(background) = background {
style = style.bg(background);
}
style = match syn_region.0.font_style {
syntect::highlighting::FontStyle::BOLD => style.bold(),
syntect::highlighting::FontStyle::ITALIC => style.italic(),
syntect::highlighting::FontStyle::UNDERLINE => style.underlined(),
_ => style,
};
Span::styled(syn_region.1.clone(), style)
}
fn convert_syn_color_to_ratatui_color(
color: syntect::highlighting::Color,
) -> Color {
Color::Rgb(color.r, color.g, color.b)
}

View File

@ -294,20 +294,17 @@ pub fn draw_results_list(
#[cfg(test)]
mod tests {
use crate::channels::preview::PreviewType;
use super::*;
#[test]
fn test_build_result_line() {
let entry =
Entry::new(String::from("something nice"), PreviewType::None)
.with_name_match_indices(
// something nice
// 012345678901234
// om ni
&[1, 2, 10, 11],
);
let entry = Entry::new(String::from("something nice"))
.with_name_match_indices(
// something nice
// 012345678901234
// om ni
&[1, 2, 10, 11],
);
let result_line = build_result_line(
&entry,
None,
@ -331,7 +328,7 @@ mod tests {
fn test_build_result_line_multibyte_chars() {
let entry =
// See https://github.com/alexpasmantier/television/issues/439
Entry::new(String::from("ジェイムス下地 - REDLINE Original Soundtrack - 06 - ROBOWORLD TV.mp3"), PreviewType::None)
Entry::new(String::from("ジェイムス下地 - REDLINE Original Soundtrack - 06 - ROBOWORLD TV.mp3"))
.with_name_match_indices(&[27, 28, 29, 30, 31]);
let result_line = build_result_line(
&entry,

View File

@ -1,24 +1,30 @@
use crate::action::Action;
use crate::cable::load_cable_channels;
use crate::channels::{
entry::{Entry, ENTRY_PLACEHOLDER},
preview::PreviewType,
use crate::{
action::Action,
cable::load_cable_channels,
channels::{
cable::{
prototypes::{CableChannelPrototype, CableChannelPrototypes},
Channel as CableChannel,
},
entry::Entry,
remote_control::RemoteControl,
OnAir, TelevisionChannel,
},
config::{Config, Theme},
draw::{ChannelState, Ctx, TvState},
input::convert_action_to_input_request,
picker::Picker,
preview::{previewer::Previewer, Preview, PreviewState},
render::UiState,
screen::{
colors::Colorscheme,
layout::InputPosition,
spinner::{Spinner, SpinnerState},
},
utils::{
clipboard::CLIPBOARD, metadata::AppMetadata, strings::EMPTY_STRING,
},
};
use crate::channels::{
remote_control::RemoteControl, OnAir, TelevisionChannel,
};
use crate::config::{Config, Theme};
use crate::draw::{ChannelState, Ctx, TvState};
use crate::input::convert_action_to_input_request;
use crate::picker::Picker;
use crate::preview::{Preview, PreviewState, Previewer};
use crate::render::UiState;
use crate::screen::colors::Colorscheme;
use crate::screen::layout::InputPosition;
use crate::screen::spinner::{Spinner, SpinnerState};
use crate::utils::clipboard::CLIPBOARD;
use crate::utils::metadata::AppMetadata;
use crate::utils::strings::EMPTY_STRING;
use anyhow::Result;
use rustc_hash::{FxBuildHasher, FxHashSet};
use serde::{Deserialize, Serialize};
@ -41,14 +47,14 @@ pub enum MatchingMode {
pub struct Television {
action_tx: UnboundedSender<Action>,
pub config: Config,
pub channel: TelevisionChannel,
pub channel: CableChannel,
pub remote_control: Option<TelevisionChannel>,
pub mode: Mode,
pub current_pattern: String,
pub matching_mode: MatchingMode,
pub results_picker: Picker,
pub rc_picker: Picker,
pub previewer: Previewer,
pub previewer: Option<Previewer>,
pub preview_state: PreviewState,
pub spinner: Spinner,
pub spinner_state: SpinnerState,
@ -60,22 +66,25 @@ pub struct Television {
}
impl Television {
#[allow(clippy::too_many_arguments)]
#[must_use]
pub fn new(
action_tx: UnboundedSender<Action>,
mut channel: TelevisionChannel,
channel_prototype: CableChannelPrototype,
mut config: Config,
input: Option<String>,
no_remote: bool,
no_help: bool,
exact: bool,
cable_channels: CableChannelPrototypes,
) -> Self {
let mut results_picker = Picker::new(input.clone());
if config.ui.input_bar_position == InputPosition::Bottom {
results_picker = results_picker.inverted();
}
let previewer = Previewer::new(Some(config.previewers.clone().into()));
let cable_channels = load_cable_channels().unwrap_or_default();
let previewer: Option<Previewer> = (&channel_prototype).into();
let mut channel: CableChannel = channel_prototype.into();
let app_metadata = AppMetadata::new(
env!("CARGO_PKG_VERSION").to_string(),
@ -150,7 +159,7 @@ impl Television {
pub fn dump_context(&self) -> Ctx {
let channel_state = ChannelState::new(
self.channel.name(),
self.channel.name.clone(),
self.channel.selected_entries().clone(),
self.channel.total_count(),
self.channel.running(),
@ -177,20 +186,25 @@ impl Television {
}
pub fn current_channel(&self) -> String {
self.channel.name()
self.channel.name.clone()
}
pub fn change_channel(&mut self, channel: TelevisionChannel) {
pub fn change_channel(
&mut self,
channel_prototype: CableChannelPrototype,
) {
self.preview_state.reset();
self.preview_state.enabled = channel.supports_preview();
self.preview_state.enabled =
channel_prototype.preview_command.is_some();
self.reset_picker_selection();
self.reset_picker_input();
self.current_pattern = EMPTY_STRING.to_string();
self.channel.shutdown();
self.channel = channel;
self.previewer = (&channel_prototype).into();
self.channel = channel_prototype.into();
}
fn find(&mut self, pattern: &str) {
pub fn find(&mut self, pattern: &str) {
match self.mode {
Mode::Channel => {
self.channel.find(
@ -373,15 +387,14 @@ impl Television {
) -> Result<()> {
if self.config.ui.show_preview_panel
&& self.channel.supports_preview()
&& !matches!(selected_entry.preview_type, PreviewType::None)
// FIXME: this is probably redundant with the channel supporting previews
&& self.previewer.is_some()
{
// preview content
if let Some(preview) = self
.previewer
.preview(selected_entry, self.ui_state.layout.preview_window)
{
// only update if the preview content has changed
if self.preview_state.preview.title != preview.title {
// avoid sending unnecessary requests to the previewer
if self.preview_state.preview.title != selected_entry.name {
if let Some(preview) =
self.previewer.as_mut().unwrap().request(selected_entry)
{
self.preview_state.update(
preview,
// scroll to center the selected entry
@ -515,6 +528,7 @@ impl Television {
.remote_control
.as_ref()
.unwrap()
// FIXME: is the TelevisionChannel enum still worth it?
.zap(entry.name.as_str())?;
// this resets the RC picker
self.reset_picker_selection();
@ -557,15 +571,12 @@ impl Television {
self.handle_input_action(action);
}
Action::SelectNextEntry => {
self.preview_state.reset();
self.select_next_entry(1);
}
Action::SelectPrevEntry => {
self.preview_state.reset();
self.select_prev_entry(1);
}
Action::SelectNextPage => {
self.preview_state.reset();
self.select_next_entry(
self.ui_state
.layout
@ -576,7 +587,6 @@ impl Television {
);
}
Action::SelectPrevPage => {
self.preview_state.reset();
self.select_prev_entry(
self.ui_state
.layout
@ -634,11 +644,11 @@ impl Television {
self.update_rc_picker_state();
}
let selected_entry = self
.get_selected_entry(Some(Mode::Channel))
.unwrap_or(ENTRY_PLACEHOLDER);
self.update_preview_state(&selected_entry)?;
if let Some(selected_entry) =
self.get_selected_entry(Some(Mode::Channel))
{
self.update_preview_state(&selected_entry)?;
}
self.ticks += 1;

View File

@ -1,185 +0,0 @@
use image::imageops::FilterType;
use image::{DynamicImage, Pixel, Rgba};
use ratatui::buffer::{Buffer, Cell};
use ratatui::layout::{Position, Rect};
use ratatui::prelude::Color;
use ratatui::widgets::Widget;
use std::fmt::Debug;
use std::hash::Hash;
static PIXEL_STRING: &str = "";
const FILTER_TYPE: FilterType = FilterType::Lanczos3;
// use to reduce the size of the image before storing it
const DEFAULT_CACHED_WIDTH: u32 = 50;
const DEFAULT_CACHED_HEIGHT: u32 = 100;
const GRAY: Rgba<u8> = Rgba([242, 242, 242, 255]);
const WHITE: Rgba<u8> = Rgba([255, 255, 255, 255]);
#[derive(Clone, Debug, Hash, PartialEq)]
pub struct ImagePreviewWidget {
cells: Vec<Vec<Cell>>,
}
impl Widget for &ImagePreviewWidget {
fn render(self, area: Rect, buf: &mut Buffer) {
let height = self.height();
let width = self.width();
// offset of the left top corner where the image is centered
let total_width = usize::from(area.width) + 2 * usize::from(area.x);
let x_offset = total_width.saturating_sub(width) / 2 + 1;
let total_height = usize::from(area.height) + 2 * usize::from(area.y);
let y_offset = total_height.saturating_sub(height) / 2;
let (area_border_up, area_border_down) =
(area.y, area.y + area.height);
let (area_border_left, area_border_right) =
(area.x, area.x + area.width);
for (y, row) in self.cells.iter().enumerate() {
let pos_y = u16::try_from(y_offset + y).unwrap_or(u16::MAX);
if pos_y >= area_border_up && pos_y < area_border_down {
for (x, cell) in row.iter().enumerate() {
let pos_x =
u16::try_from(x_offset + x).unwrap_or(u16::MAX);
if pos_x >= area_border_left && pos_x <= area_border_right
{
if let Some(buf_cell) =
buf.cell_mut(Position::new(pos_x, pos_y))
{
*buf_cell = cell.clone();
}
}
}
}
}
}
}
impl ImagePreviewWidget {
pub fn new(cells: Vec<Vec<Cell>>) -> ImagePreviewWidget {
ImagePreviewWidget { cells }
}
pub fn height(&self) -> usize {
self.cells.len()
}
pub fn width(&self) -> usize {
if self.height() > 0 {
self.cells[0].len()
} else {
0
}
}
pub fn from_dynamic_image(
dynamic_image: DynamicImage,
dimension: Option<(u32, u32)>,
) -> Self {
let (window_width, window_height) =
dimension.unwrap_or((DEFAULT_CACHED_WIDTH, DEFAULT_CACHED_HEIGHT));
let (max_width, max_height) = (window_width, window_height * 2 - 2); // -2 to have some space with the title
// first quick resize
let big_resized_image = if dynamic_image.width() > max_width * 4
|| dynamic_image.height() > max_height * 4
{
dynamic_image.resize(
max_width * 4,
max_height * 4,
FilterType::Nearest,
)
} else {
dynamic_image
};
// this time resize with the filter
let resized_image = if big_resized_image.width() > max_width
|| big_resized_image.height() > max_height
{
big_resized_image.resize(max_width, max_height, FILTER_TYPE)
} else {
big_resized_image
};
let cells = Self::cells_from_dynamic_image(resized_image);
ImagePreviewWidget::new(cells)
}
fn cells_from_dynamic_image(image: DynamicImage) -> Vec<Vec<Cell>> {
let image_rgba = image.into_rgba8();
//creation of the grid of cell
image_rgba
// iter over pair of rows
.rows()
.step_by(2)
.zip(image_rgba.rows().skip(1).step_by(2))
.enumerate()
.map(|(double_row_y, (row_1, row_2))| {
// create rows of cells
row_1
.into_iter()
.zip(row_2)
.enumerate()
.map(|(x, (color_up, color_down))| {
let position = (x, double_row_y);
DoublePixel::new(*color_up, *color_down)
.add_grid_background(position)
.into_cell()
})
.collect::<Vec<Cell>>()
})
.collect::<Vec<Vec<Cell>>>()
}
}
// util to convert Rgba into ratatui's Cell
struct DoublePixel {
color_up: Rgba<u8>,
color_down: Rgba<u8>,
}
impl DoublePixel {
pub fn new(color_up: Rgba<u8>, color_down: Rgba<u8>) -> Self {
Self {
color_up,
color_down,
}
}
pub fn add_grid_background(mut self, position: (usize, usize)) -> Self {
let color_up = self.color_up.0;
let color_down = self.color_down.0;
self.color_up = Self::blend_with_background(color_up, position, 0);
self.color_down = Self::blend_with_background(color_down, position, 1);
self
}
fn blend_with_background(
color: impl Into<Rgba<u8>>,
position: (usize, usize),
offset: usize,
) -> Rgba<u8> {
let color = color.into();
if color[3] == 255 {
color
} else {
let is_white = (position.0 + position.1 * 2 + offset) % 2 == 0;
let mut base = if is_white { WHITE } else { GRAY };
base.blend(&color);
base
}
}
pub fn into_cell(self) -> Cell {
let mut cell = Cell::new(PIXEL_STRING);
cell.set_bg(Self::convert_image_color_to_ratatui_color(
self.color_down,
))
.set_fg(Self::convert_image_color_to_ratatui_color(self.color_up));
cell
}
fn convert_image_color_to_ratatui_color(color: Rgba<u8>) -> Color {
Color::Rgb(color[0], color[1], color[2])
}
}

View File

@ -3,7 +3,6 @@ pub mod clipboard;
pub mod command;
pub mod files;
pub mod hashmaps;
pub mod image;
pub mod indices;
pub mod input;
pub mod metadata;
@ -11,5 +10,4 @@ pub mod rocell;
pub mod shell;
pub mod stdin;
pub mod strings;
pub mod syntax;
pub mod threads;

View File

@ -1,10 +1,11 @@
use std::fmt::Display;
use crate::cli::args::Shell as CliShell;
use crate::config::shell_integration::ShellIntegrationConfig;
use anyhow::Result;
use strum::Display;
use tracing::debug;
#[derive(Debug, Clone, Copy, PartialEq, Display)]
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Shell {
Bash,
Zsh,
@ -25,6 +26,18 @@ impl Default for Shell {
}
}
impl Display for Shell {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Shell::Bash => write!(f, "bash"),
Shell::Zsh => write!(f, "zsh"),
Shell::Fish => write!(f, "fish"),
Shell::PowerShell => write!(f, "powershell"),
Shell::Cmd => write!(f, "cmd"),
}
}
}
const SHELL_ENV_VAR: &str = "SHELL";
impl TryFrom<&str> for Shell {

View File

@ -1,284 +0,0 @@
use anyhow::Result;
use bat::assets::HighlightingAssets;
use gag::Gag;
use std::path::{Path, PathBuf};
use syntect::easy::HighlightLines;
use syntect::highlighting::{
HighlightIterator, HighlightState, Highlighter, Style, Theme,
};
use syntect::parsing::{ParseState, ScopeStack, SyntaxReference, SyntaxSet};
use tracing::warn;
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct HighlightingState {
parse_state: ParseState,
highlight_state: HighlightState,
}
impl HighlightingState {
pub fn new(
parse_state: ParseState,
highlight_state: HighlightState,
) -> Self {
Self {
parse_state,
highlight_state,
}
}
}
struct LineHighlighter<'a> {
highlighter: Highlighter<'a>,
pub parse_state: ParseState,
pub highlight_state: HighlightState,
}
impl<'a> LineHighlighter<'a> {
pub fn new(
syntax: &SyntaxReference,
theme: &'a Theme,
) -> LineHighlighter<'a> {
let highlighter = Highlighter::new(theme);
let highlight_state =
HighlightState::new(&highlighter, ScopeStack::new());
Self {
highlighter,
parse_state: ParseState::new(syntax),
highlight_state,
}
}
#[allow(dead_code)]
pub fn from_state(
state: HighlightingState,
theme: &'a Theme,
) -> LineHighlighter<'a> {
Self {
highlighter: Highlighter::new(theme),
parse_state: state.parse_state,
highlight_state: state.highlight_state,
}
}
/// Highlights a line of a file
pub fn highlight_line<'b>(
&mut self,
line: &'b str,
syntax_set: &SyntaxSet,
) -> Result<Vec<(Style, &'b str)>, syntect::Error> {
let ops = self.parse_state.parse_line(line, syntax_set)?;
let iter = HighlightIterator::new(
&mut self.highlight_state,
&ops[..],
line,
&self.highlighter,
);
Ok(iter.collect())
}
}
#[deprecated(
note = "Use `compute_highlights_incremental` instead, which also returns the state"
)]
pub fn compute_highlights_for_path(
file_path: &Path,
lines: &[String],
syntax_set: &SyntaxSet,
syntax_theme: &Theme,
) -> Result<Vec<Vec<(Style, String)>>> {
let syntax = set_syntax_set(syntax_set, file_path);
let mut highlighter = HighlightLines::new(syntax, syntax_theme);
let mut highlighted_lines = Vec::new();
for line in lines {
let hl_regions = highlighter.highlight_line(line, syntax_set)?;
highlighted_lines.push(
hl_regions
.iter()
.map(|(style, text)| (*style, (*text).to_string()))
.collect(),
);
}
Ok(highlighted_lines)
}
fn set_syntax_set<'a>(
syntax_set: &'a SyntaxSet,
file_path: &Path,
) -> &'a SyntaxReference {
syntax_set
.find_syntax_for_file(file_path)
.unwrap_or(None)
.unwrap_or_else(|| {
warn!(
"No syntax found for {:?}, defaulting to plain text",
file_path
);
syntax_set.find_syntax_plain_text()
})
}
#[derive(Debug, Clone, PartialEq, Hash)]
pub struct HighlightedLines {
pub lines: Vec<Vec<(Style, String)>>,
//pub state: Option<HighlightingState>,
}
impl HighlightedLines {
pub fn new(
lines: Vec<Vec<(Style, String)>>,
_state: &Option<HighlightingState>,
) -> Self {
Self { lines, /*state*/ }
}
}
pub fn compute_highlights_incremental(
file_path: &Path,
lines: &[String],
syntax_set: &SyntaxSet,
syntax_theme: &Theme,
_cached_lines: Option<&HighlightedLines>,
) -> Result<HighlightedLines> {
let mut highlighted_lines: Vec<_>;
let mut highlighter: LineHighlighter;
//if let Some(HighlightedLines {
// lines: c_lines,
// state: Some(s),
//}) = cached_lines
//{
// highlighter = LineHighlighter::from_state(s, syntax_theme);
// highlighted_lines = c_lines;
//} else {
// let syntax = set_syntax_set(syntax_set, file_path);
// highlighter = LineHighlighter::new(syntax, syntax_theme);
// highlighted_lines = Vec::new();
//};
let syntax = set_syntax_set(syntax_set, file_path);
highlighter = LineHighlighter::new(syntax, syntax_theme);
highlighted_lines = Vec::with_capacity(lines.len());
for line in lines {
let hl_regions = highlighter.highlight_line(line, syntax_set)?;
highlighted_lines.push(
hl_regions
.iter()
.map(|(style, text)| (*style, (*text).to_string()))
.collect(),
);
}
Ok(HighlightedLines::new(
highlighted_lines,
&Some(HighlightingState::new(
highlighter.parse_state.clone(),
highlighter.highlight_state.clone(),
)),
))
}
#[allow(dead_code)]
pub fn compute_highlights_for_line<'a>(
line: &'a str,
syntax_set: &SyntaxSet,
syntax_theme: &Theme,
file_path: &str,
) -> Result<Vec<(Style, &'a str)>> {
let syntax = syntax_set.find_syntax_for_file(file_path)?;
match syntax {
None => {
warn!(
"No syntax found for path {:?}, defaulting to plain text",
file_path
);
Ok(vec![(Style::default(), line)])
}
Some(syntax) => {
let mut highlighter = HighlightLines::new(syntax, syntax_theme);
Ok(highlighter.highlight_line(line, syntax_set)?)
}
}
}
// Based on code from https://github.com/sharkdp/bat e981e974076a926a38f124b7d8746de2ca5f0a28
//
// Copyright (c) 2018-2023 bat-developers (https://github.com/sharkdp/bat).
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
use directories::BaseDirs;
#[cfg(target_os = "macos")]
use std::env;
/// Wrapper for 'dirs' that treats `MacOS` more like `Linux`, by following the XDG specification.
///
/// This means that the `XDG_CACHE_HOME` and `XDG_CONFIG_HOME` environment variables are
/// checked first. The fallback directories are `~/.cache/bat` and `~/.config/bat`, respectively.
pub struct BatProjectDirs {
cache_dir: PathBuf,
}
impl BatProjectDirs {
fn new() -> Option<BatProjectDirs> {
#[cfg(target_os = "macos")]
let cache_dir_op = env::var_os("XDG_CACHE_HOME")
.map(PathBuf::from)
.filter(|p| p.is_absolute())
.or_else(|| BaseDirs::new().map(|d| d.home_dir().join(".cache")));
#[cfg(not(target_os = "macos"))]
let cache_dir_op = BaseDirs::new().map(|d| d.cache_dir().to_owned());
let cache_dir = cache_dir_op.map(|d| d.join("bat"))?;
Some(BatProjectDirs { cache_dir })
}
pub fn cache_dir(&self) -> &Path {
&self.cache_dir
}
}
pub fn load_highlighting_assets() -> HighlightingAssets {
let project_dirs = BatProjectDirs::new()
.unwrap_or_else(|| panic!("Could not get home directory"));
HighlightingAssets::from_cache(project_dirs.cache_dir())
.unwrap_or_else(|_| HighlightingAssets::from_binary())
}
pub trait HighlightingAssetsExt {
fn get_theme_no_output(&self, theme_name: &str) -> &Theme;
}
impl HighlightingAssetsExt for HighlightingAssets {
/// Get a theme by name. If the theme is not found, the default theme is returned.
///
/// This is an ugly hack to work around the fact that bat actually prints a warning
/// to stderr when a theme is not found which might mess up the TUI. This function
/// suppresses that warning by temporarily redirecting stderr and stdout.
fn get_theme_no_output(&self, theme_name: &str) -> &Theme {
let _e = Gag::stderr();
let _o = Gag::stdout();
let theme = self.get_theme(theme_name);
theme
}
}

View File

@ -3,7 +3,9 @@ use std::{collections::HashSet, path::PathBuf, time::Duration};
use television::{
action::Action,
app::{App, AppOptions},
channels::{cable::prototypes::CableChannelPrototype, TelevisionChannel},
channels::cable::prototypes::{
CableChannelPrototype, CableChannelPrototypes,
},
config::default_config_from_file,
};
use tokio::{task::JoinHandle, time::timeout};
@ -21,19 +23,19 @@ const DEFAULT_TIMEOUT: Duration = Duration::from_millis(100);
/// The app is started in a separate task and can be interacted with by sending
/// actions to the action channel.
fn setup_app(
channel: Option<TelevisionChannel>,
channel_prototype: Option<CableChannelPrototype>,
select_1: bool,
exact: bool,
) -> (
JoinHandle<television::app::AppOutput>,
tokio::sync::mpsc::UnboundedSender<Action>,
) {
let chan: TelevisionChannel = channel.unwrap_or_else(|| {
let chan: CableChannelPrototype = channel_prototype.unwrap_or_else(|| {
let target_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("target_dir");
std::env::set_current_dir(&target_dir).unwrap();
TelevisionChannel::Cable(CableChannelPrototype::default().into())
CableChannelPrototype::default()
});
let mut config = default_config_from_file().unwrap();
// this speeds up the tests
@ -47,7 +49,13 @@ fn setup_app(
false,
config.application.tick_rate,
);
let mut app = App::new(chan, config, input, options);
let mut app = App::new(
chan,
config,
input,
options,
&CableChannelPrototypes::default(),
);
// retrieve the app's action channel handle in order to send a quit action
let tx = app.action_tx.clone();
@ -212,11 +220,15 @@ async fn test_app_exact_search_positive() {
#[tokio::test(flavor = "multi_thread", worker_threads = 3)]
async fn test_app_exits_when_select_1_and_only_one_result() {
let channel =
TelevisionChannel::Stdin(television::channels::stdin::Channel::from(
vec!["file1.txt".to_string()],
));
let (f, tx) = setup_app(Some(channel), true, false);
let prototype = CableChannelPrototype::new(
"cable",
"echo file1.txt",
false,
None,
None,
None,
);
let (f, tx) = setup_app(Some(prototype), true, false);
// tick a few times to get the results
for _ in 0..=10 {
@ -246,11 +258,15 @@ async fn test_app_exits_when_select_1_and_only_one_result() {
#[tokio::test(flavor = "multi_thread", worker_threads = 3)]
async fn test_app_does_not_exit_when_select_1_and_more_than_one_result() {
let channel =
TelevisionChannel::Stdin(television::channels::stdin::Channel::from(
vec!["file1.txt".to_string(), "file2.txt".to_string()],
));
let (f, tx) = setup_app(Some(channel), true, false);
let prototype = CableChannelPrototype::new(
"cable",
"echo 'file1.txt\nfile2.txt'",
false,
None,
None,
None,
);
let (f, tx) = setup_app(Some(prototype), true, false);
// tick a few times to get the results
for _ in 0..=10 {