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]]
name = "text"
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_offset = "{1}"
# Directories
[[cable_channel]]

View File

@ -14,6 +14,8 @@ 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<String>,
@ -94,8 +96,27 @@ impl Channel {
pub fn get_result(&self, index: u32) -> Option<Entry> {
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::<usize>().unwrap(),
);
}
}
Entry::new(name)
})
}

View File

@ -2,12 +2,10 @@ use std::fmt::Display;
use crate::channels::{
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)]
pub struct PreviewCommand {
@ -47,23 +45,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::<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
format_prototype_string(&self.command, &entry.name, &self.delimiter)
}
}

View File

@ -1,3 +1,4 @@
use lazy_regex::{regex, Lazy, Regex};
use rustc_hash::FxHashMap;
use std::{
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.
///
/// 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)]
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.
#[arg(long, default_value = "false", verbatim_doc_comment)]
pub no_preview: bool,

View File

@ -75,8 +75,7 @@ impl From<Cli> 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;

View File

@ -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<u16>,
) {
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::<Vec<_>>()
.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(
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,
)
}
}

View File

@ -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<u16>,
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>(

View File

@ -416,25 +416,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)?;