feat(cable): support for custom preview offset parsing for cable channels

This commit is contained in:
Alexandre Pasmantier 2025-05-15 01:49:57 +02:00
parent 39dd9efd5d
commit ef2842a395
9 changed files with 127 additions and 54 deletions

View File

@ -8,8 +8,9 @@ preview_command = "bat -n --color=always {}"
[[cable_channel]] [[cable_channel]]
name = "text" name = "text"
source_command = "rg . --no-heading --line-number" source_command = "rg . --no-heading --line-number"
preview_command = "bat -n --color=always {0} -H {1}" preview_command = "bat -n --color=always {0}"
preview_delimiter = ":" preview_delimiter = ":"
preview_offset = "{1}"
# Directories # Directories
[[cable_channel]] [[cable_channel]]

View File

@ -14,6 +14,8 @@ use crate::matcher::Matcher;
use crate::matcher::{config::Config, injector::Injector}; use crate::matcher::{config::Config, injector::Injector};
use crate::utils::command::shell_command; use crate::utils::command::shell_command;
use super::prototypes::format_prototype_string;
pub struct Channel { pub struct Channel {
pub name: String, pub name: String,
matcher: Matcher<String>, matcher: Matcher<String>,
@ -94,8 +96,27 @@ impl Channel {
pub fn get_result(&self, index: u32) -> Option<Entry> { pub fn get_result(&self, index: u32) -> Option<Entry> {
self.matcher.get_result(index).map(|item| { self.matcher.get_result(index).map(|item| {
let path = item.matched_string; let name = item.matched_string;
Entry::new(path) 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::<usize>().unwrap(),
);
}
}
Entry::new(name)
}) })
} }

View File

@ -2,12 +2,10 @@ use std::fmt::Display;
use crate::channels::{ use crate::channels::{
entry::Entry, entry::Entry,
prototypes::{ChannelPrototype, DEFAULT_DELIMITER}, prototypes::{
format_prototype_string, ChannelPrototype, DEFAULT_DELIMITER,
},
}; };
use lazy_regex::{regex, Lazy, Regex};
use tracing::debug;
static CMD_RE: &Lazy<Regex> = regex!(r"\{(\d+)\}");
#[derive(Debug, Clone, Eq, PartialEq, Hash, Default)] #[derive(Debug, Clone, Eq, PartialEq, Hash, Default)]
pub struct PreviewCommand { pub struct PreviewCommand {
@ -47,23 +45,7 @@ impl PreviewCommand {
/// assert_eq!(formatted_command, "something 'a:given:entry:to:preview' 'entry' 'a'"); /// assert_eq!(formatted_command, "something 'a:given:entry:to:preview' 'entry' 'a'");
/// ``` /// ```
pub fn format_with(&self, entry: &Entry) -> String { pub fn format_with(&self, entry: &Entry) -> String {
let parts = entry.name.split(&self.delimiter).collect::<Vec<&str>>(); format_prototype_string(&self.command, &entry.name, &self.delimiter)
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
} }
} }

View File

@ -1,3 +1,4 @@
use lazy_regex::{regex, Lazy, Regex};
use rustc_hash::FxHashMap; use rustc_hash::FxHashMap;
use std::{ use std::{
fmt::{self, Display, Formatter}, fmt::{self, Display, Formatter},
@ -126,6 +127,29 @@ impl Display for ChannelPrototype {
} }
} }
pub static CMD_RE: &Lazy<Regex> = regex!(r"\{(\d+)\}");
pub fn format_prototype_string(
template: &str,
source: &str,
delimiter: &str,
) -> String {
let parts = source.split(delimiter).collect::<Vec<&str>>();
let mut formatted_string =
template.replace("{}", format!("'{}'", source).as_str());
formatted_string = CMD_RE
.replace_all(&formatted_string, |caps: &regex::Captures| {
let index =
caps.get(1).unwrap().as_str().parse::<usize>().unwrap();
format!("'{}'", parts[index])
})
.to_string();
formatted_string
}
/// A neat `HashMap` of channel prototypes indexed by their name. /// A neat `HashMap` of channel prototypes indexed by their name.
/// ///
/// This is used to store cable channel prototypes throughout the application /// This is used to store cable channel prototypes throughout the application

View File

