From ca09c503ca78be794d1daf32c05d5250a1d87835 Mon Sep 17 00:00:00 2001 From: Alex Pasmantier <47638216+alexpasmantier@users.noreply.github.com> Date: Fri, 16 May 2025 01:07:12 +0200 Subject: [PATCH] refactor!(cable): update cable channel preview configuration format + add custom preview offsets (#511) BREAKING CHANGE: the format of the cable channel files and more specifically the preview specification is updated to be a single table named `preview` (with keys `command`, `delimiter`, and `offset`) instead of three flat fields. ```toml preview_command = "echo 3" preview_delimiter = " " preview_offset = "{1}" ``` becomes: ```toml preview.command = "echo 3" preview.delimiter = " " preview.offset = "{1}" ``` --- benches/main/ui.rs | 4 +- cable/unix-channels.toml | 32 +++++++----- cable/windows-channels.toml | 18 +++---- television/app.rs | 2 +- television/cable.rs | 9 ++-- television/channels/cable.rs | 68 +++++++++++------------- television/channels/preview.rs | 60 ++++++--------------- television/channels/prototypes.rs | 86 +++++++++++++++---------------- television/cli/args.rs | 8 +++ television/cli/mod.rs | 32 +++++------- television/config/mod.rs | 6 +-- television/main.rs | 20 ++++--- television/previewer/state.rs | 27 +++++++--- television/screen/preview.rs | 30 +++++++++-- television/television.rs | 53 ++++++++++--------- tests/app.rs | 14 ++--- 16 files changed, 237 insertions(+), 232 deletions(-) diff --git a/benches/main/ui.rs b/benches/main/ui.rs index 886f42a..c095812 100644 --- a/benches/main/ui.rs +++ b/benches/main/ui.rs @@ -472,11 +472,11 @@ 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 channel = ChannelPrototype::default(); + let channel_prototype = ChannelPrototype::default(); // Wait for the channel to finish loading let mut tv = Television::new( tx, - channel, + &channel_prototype, config, None, false, diff --git a/cable/unix-channels.toml b/cable/unix-channels.toml index b98b885..585b54a8 100644 --- a/cable/unix-channels.toml +++ b/cable/unix-channels.toml @@ -2,26 +2,27 @@ [[cable_channel]] name = "files" source_command = "fd -t f" -preview_command = "bat -n --color=always {}" +preview.command = "bat -n --color=always {}" # Text [[cable_channel]] name = "text" source_command = "rg . --no-heading --line-number" -preview_command = "bat -n --color=always {0} -H {1}" -preview_delimiter = ":" +preview.command = "bat -n --color=always {0}" +preview.delimiter = ":" +preview.offset = "{1}" # Directories [[cable_channel]] name = "dirs" source_command = "fd -t d" -preview_command = "ls -la --color=always {}" +preview.command = "ls -la --color=always {}" # Environment variables [[cable_channel]] name = "env" source_command = "printenv" -preview_command = "cut -d= -f2 <<< ${0} | cut -d\" \" -f2- | sed 's/:/\\n/g'" +preview.command = "cut -d= -f2 <<< ${0} | cut -d\" \" -f2- | sed 's/:/\\n/g'" # Aliases [[cable_channel]] @@ -32,47 +33,50 @@ interactive = true # GIT [[cable_channel]] name = "git-repos" -# this is a MacOS version but feel free to change it to fit your needs +# this is a MacOS version but feel free to override it to fit your needs source_command = "fd -g .git -HL -t d -d 10 --prune ~ -E 'Library' -E 'Application Support' --exec dirname {}" -preview_command = "cd {} && git log -n 200 --pretty=medium --all --graph --color" +preview.command = "cd {} && git log -n 200 --pretty=medium --all --graph --color" [[cable_channel]] name = "git-diff" source_command = "git diff --name-only" -preview_command = "git diff --color=always {0}" +preview.command = "git diff --color=always {0}" [[cable_channel]] name = "git-reflog" source_command = 'git reflog' -preview_command = 'git show -p --stat --pretty=fuller --color=always {0}' +preview.command = 'git show -p --stat --pretty=fuller --color=always {0}' + [[cable_channel]] name = "git-log" source_command = "git log --oneline --date=short --pretty=\"format:%h %s %an %cd\" \"$@\"" -preview_command = "git show -p --stat --pretty=fuller --color=always {0}" +preview.command = 'git show -p --stat --pretty=fuller --color=always {0}' [[cable_channel]] name = "git-branch" source_command = "git --no-pager branch --all --format=\"%(refname:short)\"" -preview_command = "git show -p --stat --pretty=fuller --color=always {0}" +preview.command = 'git show -p --stat --pretty=fuller --color=always {0}' # Docker [[cable_channel]] name = "docker-images" source_command = "docker image list --format \"{{.ID}}\"" -preview_command = "docker image inspect {0} | jq -C" +preview.command = "docker image inspect {0} | jq -C" + # S3 [[cable_channel]] name = "s3-buckets" source_command = "aws s3 ls | cut -d \" \" -f 3" -preview_command = "aws s3 ls s3://{0}" +preview.command = "aws s3 ls s3://{0}" + # Dotfiles [[cable_channel]] name = "my-dotfiles" source_command = "fd -t f . $HOME/.config" -preview_command = "bat -n --color=always {}" +preview.command = "bat -n --color=always {}" # Shell history [[cable_channel]] diff --git a/cable/windows-channels.toml b/cable/windows-channels.toml index 2dbfcde..c9e5455 100644 --- a/cable/windows-channels.toml +++ b/cable/windows-channels.toml @@ -2,13 +2,13 @@ [[cable_channel]] name = "files" source_command = "Get-ChildItem -Recurse -File | Select-Object -ExpandProperty FullName" -preview_command = "bat -n --color=always {}" +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 {}" +preview.command = "ls -l {}" # Environment variables [[cable_channel]] @@ -24,39 +24,39 @@ source_command = "Get-Alias" [[cable_channel]] name = "git-repos" source_command = "Get-ChildItem -Path 'C:\\Users' -Recurse -Directory -Force -ErrorAction SilentlyContinue | Where-Object { Test-Path \"$($_.FullName)\\.git\" } | Select-Object -ExpandProperty FullName" -preview_command = "cd '{}' ; git log -n 200 --pretty=medium --all --graph --color" +preview.command = "cd '{}' ; git log -n 200 --pretty=medium --all --graph --color" [[cable_channel]] name = "git-diff" source_command = "git diff --name-only" -preview_command = "git diff --color=always {}" +preview.command = "git diff --color=always {}" [[cable_channel]] name = "git-reflog" source_command = "git reflog" -preview_command = "git show -p --stat --pretty=fuller --color=always {0}" +preview.command = "git show -p --stat --pretty=fuller --color=always {0}" [[cable_channel]] name = "git-log" source_command = "git log --oneline --date=short --pretty='format:%h %s %an %cd'" -preview_command = "git show -p --stat --pretty=fuller --color=always {0}" +preview.command = "git show -p --stat --pretty=fuller --color=always {0}" [[cable_channel]] name = "git-branch" source_command = "git branch --all --format='%(refname:short)'" -preview_command = "git show -p --stat --pretty=fuller --color=always {0}" +preview.command = "git show -p --stat --pretty=fuller --color=always {0}" # Docker [[cable_channel]] name = "docker-images" source_command = "docker image ls --format '{{.ID}}'" -preview_command = "docker image inspect {0} | jq -C" +preview.command = "docker image inspect {0} | jq -C" # Dotfiles (adapted to common Windows dotfile locations) [[cable_channel]] name = "my-dotfiles" source_command = "Get-ChildItem -Recurse -File -Path \"$env:USERPROFILE\\AppData\\Roaming\\\"" -preview_command = "bat -n --color=always {}" +preview.command = "bat -n --color=always {}" # Shell history [[cable_channel]] diff --git a/television/app.rs b/television/app.rs index be12bdc..ec6a122 100644 --- a/television/app.rs +++ b/television/app.rs @@ -137,7 +137,7 @@ const ACTION_BUF_SIZE: usize = 8; impl App { pub fn new( - channel_prototype: ChannelPrototype, + channel_prototype: &ChannelPrototype, config: Config, input: Option, options: AppOptions, diff --git a/television/cable.rs b/television/cable.rs index c8cf2c7..fa8fbca 100644 --- a/television/cable.rs +++ b/television/cable.rs @@ -80,11 +80,10 @@ pub fn load_cable() -> Result { }, ); - debug!( - "Loaded {} default and {} custom prototypes", - default_prototypes.prototypes.len(), - prototypes.len() - ); + debug!("Loaded {} custom cable channels", prototypes.len()); + if prototypes.is_empty() { + debug!("No custom cable channels found"); + } let mut cable_channels = FxHashMap::default(); // custom prototypes take precedence over default ones diff --git a/television/channels/cable.rs b/television/channels/cable.rs index 1597eff..313eec8 100644 --- a/television/channels/cable.rs +++ b/television/channels/cable.rs @@ -6,14 +6,14 @@ use rustc_hash::{FxBuildHasher, FxHashSet}; use tracing::debug; use crate::channels::{ - entry::Entry, - preview::PreviewCommand, - prototypes::{ChannelPrototype, DEFAULT_DELIMITER}, + entry::Entry, preview::PreviewCommand, prototypes::ChannelPrototype, }; use crate::matcher::Matcher; use crate::matcher::{config::Config, injector::Injector}; use crate::utils::command::shell_command; +use super::prototypes::format_prototype_string; + pub struct Channel { pub name: String, matcher: Matcher, @@ -24,53 +24,28 @@ pub struct Channel { impl Default for Channel { fn default() -> Self { - Self::new( + Self::new(&ChannelPrototype::new( "files", "find . -type f", false, Some(PreviewCommand::new("cat {}", ":", None)), - ) - } -} - -impl From for Channel { - fn from(prototype: ChannelPrototype) -> Self { - Self::new( - &prototype.name, - &prototype.source_command, - prototype.interactive, - match prototype.preview_command { - Some(command) => Some(PreviewCommand::new( - &command, - &prototype - .preview_delimiter - .unwrap_or(DEFAULT_DELIMITER.to_string()), - prototype.preview_offset, - )), - None => None, - }, - ) + )) } } impl Channel { - pub fn new( - name: &str, - entries_command: &str, - interactive: bool, - preview_command: Option, - ) -> Self { + pub fn new(prototype: &ChannelPrototype) -> Self { let matcher = Matcher::new(Config::default()); let injector = matcher.injector(); let crawl_handle = tokio::spawn(load_candidates( - entries_command.to_string(), - interactive, + prototype.source_command.to_string(), + prototype.interactive, injector, )); Self { matcher, - preview_command, - name: name.to_string(), + preview_command: prototype.preview_command.clone(), + name: prototype.name.to_string(), selected_entries: HashSet::with_hasher(FxBuildHasher), crawl_handle, } @@ -94,8 +69,27 @@ impl Channel { pub fn get_result(&self, index: u32) -> Option { self.matcher.get_result(index).map(|item| { - let path = item.matched_string; - Entry::new(path) + let name = item.matched_string; + if let Some(cmd) = &self.preview_command { + if let Some(offset_expr) = &cmd.offset_expr { + let offset_string = format_prototype_string( + offset_expr, + &name, + &cmd.delimiter, + ); + let offset_str = { + offset_string + .strip_prefix('\'') + .and_then(|s| s.strip_suffix('\'')) + .unwrap_or(&offset_string) + }; + + return Entry::new(name).with_line_number( + offset_str.parse::().unwrap(), + ); + } + } + Entry::new(name) }) } diff --git a/television/channels/preview.rs b/television/channels/preview.rs index 5c7f48a..e6f4dbe 100644 --- a/television/channels/preview.rs +++ b/television/channels/preview.rs @@ -1,21 +1,27 @@ use std::fmt::Display; -use crate::channels::{ - entry::Entry, - prototypes::{ChannelPrototype, DEFAULT_DELIMITER}, -}; -use lazy_regex::{regex, Lazy, Regex}; -use tracing::debug; +use serde::Deserialize; -static CMD_RE: &Lazy = regex!(r"\{(\d+)\}"); +use crate::channels::{entry::Entry, prototypes::format_prototype_string}; -#[derive(Debug, Clone, Eq, PartialEq, Hash, Default)] +#[derive(Debug, Clone, Eq, PartialEq, Hash, Default, Deserialize)] pub struct PreviewCommand { pub command: String, + #[serde(default = "default_delimiter")] pub delimiter: String, + #[serde(rename = "offset")] pub offset_expr: Option, } +pub const DEFAULT_DELIMITER: &str = " "; + +/// The default delimiter to use for the preview command to use to split +/// entries into multiple referenceable parts. +#[allow(clippy::unnecessary_wraps)] +fn default_delimiter() -> String { + DEFAULT_DELIMITER.to_string() +} + impl PreviewCommand { pub fn new( command: &str, @@ -47,43 +53,7 @@ impl PreviewCommand { /// 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::>(); - - 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: ®ex::Captures| { - let index = - caps.get(1).unwrap().as_str().parse::().unwrap(); - format!("'{}'", parts[index]) - }) - .to_string(); - - formatted_command - } -} - -impl From<&ChannelPrototype> for Option { - fn from(value: &ChannelPrototype) -> 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 - } + format_prototype_string(&self.command, &entry.name, &self.delimiter) } } diff --git a/television/channels/prototypes.rs b/television/channels/prototypes.rs index 57aae72..d636baf 100644 --- a/television/channels/prototypes.rs +++ b/television/channels/prototypes.rs @@ -1,3 +1,4 @@ +use lazy_regex::{regex, Lazy, Regex}; use rustc_hash::FxHashMap; use std::{ fmt::{self, Display, Formatter}, @@ -43,10 +44,8 @@ pub struct ChannelPrototype { pub source_command: String, #[serde(default)] pub interactive: bool, - pub preview_command: Option, - #[serde(default = "default_delimiter")] - pub preview_delimiter: Option, - pub preview_offset: Option, + #[serde(rename = "preview")] + pub preview_command: Option, } const STDIN_CHANNEL_NAME: &str = "stdin"; @@ -57,52 +56,27 @@ impl ChannelPrototype { name: &str, source_command: &str, interactive: bool, - preview_command: Option, - preview_delimiter: Option, - preview_offset: Option, + preview_command: Option, ) -> Self { Self { name: name.to_string(), source_command: source_command.to_string(), interactive, preview_command, - preview_delimiter, - preview_offset, } } pub fn stdin(preview: Option) -> 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, - }, + Self { + name: STDIN_CHANNEL_NAME.to_string(), + source_command: STDIN_SOURCE_COMMAND.to_string(), + interactive: false, + preview_command: preview, } } - - pub fn preview_command(&self) -> Option { - self.into() - } } -const DEFAULT_PROTOTYPE_NAME: &str = "files"; -pub const DEFAULT_DELIMITER: &str = " "; +pub const DEFAULT_PROTOTYPE_NAME: &str = "files"; impl Default for ChannelPrototype { fn default() -> Self { @@ -113,19 +87,35 @@ impl Default for ChannelPrototype { } } -/// The default delimiter to use for the preview command to use to split -/// entries into multiple referenceable parts. -#[allow(clippy::unnecessary_wraps)] -fn default_delimiter() -> Option { - Some(DEFAULT_DELIMITER.to_string()) -} - impl Display for ChannelPrototype { fn fmt(&self, f: &mut Formatter) -> fmt::Result { write!(f, "{}", self.name) } } +pub static CMD_RE: &Lazy = regex!(r"\{(\d+)\}"); + +pub fn format_prototype_string( + template: &str, + source: &str, + delimiter: &str, +) -> String { + let parts = source.split(delimiter).collect::>(); + + let mut formatted_string = + template.replace("{}", format!("'{}'", source).as_str()); + + formatted_string = CMD_RE + .replace_all(&formatted_string, |caps: ®ex::Captures| { + let index = + caps.get(1).unwrap().as_str().parse::().unwrap(); + format!("'{}'", parts[index]) + }) + .to_string(); + + formatted_string +} + /// A neat `HashMap` of channel prototypes indexed by their name. /// /// This is used to store cable channel prototypes throughout the application @@ -142,6 +132,16 @@ impl Deref for Cable { } } +impl Cable { + pub fn default_channel(&self) -> ChannelPrototype { + self.get(DEFAULT_PROTOTYPE_NAME) + .cloned() + .unwrap_or_else(|| { + panic!("Default channel '{DEFAULT_PROTOTYPE_NAME}' not found") + }) + } +} + /// A default cable channels specification that is compiled into the /// application. #[cfg(unix)] diff --git a/television/cli/args.rs b/television/cli/args.rs index f61d1d3..100bbe2 100644 --- a/television/cli/args.rs +++ b/television/cli/args.rs @@ -25,6 +25,14 @@ pub struct Cli { #[arg(short, long, value_name = "STRING", verbatim_doc_comment)] pub preview: Option, + /// A preview line number offset template to use to scroll the preview to for each + /// entry. + /// + /// This template uses the same syntax as the `preview` option and will be formatted + /// using the currently selected entry. + #[arg(long, value_name = "STRING", verbatim_doc_comment)] + pub preview_offset: Option, + /// Disable the preview panel entirely on startup. #[arg(long, default_value = "false", verbatim_doc_comment)] pub no_preview: bool, diff --git a/television/cli/mod.rs b/television/cli/mod.rs index 4b87808..44fcd4f 100644 --- a/television/cli/mod.rs +++ b/television/cli/mod.rs @@ -11,7 +11,7 @@ use crate::{ prototypes::{Cable, ChannelPrototype}, }, cli::args::{Cli, Command}, - config::{get_config_dir, get_data_dir, KeyBindings, DEFAULT_CHANNEL}, + config::{get_config_dir, get_data_dir, KeyBindings}, }; pub mod args; @@ -75,23 +75,19 @@ impl From for PostProcessedCli { 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, + offset_expr: cli.preview_offset.clone(), }); let mut channel: ChannelPrototype; let working_directory: Option; - let cable_channels = cable::load_cable().unwrap_or_default(); + let cable = cable::load_cable().unwrap_or_default(); if cli.channel.is_none() { - channel = cable_channels - .get(DEFAULT_CHANNEL) - .expect("Default channel not found in cable channels") - .clone(); + channel = cable.default_channel(); working_directory = cli.working_directory; } else { let cli_channel = cli.channel.as_ref().unwrap().to_owned(); - match parse_channel(&cli_channel, &cable_channels) { + match parse_channel(&cli_channel, &cable) { Ok(p) => { channel = p; working_directory = cli.working_directory; @@ -102,12 +98,7 @@ impl From for PostProcessedCli { if cli.working_directory.is_none() && Path::new(&cli_channel).exists() { - channel = cable_channels - .get(DEFAULT_CHANNEL) - .expect( - "Default channel not found in cable channels", - ) - .clone(); + channel = cable.default_channel(); working_directory = Some(cli.channel.unwrap().clone()); } else { unknown_channel_exit(&cli.channel.unwrap()); @@ -117,10 +108,9 @@ impl From for PostProcessedCli { } } + // override the default previewer 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); + channel.preview_command = Some(preview_cmd.clone()); } Self { @@ -317,7 +307,11 @@ mod tests { let post_processed_cli: PostProcessedCli = cli.into(); let expected = ChannelPrototype { - preview_delimiter: Some(":".to_string()), + preview_command: Some(PreviewCommand { + command: "bat -n --color=always {}".to_string(), + delimiter: ":".to_string(), + offset_expr: None, + }), ..Default::default() }; diff --git a/television/config/mod.rs b/television/config/mod.rs index bd3158e..078bb58 100644 --- a/television/config/mod.rs +++ b/television/config/mod.rs @@ -15,6 +15,8 @@ pub use themes::Theme; use tracing::{debug, warn}; pub use ui::UiConfig; +use crate::channels::prototypes::DEFAULT_PROTOTYPE_NAME; + mod keybindings; pub mod shell_integration; mod themes; @@ -39,10 +41,8 @@ pub struct AppConfig { pub default_channel: String, } -pub const DEFAULT_CHANNEL: &str = "files"; - fn default_channel() -> String { - DEFAULT_CHANNEL.to_string() + DEFAULT_PROTOTYPE_NAME.to_string() } impl Hash for AppConfig { diff --git a/television/main.rs b/television/main.rs index 57713e7..c496beb 100644 --- a/television/main.rs +++ b/television/main.rs @@ -60,7 +60,7 @@ async fn main() -> Result<()> { // determine the channel to use based on the CLI arguments and configuration debug!("Determining channel..."); - let channel = determine_channel( + let channel_prototype = determine_channel( args.clone(), &config, is_readable_stdin(), @@ -77,8 +77,13 @@ async fn main() -> Result<()> { args.no_help, config.application.tick_rate, ); - let mut app = - App::new(channel, config, args.input, options, &cable_channels); + let mut app = App::new( + &channel_prototype, + config, + args.input, + options, + &cable_channels, + ); stdout().flush()?; debug!("Running application..."); let output = app.run(stdout().is_terminal(), false).await?; @@ -217,7 +222,7 @@ mod tests { &args, &config, true, - &ChannelPrototype::new("stdin", "cat", false, None, None, None), + &ChannelPrototype::new("stdin", "cat", false, None), None, ); } @@ -226,7 +231,7 @@ mod tests { async fn test_determine_channel_autocomplete_prompt() { let autocomplete_prompt = Some("cd".to_string()); let expected_channel = - ChannelPrototype::new("dirs", "ls {}", false, None, None, None); + ChannelPrototype::new("dirs", "ls {}", false, None); let args = PostProcessedCli { autocomplete_prompt, ..Default::default() @@ -258,8 +263,7 @@ mod tests { #[tokio::test] async fn test_determine_channel_standard_case() { - let channel = - ChannelPrototype::new("dirs", "", false, None, None, None); + let channel = ChannelPrototype::new("dirs", "", false, None); let args = PostProcessedCli { channel, ..Default::default() @@ -269,7 +273,7 @@ mod tests { &args, &config, false, - &ChannelPrototype::new("dirs", "", false, None, None, None), + &ChannelPrototype::new("dirs", "", false, None), None, ); } diff --git a/television/previewer/state.rs b/television/previewer/state.rs index d1265b2..57bfa91 100644 --- a/television/previewer/state.rs +++ b/television/previewer/state.rs @@ -9,7 +9,7 @@ pub struct PreviewState { } const PREVIEW_MIN_SCROLL_LINES: u16 = 3; -pub const ANSI_BEFORE_CONTEXT_SIZE: u16 = 10; +pub const ANSI_BEFORE_CONTEXT_SIZE: u16 = 3; const ANSI_CONTEXT_SIZE: usize = 500; impl PreviewState { @@ -51,7 +51,7 @@ impl PreviewState { scroll: u16, target_line: Option, ) { - if self.preview.title != preview.title { + if self.preview.title != preview.title || self.scroll != scroll { self.preview = preview; self.scroll = scroll; self.target_line = target_line; @@ -59,16 +59,31 @@ impl PreviewState { } pub fn for_render_context(&self) -> Self { - let skipped_lines = + let num_skipped_lines = self.scroll.saturating_sub(ANSI_BEFORE_CONTEXT_SIZE); let cropped_content = self .preview .content .lines() - .skip(skipped_lines as usize) + .skip(num_skipped_lines as usize) .take(ANSI_CONTEXT_SIZE) .collect::>() .join("\n"); + + let target_line: Option = + if let Some(target_line) = self.target_line { + if num_skipped_lines < target_line + && (target_line - num_skipped_lines) + <= u16::try_from(ANSI_CONTEXT_SIZE).unwrap() + { + Some(target_line.saturating_sub(num_skipped_lines)) + } else { + None + } + } else { + None + }; + PreviewState::new( self.enabled, Preview::new( @@ -77,8 +92,8 @@ impl PreviewState { self.preview.icon, self.preview.total_lines, ), - skipped_lines, - self.target_line, + num_skipped_lines, + target_line, ) } } diff --git a/television/screen/preview.rs b/television/screen/preview.rs index 87692be..de1ad1c 100644 --- a/television/screen/preview.rs +++ b/television/screen/preview.rs @@ -32,13 +32,19 @@ pub fn draw_preview_content_block( use_nerd_font_icons, )?; // render the preview content - let rp = build_preview_paragraph(&preview_state.preview.content); + let rp = build_preview_paragraph( + preview_state, + colorscheme.preview.highlight_bg, + ); f.render_widget(rp, inner); Ok(()) } -pub fn build_preview_paragraph(content: &str) -> Paragraph<'_> { +pub fn build_preview_paragraph( + preview_state: &PreviewState, + highlight_bg: Color, +) -> Paragraph<'_> { let preview_block = Block::default().style(Style::default()).padding(Padding { top: 0, @@ -47,14 +53,30 @@ pub fn build_preview_paragraph(content: &str) -> Paragraph<'_> { left: 1, }); - build_ansi_text_paragraph(content, preview_block) + build_ansi_text_paragraph( + &preview_state.preview.content, + preview_block, + preview_state.target_line, + highlight_bg, + ) } fn build_ansi_text_paragraph<'a>( text: &'a str, preview_block: Block<'a>, + target_line: Option, + highlight_bg: Color, ) -> Paragraph<'a> { - Paragraph::new(text.into_text().unwrap()).block(preview_block) + let mut t = text.into_text().unwrap(); + if let Some(target_line) = target_line { + // Highlight the target line + if let Some(line) = t.lines.get_mut((target_line - 1) as usize) { + for span in &mut line.spans { + span.style = span.style.bg(highlight_bg); + } + } + } + Paragraph::new(t).block(preview_block) } pub fn build_meta_preview_paragraph<'a>( diff --git a/television/television.rs b/television/television.rs index cc46b6f..670ee02 100644 --- a/television/television.rs +++ b/television/television.rs @@ -31,6 +31,7 @@ use std::collections::HashSet; use tokio::sync::mpsc::{ unbounded_channel, UnboundedReceiver, UnboundedSender, }; +use tracing::debug; #[derive(PartialEq, Copy, Clone, Hash, Eq, Debug, Serialize, Deserialize)] pub enum Mode { @@ -72,7 +73,7 @@ impl Television { #[must_use] pub fn new( action_tx: UnboundedSender, - channel_prototype: ChannelPrototype, + channel_prototype: &ChannelPrototype, mut config: Config, input: Option, no_remote: bool, @@ -86,9 +87,9 @@ impl Television { } // previewer - let preview_handles = Self::setup_previewer(&channel_prototype); + let preview_handles = Self::setup_previewer(channel_prototype); - let mut channel: CableChannel = channel_prototype.into(); + let mut channel = CableChannel::new(channel_prototype); let app_metadata = AppMetadata::new( env!("CARGO_PKG_VERSION").to_string(), @@ -157,7 +158,7 @@ impl Television { let (pv_request_tx, pv_request_rx) = unbounded_channel(); let (pv_preview_tx, pv_preview_rx) = unbounded_channel(); let previewer = Previewer::new( - channel_prototype.preview_command().unwrap(), + channel_prototype.preview_command.clone().unwrap(), PreviewerConfig::default(), pv_request_rx, pv_preview_tx, @@ -204,7 +205,7 @@ impl Television { self.channel.name.clone() } - pub fn change_channel(&mut self, channel_prototype: ChannelPrototype) { + pub fn change_channel(&mut self, channel_prototype: &ChannelPrototype) { self.preview_state.reset(); self.preview_state.enabled = channel_prototype.preview_command.is_some(); @@ -217,8 +218,9 @@ impl Television { .send(PreviewRequest::Shutdown) .expect("Failed to send shutdown signal to previewer"); } - self.preview_handles = Self::setup_previewer(&channel_prototype); - self.channel = channel_prototype.into(); + self.preview_handles = Self::setup_previewer(channel_prototype); + self.channel = CableChannel::new(channel_prototype); + debug!("Changed channel to {:?}", channel_prototype); } pub fn find(&mut self, pattern: &str) { @@ -416,25 +418,26 @@ impl Television { // available previews let entry = selected_entry.as_ref().unwrap(); if let Ok(preview) = receiver.try_recv() { + let scroll = entry + .line_number + .unwrap_or(0) + .saturating_sub( + (self + .ui_state + .layout + .preview_window + .map_or(0, |w| w.height.saturating_sub(2)) // borders + / 2) + .into(), + ) + .saturating_add(3) // 3 lines above the center + .try_into() + // if the scroll doesn't fit in a u16, just scroll to the top + // this is a current limitation of ratatui + .unwrap_or(0); self.preview_state.update( preview, - // scroll to center the selected entry - entry - .line_number - .unwrap_or(0) - .saturating_sub( - (self - .ui_state - .layout - .preview_window - .map_or(0, |w| w.height) - / 2) - .into(), - ) - .try_into() - // if the scroll doesn't fit in a u16, just scroll to the top - // this is a current limitation of ratatui - .unwrap_or(0), + scroll, entry.line_number.and_then(|l| l.try_into().ok()), ); self.action_tx.send(Action::Render)?; @@ -547,7 +550,7 @@ impl Television { self.reset_picker_input(); self.remote_control.as_mut().unwrap().find(EMPTY_STRING); self.mode = Mode::Channel; - self.change_channel(new_channel); + self.change_channel(&new_channel); } } } diff --git a/tests/app.rs b/tests/app.rs index 35e8854..3076e8d 100644 --- a/tests/app.rs +++ b/tests/app.rs @@ -47,7 +47,7 @@ fn setup_app( false, config.application.tick_rate, ); - let mut app = App::new(chan, config, input, options, &Cable::default()); + let mut app = App::new(&chan, config, input, options, &Cable::default()); // retrieve the app's action channel handle in order to send a quit action let tx = app.action_tx.clone(); @@ -212,14 +212,8 @@ 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 prototype = ChannelPrototype::new( - "some_channel", - "echo file1.txt", - false, - None, - None, - None, - ); + let prototype = + ChannelPrototype::new("some_channel", "echo file1.txt", false, None); let (f, tx) = setup_app(Some(prototype), true, false); // tick a few times to get the results @@ -255,8 +249,6 @@ async fn test_app_does_not_exit_when_select_1_and_more_than_one_result() { "echo 'file1.txt\nfile2.txt'", false, None, - None, - None, ); let (f, tx) = setup_app(Some(prototype), true, false);