feat: ansi-styled results with --ansi or corresponding channel option (#655)

# Examples:
## ripgrep
```toml
[metadata]
name = "text"
description = "A channel to find and select text from files"
requirements = ["rg", "bat"]

[source]
command = "rg . --no-heading --line-number --colors 'match:fg:white' --colors 'path:fg:blue' --color=always"
ansi = true
output = "{strip_ansi|split:\\::..2}"

[preview]
command = "bat -n --color=always '{strip_ansi|split:\\::0}'"
env = { BAT_THEME = "ansi" }
offset = '{strip_ansi|split:\::1}'

[ui]
preview_panel = { header = '{strip_ansi|split:\::..2}' }
```

<img width="2880" height="1620" alt="Screenshot From 2025-07-19
20-56-53"
src="https://github.com/user-attachments/assets/67055fd1-d03e-4c4b-ad82-dbc3ba8c80d4"
/>

## git-log

```toml
[metadata]
name = "git-log"
description = "A channel to select from git log entries"
requirements = ["git", "delta"]

[source]
command = "git log --graph --pretty=format:'%C(yellow)%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset' --abbrev-commit --color=always"
output = "{strip_ansi|split: :1}"
ansi = true

[preview]
command = "git show -p --stat --pretty=fuller --color=always '{strip_ansi|split: :1}' | delta"
```

<img width="2880" height="1620" alt="Screenshot From 2025-07-19
19-17-15"
src="https://github.com/user-attachments/assets/be97ff35-8a35-4d26-ad14-afd5ece3fed6"
/>
This commit is contained in:
Alex Pasmantier 2025-07-19 22:52:12 +02:00 committed by GitHub
parent c789802d86
commit 098f3f4fe4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 591 additions and 233 deletions

View File

@ -31,352 +31,387 @@ pub fn draw_results_list(c: &mut Criterion) {
Entry {
display: None,
raw: "typeshed/LICENSE".to_string(),
name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
icon: Some(FileIcon {
icon: '\u{f016}',
color: "#7e8e91",
}),
line_number: None,
ansi: false,
},
Entry {
display: None,
raw: "typeshed/README.md".to_string(),
name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
icon: Some(FileIcon {
icon: '\u{f48a}',
color: "#dddddd",
}),
line_number: None,
ansi: false,
},
Entry {
display: None,
raw: "typeshed/stdlib/re.pyi".to_string(),
name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
icon: Some(FileIcon {
icon: '\u{e606}',
color: "#ffbc03",
}),
line_number: None,
ansi: false,
},
Entry {
display: None,
raw: "typeshed/stdlib/io.pyi".to_string(),
name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
icon: Some(FileIcon {
icon: '\u{e606}',
color: "#ffbc03",
}),
line_number: None,
ansi: false,
},
Entry {
display: None,
raw: "typeshed/stdlib/gc.pyi".to_string(),
name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
icon: Some(FileIcon {
icon: '\u{e606}',
color: "#ffbc03",
}),
line_number: None,
ansi: false,
},
Entry {
display: None,
raw: "typeshed/stdlib/uu.pyi".to_string(),
name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
icon: Some(FileIcon {
icon: '\u{e606}',
color: "#ffbc03",
}),
line_number: None,
ansi: false,
},
Entry {
display: None,
raw: "typeshed/stdlib/nt.pyi".to_string(),
name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
icon: Some(FileIcon {
icon: '\u{e606}',
color: "#ffbc03",
}),
line_number: None,
ansi: false,
},
Entry {
display: None,
raw: "typeshed/stdlib/dis.pyi".to_string(),
name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
icon: Some(FileIcon {
icon: '\u{e606}',
color: "#ffbc03",
}),
line_number: None,
ansi: false,
},
Entry {
display: None,
raw: "typeshed/stdlib/imp.pyi".to_string(),
name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
icon: Some(FileIcon {
icon: '\u{e606}',
color: "#ffbc03",
}),
line_number: None,
ansi: false,
},
Entry {
display: None,
raw: "typeshed/stdlib/bdb.pyi".to_string(),
name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
icon: Some(FileIcon {
icon: '\u{e606}',
color: "#ffbc03",
}),
line_number: None,
ansi: false,
},
Entry {
display: None,
raw: "typeshed/stdlib/abc.pyi".to_string(),
name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
icon: Some(FileIcon {
icon: '\u{e606}',
color: "#ffbc03",
}),
line_number: None,
ansi: false,
},
Entry {
display: None,
raw: "typeshed/stdlib/cgi.pyi".to_string(),
name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
icon: Some(FileIcon {
icon: '\u{e606}',
color: "#ffbc03",
}),
line_number: None,
ansi: false,
},
Entry {
display: None,
raw: "typeshed/stdlib/bz2.pyi".to_string(),
name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
icon: Some(FileIcon {
icon: '\u{e606}',
color: "#ffbc03",
}),
line_number: None,
ansi: false,
},
Entry {
display: None,
raw: "typeshed/stdlib/grp.pyi".to_string(),
name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
icon: Some(FileIcon {
icon: '\u{e606}',
color: "#ffbc03",
}),
line_number: None,
ansi: false,
},
Entry {
display: None,
raw: "typeshed/stdlib/ast.pyi".to_string(),
name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
icon: Some(FileIcon {
icon: '\u{e606}',
color: "#ffbc03",
}),
line_number: None,
ansi: false,
},
Entry {
display: None,
raw: "typeshed/stdlib/csv.pyi".to_string(),
name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
icon: Some(FileIcon {
icon: '\u{e606}',
color: "#ffbc03",
}),
line_number: None,
ansi: false,
},
Entry {
display: None,
raw: "typeshed/stdlib/pdb.pyi".to_string(),
name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
icon: Some(FileIcon {
icon: '\u{e606}',
color: "#ffbc03",
}),
line_number: None,
ansi: false,
},
Entry {
display: None,
raw: "typeshed/stdlib/pwd.pyi".to_string(),
name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
icon: Some(FileIcon {
icon: '\u{e606}',
color: "#ffbc03",
}),
line_number: None,
ansi: false,
},
Entry {
display: None,
raw: "typeshed/stdlib/ssl.pyi".to_string(),
name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
icon: Some(FileIcon {
icon: '\u{e606}',
color: "#ffbc03",
}),
line_number: None,
ansi: false,
},
Entry {
display: None,
raw: "typeshed/stdlib/tty.pyi".to_string(),
name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
icon: Some(FileIcon {
icon: '\u{e606}',
color: "#ffbc03",
}),
line_number: None,
ansi: false,
},
Entry {
display: None,
raw: "typeshed/stdlib/nis.pyi".to_string(),
name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
icon: Some(FileIcon {
icon: '\u{e606}',
color: "#ffbc03",
}),
line_number: None,
ansi: false,
},
Entry {
display: None,
raw: "typeshed/stdlib/pty.pyi".to_string(),
name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
icon: Some(FileIcon {
icon: '\u{e606}',
color: "#ffbc03",
}),
line_number: None,
ansi: false,
},
Entry {
display: None,
raw: "typeshed/stdlib/cmd.pyi".to_string(),
name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
icon: Some(FileIcon {
icon: '\u{e606}',
color: "#ffbc03",
}),
line_number: None,
ansi: false,
},
Entry {
display: None,
raw: "typeshed/tests/utils.py".to_string(),
name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
icon: Some(FileIcon {
icon: '\u{e606}',
color: "#ffbc03",
}),
line_number: None,
ansi: false,
},
Entry {
display: None,
raw: "typeshed/pyproject.toml".to_string(),
name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
icon: Some(FileIcon {
icon: '\u{e6b2}',
color: "#9c4221",
}),
line_number: None,
ansi: false,
},
Entry {
display: None,
raw: "typeshed/MAINTAINERS.md".to_string(),
name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
icon: Some(FileIcon {
icon: '\u{f48a}',
color: "#dddddd",
}),
line_number: None,
ansi: false,
},
Entry {
display: None,
raw: "typeshed/stdlib/enum.pyi".to_string(),
name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
icon: Some(FileIcon {
icon: '\u{e606}',
color: "#ffbc03",
}),
line_number: None,
ansi: false,
},
Entry {
display: None,
raw: "typeshed/stdlib/hmac.pyi".to_string(),
name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
icon: Some(FileIcon {
icon: '\u{e606}',
color: "#ffbc03",
}),
line_number: None,
ansi: false,
},
Entry {
display: None,
raw: "typeshed/stdlib/uuid.pyi".to_string(),
name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
icon: Some(FileIcon {
icon: '\u{e606}',
color: "#ffbc03",
}),
line_number: None,
ansi: false,
},
Entry {
display: None,
raw: "typeshed/stdlib/glob.pyi".to_string(),
name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
icon: Some(FileIcon {
icon: '\u{e606}',
color: "#ffbc03",
}),
line_number: None,
ansi: false,
},
Entry {
display: None,
raw: "typeshed/stdlib/_ast.pyi".to_string(),
name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
icon: Some(FileIcon {
icon: '\u{e606}',
color: "#ffbc03",
}),
line_number: None,
ansi: false,
},
Entry {
display: None,
raw: "typeshed/stdlib/_csv.pyi".to_string(),
name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
icon: Some(FileIcon {
icon: '\u{e606}',
color: "#ffbc03",
}),
line_number: None,
ansi: false,
},
Entry {
display: None,
raw: "typeshed/stdlib/code.pyi".to_string(),
name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
icon: Some(FileIcon {
icon: '\u{e606}',
color: "#ffbc03",
}),
line_number: None,
ansi: false,
},
Entry {
display: None,
raw: "typeshed/stdlib/spwd.pyi".to_string(),
name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
icon: Some(FileIcon {
icon: '\u{e606}',
color: "#ffbc03",
}),
line_number: None,
ansi: false,
},
Entry {
display: None,
raw: "typeshed/stdlib/_msi.pyi".to_string(),
name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
icon: Some(FileIcon {
icon: '\u{e606}',
color: "#ffbc03",
}),
line_number: None,
ansi: false,
},
Entry {
display: None,
@ -387,7 +422,8 @@ pub fn draw_results_list(c: &mut Criterion) {
color: "#ffbc03",
}),
line_number: None,
name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
ansi: false,
},
];