@ -25,6 +25,14 @@ pub struct Cli {
#[arg(short, long, value_name = "STRING", verbatim_doc_comment)] #[arg(short, long, value_name = "STRING", verbatim_doc_comment)]
pub preview: Option<String>, pub preview: Option<String>,
/// 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<String>,
/// Disable the preview panel entirely on startup. /// Disable the preview panel entirely on startup.
#[arg(long, default_value = "false", verbatim_doc_comment)] #[arg(long, default_value = "false", verbatim_doc_comment)]
pub no_preview: bool, pub no_preview: bool,

View File

@ -75,8 +75,7 @@ impl From<Cli> for PostProcessedCli {
let preview_command = cli.preview.map(|preview| PreviewCommand { let preview_command = cli.preview.map(|preview| PreviewCommand {
command: preview, command: preview,
delimiter: cli.delimiter.clone(), delimiter: cli.delimiter.clone(),
// TODO: add the --preview-offset option to the CLI offset_expr: cli.preview_offset.clone(),
offset_expr: None,
}); });
let mut channel: ChannelPrototype; let mut channel: ChannelPrototype;

View File

@ -9,7 +9,7 @@ pub struct PreviewState {
} }
const PREVIEW_MIN_SCROLL_LINES: u16 = 3; 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; const ANSI_CONTEXT_SIZE: usize = 500;
impl PreviewState { impl PreviewState {
@ -51,7 +51,7 @@ impl PreviewState {
scroll: u16, scroll: u16,
target_line: Option<u16>, target_line: Option<u16>,
) { ) {
if self.preview.title != preview.title { if self.preview.title != preview.title || self.scroll != scroll {
self.preview = preview; self.preview = preview;
self.scroll = scroll; self.scroll = scroll;
self.target_line = target_line; self.target_line = target_line;
@ -59,16 +59,31 @@ impl PreviewState {
} }
pub fn for_render_context(&self) -> Self { pub fn for_render_context(&self) -> Self {
let skipped_lines = let num_skipped_lines =
self.scroll.saturating_sub(ANSI_BEFORE_CONTEXT_SIZE); self.scroll.saturating_sub(ANSI_BEFORE_CONTEXT_SIZE);
let cropped_content = self let cropped_content = self
.preview .preview
.content .content
.lines() .lines()
.skip(skipped_lines as usize) .skip(num_skipped_lines as usize)
.take(ANSI_CONTEXT_SIZE) .take(ANSI_CONTEXT_SIZE)
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n"); .join("\n");
let target_line: Option<u16> =
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( PreviewState::new(
self.enabled, self.enabled,
Preview::new( Preview::new(
@ -77,8 +92,8 @@ impl PreviewState {
self.preview.icon, self.preview.icon,
self.preview.total_lines, self.preview.total_lines,
), ),
skipped_lines, num_skipped_lines,
self.target_line, target_line,
) )
} }
} }

View File

@ -32,13 +32,19 @@ pub fn draw_preview_content_block(
use_nerd_font_icons, use_nerd_font_icons,
)?; )?;
// render the preview content // 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); f.render_widget(rp, inner);
Ok(()) Ok(())
} }
pub fn build_preview_paragraph(content: &str) -> Paragraph<'_> { pub fn build_preview_paragraph(
preview_state: &PreviewState,
highlight_bg: Color,
) -> Paragraph<'_> {
let preview_block = let preview_block =
Block::default().style(Style::default()).padding(Padding { Block::default().style(Style::default()).padding(Padding {
top: 0, top: 0,
@ -47,14 +53,30 @@ pub fn build_preview_paragraph(content: &str) -> Paragraph<'_> {
left: 1, 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>( fn build_ansi_text_paragraph<'a>(
text: &'a str, text: &'a str,
preview_block: Block<'a>, preview_block: Block<'a>,
target_line: Option<u16>,
highlight_bg: Color,
) -> Paragraph<'a> { ) -> 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>( pub fn build_meta_preview_paragraph<'a>(

View File

@ -416,10 +416,7 @@ impl Television {
// available previews // available previews
let entry = selected_entry.as_ref().unwrap(); let entry = selected_entry.as_ref().unwrap();
if let Ok(preview) = receiver.try_recv() { if let Ok(preview) = receiver.try_recv() {
self.preview_state.update( let scroll = entry
preview,
// scroll to center the selected entry
entry
.line_number .line_number
.unwrap_or(0) .unwrap_or(0)
.saturating_sub( .saturating_sub(
@ -427,14 +424,18 @@ impl Television {
.ui_state .ui_state
.layout .layout
.preview_window .preview_window
.map_or(0, |w| w.height) .map_or(0, |w| w.height.saturating_sub(2)) // borders
/ 2) / 2)
.into(), .into(),
) )
.saturating_add(3) // 3 lines above the center
.try_into() .try_into()
// if the scroll doesn't fit in a u16, just scroll to the top // if the scroll doesn't fit in a u16, just scroll to the top
// this is a current limitation of ratatui // this is a current limitation of ratatui
.unwrap_or(0), .unwrap_or(0);
self.preview_state.update(
preview,
scroll,
entry.line_number.and_then(|l| l.try_into().ok()), entry.line_number.and_then(|l| l.try_into().ok()),
); );
self.action_tx.send(Action::Render)?; self.action_tx.send(Action::Render)?;