From 098f3f4fe407cc942e8fa668db9065f3c90b25bc Mon Sep 17 00:00:00 2001
From: Alex Pasmantier <47638216+alexpasmantier@users.noreply.github.com>
Date: Sat, 19 Jul 2025 22:52:12 +0200
Subject: [PATCH] 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}' }
```
## 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"
```
---
benches/main/ui.rs | 108 +++++---
man/tv.1 | 24 +-
television/channels/channel.rs | 20 +-
television/channels/entry.rs | 30 +-
television/channels/prototypes.rs | 4 +
television/channels/remote_control.rs | 4 +
television/cli/args.rs | 46 ++--
television/cli/mod.rs | 3 +
television/main.rs | 3 +
television/previewer/mod.rs | 16 +-
television/screen/result_item.rs | 379 +++++++++++++++++++++-----
television/television.rs | 2 -
television/utils/strings.rs | 141 ++++++----
tests/common/mod.rs | 10 +-
tests/ui.rs | 34 ---
15 files changed, 591 insertions(+), 233 deletions(-)
delete mode 100644 tests/ui.rs
diff --git a/benches/main/ui.rs b/benches/main/ui.rs
index 514d5ae..229f582 100644
--- a/benches/main/ui.rs
+++ b/benches/main/ui.rs
@@ -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,
},
];
diff --git a/man/tv.1 b/man/tv.1
index ed6b95c..d04cbcc 100644
--- a/man/tv.1
+++ b/man/tv.1
@@ -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
diff --git a/television/channels/channel.rs b/television/channels/channel.rs
index 7807629..45ce513 100644
--- a/television/channels/channel.rs
+++ b/television/channels/channel.rs
@@ -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 '{}'",
diff --git a/television/channels/entry.rs b/television/channels/entry.rs
index 7138465..9c6b274 100644
--- a/television/channels/entry.rs
+++ b/television/channels/entry.rs
@@ -11,12 +11,14 @@ pub struct Entry {
pub raw: String,
/// The actual entry string that will be displayed in the UI.
pub display: Option,
- /// The optional ranges for matching characters in the name.
- pub name_match_ranges: Option>,
+ /// The optional ranges for matching characters (based on `self.display`).
+ pub match_ranges: Option>,
/// The optional icon associated with the entry.
pub icon: Option,
/// The optional line number associated with the entry.
pub line_number: Option,
+ /// 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())),
diff --git a/television/channels/prototypes.rs b/television/channels/prototypes.rs
index 41be5a0..35ebf34 100644
--- a/television/channels/prototypes.rs
+++ b/television/channels/prototypes.rs
@@ -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,
#[serde(default)]
+ pub ansi: bool,
+ #[serde(default)]
pub display: Option,
#[serde(default)]
pub output: Option,
diff --git a/television/channels/remote_control.rs b/television/channels/remote_control.rs
index 6a2c45e..050cc67 100644
--- a/television/channels/remote_control.rs
+++ b/television/channels/remote_control.rs
@@ -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)
diff --git a/television/cli/args.rs b/television/cli/args.rs
index aa0e9fe..6d45370 100644
--- a/television/cli/args.rs
+++ b/television/cli/args.rs
@@ -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,
/// 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,
/// 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,
+ /// 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,
/// 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,
/// 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,
/// 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,
/// The working directory to start the application in.
diff --git a/television/cli/mod.rs b/television/cli/mod.rs
index 7d06898..7d54724 100644
--- a/television/cli/mod.rs
+++ b/television/cli/mod.rs
@@ -51,6 +51,7 @@ pub struct PostProcessedCli {
pub source_entry_delimiter: Option,
pub working_directory: Option,
pub autocomplete_prompt: Option,
+ pub ansi: bool,
// Preview configuration
pub preview_command_override: Option,
@@ -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,
diff --git a/television/main.rs b/television/main.rs
index 8799b03..5685c11 100644
--- a/television/main.rs
+++ b/television/main.rs
@@ -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
diff --git a/television/previewer/mod.rs b/television/previewer/mod.rs
index 1ae57a3..05a6238 100644
--- a/television/previewer/mod.rs
+++ b/television/previewer/mod.rs
@@ -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
diff --git a/television/screen/result_item.rs b/television/screen/result_item.rs
index c1f06a9..1ef749f 100644
--- a/television/screen/result_item.rs
+++ b/television/screen/result_item.rs
@@ -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 = 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(
+ item: &T,
+ max_width: u16,
+ result_fg: Color,
+ match_fg: Color,
+) -> Vec {
+ 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 = 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(
+ item: &T,
+ max_width: u16,
+ result_fg: Color,
+ match_fg: Color,
+) -> Vec {
+ 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 = 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::>()
+ );
+ 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::>());
+ 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))
+ );
+ }
}
diff --git a/television/television.rs b/television/television.rs
index 8596465..2383d8c 100644
--- a/television/television.rs
+++ b/television/television.rs
@@ -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 {
diff --git a/television/utils/strings.rs b/television/utils/strings.rs
index 3d0d9a4..840d512 100644
--- a/television/utils/strings.rs
+++ b/television/utils/strings.rs
@@ -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) {
/// 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) {
/// # 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)]);
+ }
}
diff --git a/tests/common/mod.rs b/tests/common/mod.rs
index dcac6d1..a28b9e7 100644
--- a/tests/common/mod.rs
+++ b/tests/common/mod.rs
@@ -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
}
diff --git a/tests/ui.rs b/tests/ui.rs
deleted file mode 100644
index 81ae36e..0000000
--- a/tests/ui.rs
+++ /dev/null
@@ -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);
-}