View File

@ -4,7 +4,7 @@
.SH NAME
television \- Cross\-platform, fast and extensible general purpose fuzzy finder TUI.
.SH SYNOPSIS
\fBtelevision\fR [\fB\-\-preview\-offset\fR] [\fB\-\-no\-preview\fR] [\fB\-\-hide\-preview\fR] [\fB\-\-show\-preview\fR] [\fB\-\-no\-status\-bar\fR] [\fB\-\-hide\-status\-bar\fR] [\fB\-\-show\-status\-bar\fR] [\fB\-t\fR|\fB\-\-tick\-rate\fR] [\fB\-\-watch\fR] [\fB\-k\fR|\fB\-\-keybindings\fR] [\fB\-i\fR|\fB\-\-input\fR] [\fB\-\-input\-header\fR] [\fB\-\-preview\-header\fR] [\fB\-\-preview\-footer\fR] [\fB\-\-source\-command\fR] [\fB\-\-source\-display\fR] [\fB\-\-source\-output\fR] [\fB\-\-source\-delimiter\fR] [\fB\-p\fR|\fB\-\-preview\-command\fR] [\fB\-\-layout\fR] [\fB\-\-autocomplete\-prompt\fR] [\fB\-\-exact\fR] [\fB\-\-select\-1\fR] [\fB\-\-take\-1\fR] [\fB\-\-take\-1\-fast\fR] [\fB\-\-no\-remote\fR] [\fB\-\-hide\-remote\fR] [\fB\-\-show\-remote\fR] [\fB\-\-no\-help\-panel\fR] [\fB\-\-hide\-help\-panel\fR] [\fB\-\-show\-help\-panel\fR] [\fB\-\-ui\-scale\fR] [\fB\-\-preview\-size\fR] [\fB\-\-config\-file\fR] [\fB\-\-cable\-dir\fR] [\fB\-\-global\-history\fR] [\fB\-\-height\fR] [\fB\-\-width\fR] [\fB\-\-inline\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] [\fICHANNEL\fR] [\fIPATH\fR] [\fIsubcommands\fR]
\fBtelevision\fR [\fB\-\-preview\-offset\fR] [\fB\-\-no\-preview\fR] [\fB\-\-hide\-preview\fR] [\fB\-\-show\-preview\fR] [\fB\-\-no\-status\-bar\fR] [\fB\-\-hide\-status\-bar\fR] [\fB\-\-show\-status\-bar\fR] [\fB\-t\fR|\fB\-\-tick\-rate\fR] [\fB\-\-watch\fR] [\fB\-k\fR|\fB\-\-keybindings\fR] [\fB\-i\fR|\fB\-\-input\fR] [\fB\-\-input\-header\fR] [\fB\-\-input\-prompt\fR] [\fB\-\-preview\-header\fR] [\fB\-\-preview\-footer\fR] [\fB\-s\fR|\fB\-\-source\-command\fR] [\fB\-\-ansi\fR] [\fB\-\-source\-display\fR] [\fB\-\-source\-output\fR] [\fB\-\-source\-entry\-delimiter\fR] [\fB\-p\fR|\fB\-\-preview\-command\fR] [\fB\-\-layout\fR] [\fB\-\-autocomplete\-prompt\fR] [\fB\-\-exact\fR] [\fB\-\-select\-1\fR] [\fB\-\-take\-1\fR] [\fB\-\-take\-1\-fast\fR] [\fB\-\-no\-remote\fR] [\fB\-\-hide\-remote\fR] [\fB\-\-show\-remote\fR] [\fB\-\-no\-help\-panel\fR] [\fB\-\-hide\-help\-panel\fR] [\fB\-\-show\-help\-panel\fR] [\fB\-\-ui\-scale\fR] [\fB\-\-preview\-size\fR] [\fB\-\-config\-file\fR] [\fB\-\-cable\-dir\fR] [\fB\-\-global\-history\fR] [\fB\-\-height\fR] [\fB\-\-width\fR] [\fB\-\-inline\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] [\fICHANNEL\fR] [\fIPATH\fR] [\fIsubcommands\fR]
.SH DESCRIPTION
Cross\-platform, fast and extensible general purpose fuzzy finder TUI.
.SH OPTIONS
@ -102,6 +102,15 @@ The given value is parsed as a `MultiTemplate`. It is evaluated against
the current channel name and the resulting text is shown as the input
field title. Defaults to the current channel name when omitted.
.TP
\fB\-\-input\-prompt\fR=\fISTRING\fR
Input prompt string
When a channel is specified: This overrides the prompt defined in the channel prototype.
When no channel is specified: Sets the input prompt for the ad\-hoc channel.
The given value is used as the prompt string shown before the input field.
Defaults to ">" when omitted.
.TP
\fB\-\-preview\-header\fR=\fISTRING\fR
Preview header template
@ -120,7 +129,7 @@ When no channel is specified: This flag requires \-\-preview\-command to be set.
The given value is parsed as a `MultiTemplate`. It is evaluated for every
entry and its result is displayed below the preview panel.
.TP
\fB\-\-source\-command\fR=\fISTRING\fR
\fB\-s\fR, \fB\-\-source\-command\fR=\fISTRING\fR
Source command to use for the current channel.
When a channel is specified: This overrides the command defined in the channel prototype.
@ -128,6 +137,15 @@ When no channel is specified: This creates an ad\-hoc channel with the given com
Example: `find . \-name \*(Aq*.rs\*(Aq`
.TP
\fB\-\-ansi\fR
Whether tv should extract and parse ANSI style codes from the source command output.
This is useful when the source command outputs colored text or other ANSI styles and you
want `tv` to preserve them in the UI. It does come with a slight performance cost but
which should go mostly unnoticed for typical human interaction workloads.
Example: `tv \-\-source\-command="echo \-e \*(Aq\\x1b[31mRed\\x1b[0m\*(Aq" \-\-ansi`
.TP
\fB\-\-source\-display\fR=\fISTRING\fR
Source display template to use for the current channel.
@ -146,7 +164,7 @@ When no channel is specified: This flag requires \-\-source\-command to be set.
The template is used to format the final output when an entry is selected.
Example: "{}" (output the full entry)
.TP
\fB\-\-source\-delimiter\fR=\fISTRING\fR
\fB\-\-source\-entry\-delimiter\fR=\fISTRING\fR
The delimiter byte to use for splitting the source\*(Aqs command output into entries.
This can be useful when the source command outputs multiline entries and you want to

View File

@ -1,7 +1,7 @@
use crate::{
channels::{
entry::Entry,
prototypes::{ChannelPrototype, SourceSpec},
prototypes::{ChannelPrototype, SourceSpec, Template},
},
matcher::{Matcher, config::Config, injector::Injector},
utils::command::shell_command,
@ -13,7 +13,7 @@ use std::process::Stdio;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::time::Duration;
use tracing::debug;
use tracing::{debug, trace};
const RELOAD_RENDERING_DELAY: Duration = Duration::from_millis(200);
@ -99,7 +99,8 @@ impl Channel {
for item in results {
let entry = Entry::new(item.inner)
.with_display(item.matched_string)
.with_match_indices(&item.match_indices);
.with_match_indices(&item.match_indices)
.ansi(self.prototype.source.ansi);
entries.push(entry);
}
@ -204,6 +205,8 @@ async fn load_candidates(
.map(|d| *d as u8)
.unwrap_or(b'\n');
let strip_ansi = Template::parse("{strip_ansi}").unwrap();
loop {
buf.clear();
let n = reader.read_until(delimiter, &mut buf).unwrap();
@ -221,10 +224,17 @@ async fn load_candidates(
}
if let Ok(l) = std::str::from_utf8(&buf) {
debug!("Read line: {}", l);
trace!("Read line: {}", l);
if !l.trim().is_empty() {
let () = injector.push(l.to_string(), |e, cols| {
if let Some(display) = &source.display {
if source.ansi {
cols[0] = strip_ansi.format(e).unwrap_or_else(|_| {
panic!(
"Failed to strip ANSI from entry '{}'",
e
);
}).into();
} else if let Some(display) = &source.display {
let formatted = display.format(e).unwrap_or_else(|_| {
panic!(
"Failed to format display expression '{}' with entry '{}'",

View File

@ -11,12 +11,14 @@ pub struct Entry {
pub raw: String,
/// The actual entry string that will be displayed in the UI.
pub display: Option<String>,
/// The optional ranges for matching characters in the name.
pub name_match_ranges: Option<Vec<(u32, u32)>>,
/// The optional ranges for matching characters (based on `self.display`).
pub match_ranges: Option<Vec<(u32, u32)>>,
/// The optional icon associated with the entry.
pub icon: Option<FileIcon>,
/// The optional line number associated with the entry.
pub line_number: Option<usize>,
/// Whether the entry contains ANSI escape sequences.
pub ansi: bool,
}
impl Hash for Entry {
@ -96,9 +98,10 @@ impl Entry {
Self {
raw,
display: None,
name_match_ranges: None,
match_ranges: None,
icon: None,
line_number: None,
ansi: false,
}
}
@ -108,7 +111,7 @@ impl Entry {
}
pub fn with_match_indices(mut self, indices: &[u32]) -> Self {
self.name_match_ranges = Some(into_ranges(indices));
self.match_ranges = Some(into_ranges(indices));
self
}
@ -137,9 +140,19 @@ impl Entry {
}
self.raw.clone()
}
/// Sets whether the entry contains ANSI escape sequences.
pub fn ansi(mut self, ansi: bool) -> Self {
self.ansi = ansi;
self
}
}
impl ResultItem for Entry {
fn raw(&self) -> &str {
&self.raw
}
fn icon(&self) -> Option<&devicons::FileIcon> {
self.icon.as_ref()
}
@ -149,12 +162,16 @@ impl ResultItem for Entry {
}
fn match_ranges(&self) -> Option<&[(u32, u32)]> {
self.name_match_ranges.as_deref()
self.match_ranges.as_deref()
}
fn shortcut(&self) -> Option<&Binding> {
None
}
fn ansi(&self) -> bool {
self.ansi
}
}
#[cfg(test)]
@ -190,9 +207,10 @@ mod tests {
let entry = Entry {
raw: "test name with spaces".to_string(),
display: None,
name_match_ranges: None,
match_ranges: None,
icon: None,
line_number: None,
ansi: false,
};
assert_eq!(
entry.output(&Some(Template::parse("{}").unwrap())),

View File

@ -217,6 +217,7 @@ impl ChannelPrototype {
env: FxHashMap::default(),
},
entry_delimiter: None,
ansi: false,
display: None,
output: None,
},
@ -246,6 +247,7 @@ impl ChannelPrototype {
interactive: false,
env: FxHashMap::default(),
},
ansi: false,
entry_delimiter,
display: None,
output: None,
@ -317,6 +319,8 @@ pub struct SourceSpec {
#[serde(deserialize_with = "deserialize_entry_delimiter", default)]
pub entry_delimiter: Option<char>,
#[serde(default)]
pub ansi: bool,
#[serde(default)]
pub display: Option<Template>,
#[serde(default)]
pub output: Option<Template>,

View File

@ -50,6 +50,10 @@ impl CableEntry {
}
impl ResultItem for CableEntry {
fn raw(&self) -> &str {
&self.channel_name
}
fn icon(&self) -> Option<&devicons::FileIcon> {
// Remote control entries always share the same popcorn icon
Some(&CABLE_ICON)

View File

@ -138,7 +138,7 @@ pub struct Cli {
/// The given value is parsed as a `MultiTemplate`. It is evaluated against
/// the current channel name and the resulting text is shown as the input
/// field title. Defaults to the current channel name when omitted.
#[arg(long = "input-header", value_name = "STRING", verbatim_doc_comment)]
#[arg(long, value_name = "STRING", verbatim_doc_comment)]
pub input_header: Option<String>,
/// Input prompt string
@ -148,7 +148,7 @@ pub struct Cli {
///
/// The given value is used as the prompt string shown before the input field.
/// Defaults to ">" when omitted.
#[arg(long = "input-prompt", value_name = "STRING", verbatim_doc_comment)]
#[arg(long, value_name = "STRING", verbatim_doc_comment)]
pub input_prompt: Option<String>,
/// Preview header template
@ -159,7 +159,7 @@ pub struct Cli {
/// The given value is parsed as a `MultiTemplate`. It is evaluated for every
/// entry and its result is displayed above the preview panel.
#[arg(
long = "preview-header",
long,
value_name = "STRING",
verbatim_doc_comment,
conflicts_with = "no_preview"
@ -174,7 +174,7 @@ pub struct Cli {
/// The given value is parsed as a `MultiTemplate`. It is evaluated for every
/// entry and its result is displayed below the preview panel.
#[arg(
long = "preview-footer",
long,
value_name = "STRING",
verbatim_doc_comment,
conflicts_with = "no_preview"
@ -187,13 +187,19 @@ pub struct Cli {
/// When no channel is specified: This creates an ad-hoc channel with the given command.
///
/// Example: `find . -name '*.rs'`
#[arg(
long = "source-command",
value_name = "STRING",
verbatim_doc_comment
)]
#[arg(short, long, value_name = "STRING", verbatim_doc_comment)]
pub source_command: Option<String>,
/// Whether tv should extract and parse ANSI style codes from the source command output.
///
/// This is useful when the source command outputs colored text or other ANSI styles and you
/// want `tv` to preserve them in the UI. It does come with a slight performance cost but
/// which should go mostly unnoticed for typical human interaction workloads.
///
/// Example: `tv --source-command="echo -e '\x1b[31mRed\x1b[0m'" --ansi`
#[arg(long, default_value = "false", verbatim_doc_comment)]
pub ansi: bool,
/// Source display template to use for the current channel.
///
/// When a channel is specified: This overrides the display template defined in the channel prototype.
@ -201,11 +207,7 @@ pub struct Cli {
///
/// The template is used to format each entry in the results list.
/// Example: `{split:/:-1}` (show only the last path segment)
#[arg(
long = "source-display",
value_name = "STRING",
verbatim_doc_comment
)]
#[arg(long, value_name = "STRING", verbatim_doc_comment)]
pub source_display: Option<String>,
/// Source output template to use for the current channel.
@ -215,22 +217,14 @@ pub struct Cli {
///
/// The template is used to format the final output when an entry is selected.
/// Example: "{}" (output the full entry)
#[arg(
long = "source-output",
value_name = "STRING",
verbatim_doc_comment
)]
#[arg(long, value_name = "STRING", verbatim_doc_comment)]
pub source_output: Option<String>,
/// The delimiter byte to use for splitting the source's command output into entries.
///
/// This can be useful when the source command outputs multiline entries and you want to
/// rely on another delimiter to split the entries such a null byte or a custom character.
#[arg(
long = "source-delimiter",
value_name = "STRING",
verbatim_doc_comment
)]
#[arg(long, value_name = "STRING", verbatim_doc_comment)]
pub source_entry_delimiter: Option<String>,
/// Preview command to use for the current channel.
@ -246,7 +240,7 @@ pub struct Cli {
/// the first two fields to the command.
#[arg(
short,
long = "preview-command",
long,
value_name = "STRING",
verbatim_doc_comment,
conflicts_with = "no_preview"
@ -259,7 +253,7 @@ pub struct Cli {
/// When no channel is specified: Sets the layout orientation for the ad-hoc channel.
///
/// Options are "landscape" or "portrait".
#[arg(long = "layout", value_enum, verbatim_doc_comment)]
#[arg(long, value_enum, verbatim_doc_comment)]
pub layout: Option<LayoutOrientation>,
/// The working directory to start the application in.

View File

@ -51,6 +51,7 @@ pub struct PostProcessedCli {
pub source_entry_delimiter: Option<char>,
pub working_directory: Option<PathBuf>,
pub autocomplete_prompt: Option<String>,
pub ansi: bool,
// Preview configuration
pub preview_command_override: Option<Template>,
@ -122,6 +123,7 @@ impl Default for PostProcessedCli {
source_entry_delimiter: None,
working_directory: None,
autocomplete_prompt: None,
ansi: false,
// Preview configuration
preview_command_override: None,
@ -313,6 +315,7 @@ pub fn post_process(cli: Cli, readable_stdin: bool) -> PostProcessedCli {
source_entry_delimiter,
working_directory,
autocomplete_prompt: cli.autocomplete_prompt,
ansi: cli.ansi,
// Preview configuration
preview_command_override,

View File

@ -338,6 +338,9 @@ fn apply_source_overrides(
if let Some(source_output) = &args.source_output_override {
prototype.source.output = Some(source_output.clone());
}
if args.ansi {
prototype.source.ansi = true;
}
}
/// Applies preview-related CLI overrides to the channel prototype

View File

@ -263,7 +263,13 @@ pub fn try_preview(
let total_lines = u16::try_from(text.lines.len()).unwrap_or(0);
Preview::new(&entry.raw, text, None, total_lines, String::new())
Preview::new(
entry.display(),
text,
None,
total_lines,
String::new(),
)
} else {
let mut text = child
.stderr
@ -284,7 +290,13 @@ pub fn try_preview(
let total_lines = u16::try_from(text.lines.len()).unwrap_or(0);
Preview::new(&entry.raw, text, None, total_lines, String::new())
Preview::new(
entry.display(),
text,
None,
total_lines,
String::new(),
)
}
};
results_handle

View File

@ -6,9 +6,10 @@ use crate::{
},
utils::{
indices::truncate_highlighted_string,
strings::make_matched_string_printable,
strings::make_result_item_printable,
},
};
use ansi_to_tui::IntoText;
use devicons::FileIcon;
use ratatui::{
prelude::{Color, Line, Span, Style},
@ -20,6 +21,9 @@ use unicode_width::UnicodeWidthStr;
/// Trait implemented by any item that can be displayed in the results or remote-control list.
pub trait ResultItem {
/// Returns the raw string representation of the item.
fn raw(&self) -> &str;
/// Returns an optional icon to display in front of the item.
fn icon(&self) -> Option<&FileIcon> {
None
@ -29,6 +33,8 @@ pub trait ResultItem {
fn display(&self) -> &str;
/// Highlight match ranges (char based indices) within `display()`.
///
/// These are contiguous ranges of character indices that should be highlighted.
fn match_ranges(&self) -> Option<&[(u32, u32)]> {
None
}
@ -37,13 +43,16 @@ pub trait ResultItem {
fn shortcut(&self) -> Option<&Binding> {
None
}
/// Whether the item uses ANSI escape codes for styling.
fn ansi(&self) -> bool {
false
}
}
/// Build a single `Line` for a [`ResultItem`].
#[allow(clippy::too_many_arguments)]
#[allow(clippy::cast_possible_truncation)]
// TODO: pass the right colors directly as arguments and make the
// calling function responsible for the colors used for each line.
pub fn build_result_line<'a, T: ResultItem + ?Sized>(
item: &'a T,
use_icons: bool,
@ -82,7 +91,7 @@ pub fn build_result_line<'a, T: ResultItem + ?Sized>(
})
.unwrap_or(0);
let name_max_width = area_width
let item_max_width = area_width
.saturating_sub(2) // pointer + space (kept for caller)
.saturating_sub(2) // borders
.saturating_sub(2 * u16::from(use_icons))
@ -100,66 +109,20 @@ pub fn build_result_line<'a, T: ResultItem + ?Sized>(
}
}
let (mut entry_name, mut match_ranges) =
make_matched_string_printable(item.display(), item.match_ranges());
// Truncate if too long.
if UnicodeWidthStr::width(entry_name.as_str()) > name_max_width as usize {
let (name, ranges) = truncate_highlighted_string(
&entry_name,
&match_ranges,
name_max_width,
);
entry_name = name;
match_ranges = ranges;
}
// PERF: Early return for empty match ranges - common case optimization
if match_ranges.is_empty() {
spans.push(Span::styled(entry_name, Style::default().fg(result_fg)));
if item.ansi() {
spans.extend(build_entry_spans_ansi(
item,
item_max_width,
result_fg,
match_fg,
));
} else {
// PERF: Collect chars once to avoid repeated Unicode parsing
let chars: Vec<char> = entry_name.chars().collect();
let name_len = chars.len();
let mut last_end = 0;
for (start, end) in
match_ranges.iter().map(|(s, e)| (*s as usize, *e as usize))
{
// Add unhighlighted text before match
if start > last_end && start <= name_len {
let text: String =
chars[last_end..start.min(name_len)].iter().collect();
if !text.is_empty() {
spans.push(Span::styled(
text,
Style::default().fg(result_fg),
));
}
}
// Add highlighted match
if end > start && start < name_len {
let text: String =
chars[start..end.min(name_len)].iter().collect();
if !text.is_empty() {
spans.push(Span::styled(
text,
Style::default().fg(match_fg),
));
}
}
last_end = end;
}
// Add remaining unhighlighted text
if last_end < name_len {
let text: String = chars[last_end..].iter().collect();
if !text.is_empty() {
spans.push(Span::styled(text, Style::default().fg(result_fg)));
}
}
spans.extend(build_entry_spans(
item,
item_max_width,
result_fg,
match_fg,
));
}
// Show shortcut if present.
@ -187,6 +150,169 @@ pub fn build_result_line<'a, T: ResultItem + ?Sized>(
Line::from(spans)
}
fn build_entry_spans<T: ResultItem + ?Sized>(
item: &T,
max_width: u16,
result_fg: Color,
match_fg: Color,
) -> Vec<Span> {
let mut spans = Vec::with_capacity(16);
let (mut entry_name, mut match_ranges) = make_result_item_printable(item);
// Truncate if too long.
if UnicodeWidthStr::width(entry_name.as_str()) > max_width as usize {
let (name, ranges) =
truncate_highlighted_string(&entry_name, &match_ranges, max_width);
entry_name = name;
match_ranges = ranges;
}
if match_ranges.is_empty() {
spans.push(Span::styled(entry_name, Style::default().fg(result_fg)));
return spans;
}
let chars: Vec<char> = entry_name.chars().collect();
let mut idx = 0;
for &(start, end) in &match_ranges {
let start = start as usize;
let end = end as usize;
if idx < start {
let text: String = chars[idx..start].iter().collect();
if !text.is_empty() {
spans.push(Span::styled(text, Style::default().fg(result_fg)));
}
}
if start < end {
let text: String = chars[start..end].iter().collect();
if !text.is_empty() {
spans.push(Span::styled(text, Style::default().fg(match_fg)));
}
}
idx = end;
}
if idx < chars.len() {
let text: String = chars[idx..].iter().collect();
if !text.is_empty() {
spans.push(Span::styled(text, Style::default().fg(result_fg)));
}
}
spans
}
/// Builds a vector of [`Span`]s for a [`ResultItem`] that may contain ANSI escape codes.
///
/// # Algorithm
///
/// - 1/ The function parses the raw string of the item into styled spans using `ansi_to_tui`.
///
/// - 2/ If the parsed result contains only a single span with the default style (i.e., no ANSI codes),
/// it falls back to the simpler `build_entry_spans` function.
///
/// - 3/ Otherwise, it iterates over the parsed spans and overlays the match highlight ranges onto them.
/// - It tracks the current character position across all spans.
/// - For each span, it walks through its characters, checking if the current position falls within
/// any of the match highlight ranges.
/// - If a highlight range is encountered, it splits the span into sub-spans:
/// - Unhighlighted text before the match uses the original style.
/// - Highlighted text within the match uses the original style but overrides the foreground color with `match_fg`.
/// - Remaining text after the match continues with the original style.
/// - The algorithm advances through both the spans and the highlight ranges, ensuring that highlights
/// are applied correctly even if they cross span boundaries.
/// - 4/ The result is a vector of spans that preserves the original ANSI styling, but overlays match highlights
/// as specified by the input ranges.
///
/// # Parameters
/// - `item`: The result item to render.
/// - `max_width`: The maximum width for the rendered line (currently not used for truncation in this function).
/// - `result_fg`: The default foreground color for non-highlighted text.
/// - `match_fg`: The foreground color to use for highlighted (matched) text.
///
/// # Returns
/// A vector of [`Span`]s, each with appropriate styling and highlighting.
///
/// # Notes
/// - This function is designed to work with items that use ANSI escape codes for styling.
/// - It ensures that match highlights do not disrupt the underlying ANSI styles, except for the foreground color.
/// - If no ANSI codes are present, it delegates to the simpler span builder for efficiency.
fn build_entry_spans_ansi<T: ResultItem + ?Sized>(
item: &T,
max_width: u16,
result_fg: Color,
match_fg: Color,
) -> Vec<Span> {
let text = item.raw();
let match_ranges = item.match_ranges().unwrap_or(&[]);
let parsed = text.into_text().unwrap();
let spans = &parsed.lines[0].spans;
// If there are no ANSI codes, fall back to the simple span builder
if spans.len() == 1 && spans[0].style == Style::default() {
return build_entry_spans(item, max_width, result_fg, match_fg);
}
// hypothesis: ~ 2 to 3 highlighted clusters + in the worst case scenario
// each cluster splits its containing span into 3 parts -> + 6 spans so we
// should be fine pre-allocating `spans.len() + 8`
let mut highlighted_spans: Vec<Span> = Vec::with_capacity(spans.len() + 8);
let mut hl_ranges = match_ranges
.iter()
.map(|(start, end)| (*start as usize, *end as usize))
.peekable();
let mut char_pos = 0;
for span in spans {
let span_len = span.content.chars().count();
let mut cursor = 0;
while cursor < span_len {
if let Some(&(hl_start, hl_end)) = hl_ranges.peek() {
if char_pos + cursor >= hl_end {
hl_ranges.next();
continue;
}
let highlight_start = hl_start.saturating_sub(char_pos);
let highlight_end = hl_end.saturating_sub(char_pos);
// note that the following will also just push the entire span if
// the highlight range comes anywhere after the current span
if cursor < highlight_start {
let s: String = span
.content
.chars()
.skip(cursor)
.take(highlight_start - cursor)
.collect();
if !s.is_empty() {
highlighted_spans.push(Span::styled(s, span.style));
}
cursor = highlight_start;
} else {
let s: String = span
.content
.chars()
.skip(cursor)
.take(highlight_end - cursor)
.collect();
if !s.is_empty() {
highlighted_spans
.push(Span::styled(s, span.style.fg(match_fg)));
}
cursor = highlight_end;
}
} else {
let s: String = span.content.chars().skip(cursor).collect();
if !s.is_empty() {
highlighted_spans.push(Span::styled(s, span.style));
}
break;
}
}
char_pos += span_len;
}
highlighted_spans
}
/// Build a `List` widget from a slice of [`ResultItem`]s.
#[allow(clippy::too_many_arguments)]
pub fn build_results_list<'a, 'b, T, F>(
@ -281,4 +407,131 @@ mod tests {
line.spans.iter().map(|s| s.content.clone()).collect();
assert!(rendered.contains('…'));
}
#[test]
fn test_build_entry_spans_ansi_no_ansi() {
let entry = Entry::new("A simple string".to_string())
.with_match_indices(&[3, 4, 5]);
let spans =
build_entry_spans_ansi(&entry, 200, Color::Blue, Color::Red);
let blue_fg = Style::default().fg(Color::Blue);
assert_eq!(spans.len(), 3);
assert_eq!(spans[0], Span::styled("A s", blue_fg));
assert_eq!(
spans[1],
Span::styled("imp", Style::default().fg(Color::Red))
);
assert_eq!(spans[2], Span::styled("le string", blue_fg));
}
#[test]
fn test_build_entry_spans_ansi_no_ansi_corner_cases() {
let entry = Entry::new("A".to_string()).with_match_indices(&[0]);
let spans =
build_entry_spans_ansi(&entry, 200, Color::Reset, Color::Red);
assert_eq!(spans.len(), 1);
assert_eq!(
spans[0],
Span::styled("A", Style::default().fg(Color::Red))
);
let entry = Entry::new(String::new()).with_match_indices(&[]);
let spans =
build_entry_spans_ansi(&entry, 200, Color::Reset, Color::Red);
assert!(spans.is_empty());
let entry = Entry::new("A".to_string()).with_match_indices(&[]);
let spans =
build_entry_spans_ansi(&entry, 200, Color::Reset, Color::Red);
assert_eq!(spans.len(), 1);
assert_eq!(
spans[0],
Span::styled("A", Style::default().fg(Color::Reset))
);
}
#[test]
fn test_build_entry_spans_ansi_with_ansi() {
let entry = Entry::new(
"\x1b[31mRed\x1b[0m and \x1b[32mGreen\x1b[0m".to_string(),
)
.with_match_indices(&[1, 4, 5]);
let spans =
build_entry_spans_ansi(&entry, 200, Color::Blue, Color::Yellow);
assert_eq!(
spans.len(),
7,
"Expected 7 spans but got {:?}",
spans
.iter()
.map(|s| (s.content.clone(), s.style.fg))
.collect::<Vec<_>>()
);
assert_eq!(spans[0], Span::raw("R").fg(Color::Red));
assert_eq!(spans[1], Span::raw("e").fg(Color::Yellow));
assert_eq!(spans[2], Span::raw("d").fg(Color::Red));
assert_eq!(spans[3].content, Span::raw(" ").content);
assert_eq!(spans[3].style, Style::reset());
assert_eq!(spans[4], Span::raw("an").reset().fg(Color::Yellow));
assert_eq!(spans[5], Span::raw("d ").reset());
assert_eq!(spans[6], Span::raw("Green").reset().fg(Color::Green));
}
#[test]
fn test_build_entry_spans_full_string_highlight() {
let entry = Entry::new("highlight me".to_string())
.with_match_indices(&(0..12).collect::<Vec<_>>());
let spans = build_entry_spans(&entry, 200, Color::Blue, Color::Red);
// All chars should be highlighted
assert_eq!(spans.len(), 1);
assert_eq!(
spans[0],
Span::styled("highlight me", Style::default().fg(Color::Red))
);
}
#[test]
fn test_build_entry_spans_match_at_boundaries() {
let entry =
Entry::new("boundary".to_string()).with_match_indices(&[0, 7]);
let spans = build_entry_spans(&entry, 200, Color::Blue, Color::Red);
assert_eq!(
spans[0],
Span::styled("b", Style::default().fg(Color::Red))
);
assert_eq!(
spans[1],
Span::styled("oundar", Style::default().fg(Color::Blue))
);
assert_eq!(
spans[2],
Span::styled("y", Style::default().fg(Color::Red))
);
}
#[test]
fn test_build_entry_spans_unicode_boundaries() {
let entry = Entry::new("a😀b".to_string()).with_match_indices(&[1]); // highlight the emoji only
let spans = build_entry_spans(&entry, 200, Color::Blue, Color::Red);
assert_eq!(
spans[0],
Span::styled("a", Style::default().fg(Color::Blue))
);
assert_eq!(
spans[1],
Span::styled("😀", Style::default().fg(Color::Red))
);
assert_eq!(
spans[2],
Span::styled("b", Style::default().fg(Color::Blue))
);
}
}

View File

@ -566,8 +566,6 @@ impl Television {
preview.title = template
.format(&entry.raw)
.unwrap_or_else(|_| entry.raw.clone());
} else {
preview.title.clone_from(&entry.raw);
}
if let Some(template) = &self.config.ui.preview_panel.footer {

View File

@ -1,5 +1,7 @@
use lazy_regex::{Lazy, Regex, regex};
use crate::screen::result_item::ResultItem;
/// Returns the index of the next character boundary in the given string.
///
/// If the given index is already a character boundary, it is returned as is.
@ -456,60 +458,47 @@ pub fn preprocess_line(line: &str) -> (String, Vec<i16>) {
/// the match ranges adjusted to the new string.
///
/// # Examples
/// ```
/// use television::utils::strings::make_matched_string_printable;
/// ```ignore
/// use television::channels::entry::Entry;
/// use television::utils::strings::make_result_item_printable;
///
/// let matched_string = "Hello, World!";
/// let match_ranges = vec![(0, 1), (7, 8)];
/// let match_ranges = Some(match_ranges.as_slice());
/// let (printable, match_indices) = make_matched_string_printable(matched_string, match_ranges);
/// let entry = Entry::new("Hello, World!".to_string()).with_match_indices(&[0, 7]);
/// let (printable, match_indices) = make_result_item_printable(&entry);
/// assert_eq!(printable, "Hello, World!");
/// assert_eq!(match_indices, vec![(0, 1), (7, 8)]);
///
/// let matched_string = "Hello,\tWorld!";
/// let match_ranges = vec![(0, 1), (7, 8)];
/// let match_ranges = Some(match_ranges.as_slice());
/// let (printable, match_indices) = make_matched_string_printable(matched_string, match_ranges);
/// let entry = Entry::new("Hello,\tWorld!".to_string()).with_match_indices(&[0, 10]);
/// let (printable, match_indices) = make_result_item_printable(&entry);
/// assert_eq!(printable, "Hello, World!");
/// assert_eq!(match_indices, vec![(0, 1), (10, 11)]);
///
/// let matched_string = "Hello,\nWorld!";
/// let match_ranges = vec![(0, 1), (7, 8)];
/// let match_ranges = Some(match_ranges.as_slice());
/// let (printable, match_indices) = make_matched_string_printable(matched_string, match_ranges);
/// let entry = Entry::new("Hello,\nWorld!".to_string()).with_match_indices(&[0, 6]);
/// let (printable, match_indices) = make_result_item_printable(&entry);
/// assert_eq!(printable, "Hello,World!");
/// assert_eq!(match_indices, vec![(0, 1), (6, 7)]);
///
/// let matched_string = "Hello, World!";
/// let (printable, match_indices) = make_matched_string_printable(matched_string, None);
/// let entry = Entry::new("Hello, World!".to_string());
/// let (printable, match_indices) = make_result_item_printable(&entry);
/// assert_eq!(printable, "Hello, World!");
/// assert_eq!(match_indices, vec![]);
///
/// let matched_string = "build.rs";
/// let match_ranges = vec![(0, 1), (7, 8)];
/// let match_ranges = Some(match_ranges.as_slice());
/// let (printable, match_indices) = make_matched_string_printable(matched_string, match_ranges);
/// let entry = Entry::new("build.rs".to_string()).with_match_indices(&[0, 7]);
/// let (printable, match_indices) = make_result_item_printable(&entry);
/// assert_eq!(printable, "build.rs");
/// assert_eq!(match_indices, vec![(0, 1), (7, 8)]);
///
/// let matched_string = "a\tb";
/// let match_ranges = vec![(0, 1), (2, 3)];
/// let match_ranges = Some(match_ranges.as_slice());
/// let (printable, match_indices) = make_matched_string_printable(matched_string, match_ranges);
/// let entry = Entry::new("a\tb".to_string()).with_match_indices(&[0, 5]);
/// let (printable, match_indices) = make_result_item_printable(&entry);
/// assert_eq!(printable, "a b");
/// assert_eq!(match_indices, vec![(0, 1), (5, 6)]);
///
/// let matched_string = "a\tbcd".repeat(65);
/// let match_ranges = vec![(0, 1), (310, 311)];
/// let match_ranges = Some(match_ranges.as_slice());
/// let (printable, match_indices) = make_matched_string_printable(&matched_string, match_ranges);
/// let entry = Entry::new("a\tbcd".repeat(65)).with_match_indices(&[0, 330]);
/// let (printable, match_indices) = make_result_item_printable(&entry);
/// assert_eq!(printable.len(), 480);
/// assert_eq!(match_indices, vec![(0, 1)]);
///
/// let matched_string = "ジェ abc";
/// let match_ranges = vec![(0, 1), (2, 3)];
/// let match_ranges = Some(match_ranges.as_slice());
/// let (printable, match_indices) = make_matched_string_printable(matched_string, match_ranges);
/// let entry = Entry::new("ジェ abc".to_string()).with_match_indices(&[0, 2]);
/// let (printable, match_indices) = make_result_item_printable(&entry);
/// assert_eq!(printable, "ジェ abc");
/// assert_eq!(match_indices, vec![(0, 1), (2, 3)]);
/// ```
@ -517,33 +506,38 @@ pub fn preprocess_line(line: &str) -> (String, Vec<i16>) {
/// # Panics
/// This will panic if the length of the printable string or the match indices don't fit into a
/// `u32`.
pub fn make_matched_string_printable(
matched_string: &str,
match_ranges: Option<&[(u32, u32)]>,
pub fn make_result_item_printable(
result_item: &(impl ResultItem + ?Sized),
) -> (String, Vec<(u32, u32)>) {
// PERF: Fast path for ASCII strings without match ranges
if matched_string.is_ascii() && match_ranges.is_none() {
return (matched_string.to_string(), Vec::new());
}
// PERF: If ASCII with ranges, check if we need preprocessing (tabs, newlines, etc.)
if let Some(ranges) = match_ranges {
if matched_string.is_ascii() {
// Only use fast path if no characters need preprocessing
if !matched_string
.chars()
.any(|c| c == '\t' || c == '\n' || c.is_control())
{
return (matched_string.to_string(), ranges.to_vec());
// PERF: fast path for ascii
if result_item.display().is_ascii() {
match result_item.match_ranges() {
// If there are no match ranges, we can return the display string directly
None => {
return (result_item.display().to_string(), Vec::new());
}
// Otherwise, check if we can return the display string without further processing
Some(ranges) => {
if !result_item
.display()
.chars()
.any(|c| c == '\t' || c == '\n' || c.is_control())
{
return (
result_item.display().to_string(),
ranges.to_vec(),
);
}
}
}
}
// Full processing for non-ASCII strings or strings that need preprocessing
let (printable, transformation_offsets) = preprocess_line(matched_string);
let (printable, transformation_offsets) =
preprocess_line(result_item.display());
let mut match_indices = Vec::new();
if let Some(ranges) = match_ranges {
if let Some(ranges) = result_item.match_ranges() {
// PERF: Pre-allocate with known capacity
match_indices.reserve(ranges.len());
@ -631,6 +625,7 @@ pub fn format_string(template: &str, source: &str, delimiter: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::channels::entry::Entry;
fn test_next_char_boundary(input: &str, start: usize, expected: usize) {
let actual = next_char_boundary(input, start);
@ -889,4 +884,48 @@ mod tests {
test_preprocess_line("Hello, World!\u{FEFF}", "Hello, World!␀");
test_preprocess_line(&"a".repeat(400), &"a".repeat(300));
}
#[test]
fn test_make_match_string_printable() {
let entry = Entry::new("Hello, World!".to_string())
.with_match_indices(&[0, 7]);
let (printable, match_indices) = make_result_item_printable(&entry);
assert_eq!(printable, "Hello, World!");
assert_eq!(match_indices, vec![(0, 1), (7, 8)]);
let entry = Entry::new("Hello,\tWorld!".to_string())
.with_match_indices(&[0, 10]);
let (printable, match_indices) = make_result_item_printable(&entry);
assert_eq!(printable, "Hello, World!");
assert_eq!(match_indices, vec![(0, 1), (13, 14)]);
let entry = Entry::new("Hello,\nWorld!".to_string())
.with_match_indices(&[0, 6]);
let (printable, match_indices) = make_result_item_printable(&entry);
assert_eq!(printable, "Hello,World!");
assert_eq!(match_indices, vec![(0, 1), (6, 6)]);
let entry = Entry::new("Hello, World!".to_string());
let (printable, match_indices) = make_result_item_printable(&entry);
assert_eq!(printable, "Hello, World!");
assert_eq!(match_indices, vec![]);
let entry =
Entry::new("build.rs".to_string()).with_match_indices(&[0, 7]);
let (printable, match_indices) = make_result_item_printable(&entry);
assert_eq!(printable, "build.rs");
assert_eq!(match_indices, vec![(0, 1), (7, 8)]);
let entry =
Entry::new("a\tbcd".repeat(65)).with_match_indices(&[0, 330]);
let (printable, match_indices) = make_result_item_printable(&entry);
assert_eq!(printable.len(), 480);
assert_eq!(match_indices, vec![(0, 1)]);
let entry =
Entry::new("ジェ abc".to_string()).with_match_indices(&[0, 2]);
let (printable, match_indices) = make_result_item_printable(&entry);
assert_eq!(printable, "ジェ abc");
assert_eq!(match_indices, vec![(0, 1), (2, 3)]);
}
}

View File

@ -27,7 +27,7 @@ pub const DEFAULT_PTY_SIZE: PtySize = PtySize {
pixel_height: 0,
};
pub const DEFAULT_DELAY: Duration = Duration::from_millis(300);
pub const DEFAULT_DELAY: Duration = Duration::from_millis(100);
/// A helper to test terminal user interfaces (TUIs) using a pseudo-terminal (pty).
///
@ -214,7 +214,7 @@ impl PtyTester {
}
/// How long to wait for the TUI to stabilize before asserting its output.
const FRAME_STABILITY_TIMEOUT: Duration = Duration::from_millis(5000);
const FRAME_STABILITY_TIMEOUT: Duration = Duration::from_millis(3000);
/// Gets the current TUI frame, ensuring it has stabilized.
///
@ -249,9 +249,9 @@ impl PtyTester {
// wait for the UI to stabilize with a timeout
let mut frame = String::new();
let start_time = std::time::Instant::now();
// wait till we get 3 consecutive frames that are the same
// wait till we get 2 consecutive frames that are the same
let mut counter = 0;
while counter < 3 {
while counter < 2 {
let new_frame = self.read_tui_output();
if new_frame == frame {
counter += 1;
@ -266,7 +266,7 @@ impl PtyTester {
frame
);
// Sleep briefly to allow the UI to update
sleep(Duration::from_millis(50));
sleep(Duration::from_millis(20));
}
frame
}

View File

@ -1,34 +0,0 @@
mod common;
use common::*;
#[test]
// FIXME: was lazy, this should be more robust
fn toggle_preview() {
let mut tester = PtyTester::new();
let mut child =
tester.spawn_command_tui(tv_local_config_and_cable_with_args(&[
"files",
"-p",
"cat -n {}",
]));
let with_preview =
"╭───────────────────────── files ──────────────────────────╮";
tester.assert_tui_frame_contains(with_preview);
// Toggle preview
tester.send(&ctrl('o'));
let without_preview = "╭─────────────────────────────────────────────────────── files ────────────────────────────────────────────────────────╮";
tester.assert_tui_frame_contains(without_preview);
// Toggle preview
tester.send(&ctrl('o'));
tester.assert_tui_frame_contains(with_preview);
// Exit the application
tester.send(&ctrl('c'));
PtyTester::assert_exit_ok(&mut child, DEFAULT_DELAY);
}