feat(ui): add support for customizing input_header, preview_header and preview_footer

This PR adds support for customizing

* input_header
* preview_header
* preview_footer

all the items are of type MultiTemplate, this gives some flexibility
when showing the selected value, let's say on the preview header, if
it's long or you want only a section

---------

Co-authored-by: alexandre pasmantier <alex.pasmant@gmail.com>
This commit is contained in:
LM 2025-06-17 01:45:26 +02:00 committed by Alex Pasmantier
parent ab1efed88d
commit 510e7b6338
16 changed files with 462 additions and 202 deletions

153
Cargo.lock generated
View File

@ -545,6 +545,16 @@ dependencies = [
"syn",
]
[[package]]
name = "deranged"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
dependencies = [
"powerfmt",
"serde",
]
[[package]]
name = "devicons"
version = "0.6.12"
@ -591,6 +601,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "dyn-clone"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005"
[[package]]
name = "either"
version = "1.15.0"
@ -774,6 +790,12 @@ dependencies = [
"crunchy",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.15.2"
@ -797,6 +819,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbd780fe5cc30f81464441920d82ac8740e2e46b29a6fad543ddd075229ce37e"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "http"
version = "1.3.1"
@ -860,6 +888,17 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "indexmap"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [
"autocfg",
"hashbrown 0.12.3",
"serde",
]
[[package]]
name = "indexmap"
version = "2.9.0"
@ -867,7 +906,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e"
dependencies = [
"equivalent",
"hashbrown",
"hashbrown 0.15.2",
"serde",
]
[[package]]
@ -1019,7 +1059,7 @@ version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
dependencies = [
"hashbrown",
"hashbrown 0.15.2",
]
[[package]]
@ -1117,6 +1157,12 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "num-conv"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]]
name = "num-traits"
version = "0.2.19"
@ -1311,6 +1357,12 @@ dependencies = [
"winreg",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "proc-macro2"
version = "1.0.94"
@ -1397,6 +1449,26 @@ dependencies = [
"thiserror 2.0.12",
]
[[package]]
name = "ref-cast"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a0ae411dbe946a674d89546582cea4ba2bb8defac896622d6496f14c23ba5cf"
dependencies = [
"ref-cast-impl",
]
[[package]]
name = "ref-cast-impl"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "regex"
version = "1.11.1"
@ -1570,6 +1642,18 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schemars"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
dependencies = [
"dyn-clone",
"ref-cast",
"serde",
"serde_json",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
@ -1617,6 +1701,37 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_with"
version = "3.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf65a400f8f66fb7b0552869ad70157166676db75ed8181f8104ea91cf9d0b42"
dependencies = [
"base64",
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.9.0",
"schemars",
"serde",
"serde_derive",
"serde_json",
"serde_with_macros",
"time",
]
[[package]]
name = "serde_with_macros"
version = "3.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81679d9ed988d5e9a5e6531dc3f2c28efbd639cbd1dfb628df08edea6004da77"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serial2"
version = "0.2.29"
@ -1824,6 +1939,7 @@ dependencies = [
"rustc-hash",
"serde",
"serde_json",
"serde_with",
"signal-hook",
"string_pipeline",
"tempfile",
@ -1900,6 +2016,37 @@ dependencies = [
"once_cell",
]
[[package]]
name = "time"
version = "0.3.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40"
dependencies = [
"deranged",
"itoa",
"num-conv",
"powerfmt",
"serde",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c"
[[package]]
name = "time-macros"
version = "0.2.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tinytemplate"
version = "1.2.1"
@ -1966,7 +2113,7 @@ version = "0.22.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474"
dependencies = [
"indexmap",
"indexmap 2.9.0",
"serde",
"serde_spanned",
"toml_datetime",

View File

@ -58,6 +58,7 @@ string_pipeline = "0.11.1"
ureq = "3.0.11"
serde_json = "1.0.140"
colored = "3.0.0"
serde_with = "3.13.0"
# target specific dependencies

View File

@ -12,3 +12,4 @@ command = "echo '{split:=:1..}'"
[ui]
layout = "portrait"
preview_size = 20
preview_header = "{split:=:0}"

View File

@ -1,12 +1,14 @@
use anyhow::Result;
use std::fmt::{self, Display, Formatter};
use std::hash::{Hash, Hasher};
use crate::{
config::KeyBindings,
screen::layout::{InputPosition, Orientation},
};
use rustc_hash::FxHashMap;
use serde::ser::SerializeSeq;
use serde::{Deserialize, Serialize};
use serde_with::{OneOrMany, serde_as};
use string_pipeline::MultiTemplate;
#[derive(Debug, Clone)]
@ -53,13 +55,49 @@ impl Display for Template {
}
}
impl PartialEq for Template {
fn eq(&self, other: &Self) -> bool {
self.raw() == other.raw()
&& matches!(
(self, other),
(Template::StringPipeline(_), Template::StringPipeline(_))
| (Template::Raw(_), Template::Raw(_))
)
}
}
impl Eq for Template {}
impl Hash for Template {
fn hash<H: Hasher>(&self, state: &mut H) {
self.raw().hash(state);
}
}
impl Serialize for Template {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.raw())
}
}
impl<'de> Deserialize<'de> for Template {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
Template::parse(&raw).map_err(serde::de::Error::custom)
}
}
#[serde_as]
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct CommandSpec {
#[serde(
rename = "command",
deserialize_with = "deserialize_commands",
serialize_with = "serialize_commands"
)]
#[serde(rename = "command")]
#[serde_as(as = "OneOrMany<_>")]
pub inner: Vec<Template>,
#[serde(default)]
pub interactive: bool,
@ -67,13 +105,6 @@ pub struct CommandSpec {
pub env: FxHashMap<String, String>,
}
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
#[serde(untagged)]
enum SerializedCommand {
Single(String),
Multiple(Vec<String>),
}
impl Display for CommandSpec {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
write!(
@ -118,142 +149,6 @@ impl CommandSpec {
}
}
fn serialize_command<S>(
command: &Template,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(command.raw())
}
#[allow(clippy::ref_option)]
fn serialize_maybe_command<S>(
command: &Option<Template>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match command {
Some(cmd) => serialize_command(cmd, serializer),
None => serializer.serialize_none(),
}
}
#[allow(dead_code)]
fn deserialize_command<'de, D>(deserializer: D) -> Result<Template, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw: String = serde::Deserialize::deserialize(deserializer)?;
Template::parse(&raw).map_err(serde::de::Error::custom)
}
fn deserialize_maybe_command<'de, D>(
deserializer: D,
) -> Result<Option<Template>, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw: Option<String> = serde::Deserialize::deserialize(deserializer)?;
match raw {
Some(cmd) => Template::parse(&cmd)
.map(Some)
.map_err(serde::de::Error::custom),
None => Ok(None),
}
}
fn serialize_commands<S>(
commands: &[Template],
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
if commands.len() == 1 {
let raw = commands[0].raw();
serializer.serialize_str(raw)
} else {
let raw: Vec<String> =
commands.iter().map(|c| c.raw().to_string()).collect();
let mut seq = serializer.serialize_seq(Some(raw.len()))?;
for item in raw {
seq.serialize_element(&item)?;
}
seq.end()
}
}
#[allow(clippy::ref_option, dead_code)]
fn serialize_maybe_commands<S>(
commands: Option<&[Template]>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match commands {
Some(m) => serialize_commands(m, serializer),
None => serializer.serialize_none(),
}
}
fn deserialize_commands<'de, D>(
deserializer: D,
) -> Result<Vec<Template>, D::Error>
where
D: serde::Deserializer<'de>,
{
let res = match serde::Deserialize::deserialize(deserializer)? {
SerializedCommand::Single(cmd) => {
Template::parse(&cmd).map(|m| vec![m])
}
SerializedCommand::Multiple(cmds) => cmds
.iter()
.map(|cmd| Template::parse(cmd))
.collect::<Result<Vec<_>, _>>(),
}
.map_err(serde::de::Error::custom);
if let Ok(ref cmds) = res {
if cmds.is_empty() {
return Err(serde::de::Error::custom(
"Command list cannot be empty",
));
}
}
res
}
#[allow(dead_code)]
fn deserialize_maybe_commands<'de, D>(
deserializer: D,
) -> Result<Option<Vec<Template>>, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw: Option<SerializedCommand> =
serde::Deserialize::deserialize(deserializer)?;
match raw {
Some(template) => {
let cmd = match template {
SerializedCommand::Single(cmd) => {
Template::parse(&cmd).map(|m| vec![m])
}
SerializedCommand::Multiple(cmds) => {
cmds.iter().map(|cmd| Template::parse(cmd)).collect()
}
};
cmd.map_err(serde::de::Error::custom).map(Some)
}
None => Ok(None),
}
}
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct ChannelPrototype {
pub metadata: Metadata,
@ -342,17 +237,9 @@ pub struct Metadata {
pub struct SourceSpec {
#[serde(flatten)]
pub command: CommandSpec,
#[serde(
default,
deserialize_with = "deserialize_maybe_command",
serialize_with = "serialize_maybe_command"
)]
#[serde(default)]
pub display: Option<Template>,
#[serde(
default,
deserialize_with = "deserialize_maybe_command",
serialize_with = "serialize_maybe_command"
)]
#[serde(default)]
pub output: Option<Template>,
}
@ -360,11 +247,7 @@ pub struct SourceSpec {
pub struct PreviewSpec {
#[serde(flatten)]
pub command: CommandSpec,
#[serde(
default,
deserialize_with = "deserialize_maybe_command",
serialize_with = "serialize_maybe_command"
)]
#[serde(default)]
pub offset: Option<Template>,
}
@ -397,12 +280,18 @@ pub struct UiSpec {
#[serde(default)]
pub show_preview_panel: Option<bool>,
// `layout` is clearer for the user but collides with the overall `Layout` type.
#[serde(rename = "layout", default)]
#[serde(rename = "layout", alias = "orientation", default)]
pub orientation: Option<Orientation>,
#[serde(default)]
pub input_bar_position: Option<InputPosition>,
#[serde(default)]
pub preview_size: Option<u16>,
#[serde(default)]
pub input_header: Option<Template>,
#[serde(default)]
pub preview_header: Option<Template>,
#[serde(default)]
pub preview_footer: Option<Template>,
}
pub const DEFAULT_PROTOTYPE_NAME: &str = "files";
@ -432,6 +321,41 @@ mod tests {
assert_eq!(command_spec.get_nth(3).raw(), "cmd1"); // wraps around
}
#[test]
fn test_template_serialization() {
#[derive(Deserialize, Serialize, Debug, PartialEq)]
struct TestStruct {
template: Template,
}
let raw_1 = r#"template = "Hello, {}""#;
let raw_2 = r#"template = "Hello, World""#;
let raw_3 = r#"template = "docker images --format '{{.Repository}}:{{.Tag}} {{.ID}}'""#;
let test_1: TestStruct = from_str(raw_1).unwrap();
let test_2: TestStruct = from_str(raw_2).unwrap();
let test_3: TestStruct = from_str(raw_3).unwrap();
assert_eq!(
test_1.template,
Template::StringPipeline(
MultiTemplate::parse("Hello, {}").unwrap()
)
);
assert_eq!(
test_2.template,
Template::StringPipeline(
MultiTemplate::parse("Hello, World").unwrap()
)
);
assert_eq!(
test_3.template,
Template::Raw(
"docker images --format '{{.Repository}}:{{.Tag}} {{.ID}}'"
.to_string()
)
);
}
#[test]
fn test_channel_prototype_deserialization() {
let toml_data = r#"
@ -445,13 +369,14 @@ mod tests {
interactive = false
env = {}
display = "{split:/:-1}" # only show the last path segment ('/a/b/c' -> 'c')
ansi = false
output = "{}" # output the full path
unknown_field = "ignored" # should be ignored
[preview]
command = "bat -n --color=always {}"
env = { "BAT_THEME" = "ansi" }
interactive = false
offset = "3" # why not
[ui]
layout = "landscape"
@ -459,6 +384,10 @@ mod tests {
show_help_bar = false
show_preview_panel = true
input_bar_position = "bottom"
preview_size = 66
input_header = "Input: {}"
preview_header = "Preview: {}"
preview_footer = "Press 'q' to quit"
[keybindings]
quit = ["esc", "ctrl-c"]
@ -478,19 +407,37 @@ mod tests {
format!("{}", prototype.source.command.inner[0]),
"fd -t f"
);
assert!(!prototype.source.command.interactive);
assert_eq!(prototype.source.display.unwrap().raw(), "{split:/:-1}");
assert_eq!(prototype.source.output.unwrap().raw(), "{}");
let preview = prototype.preview.as_ref().unwrap();
assert_eq!(
format!("{}", prototype.preview.unwrap().command.inner[0]),
format!("{}", preview.command.inner[0]),
"bat -n --color=always {}"
);
assert!(!preview.command.interactive);
assert_eq!(
preview.command.env.get("BAT_THEME"),
Some(&"ansi".to_string())
);
assert_eq!(preview.offset.as_ref().unwrap().raw(), "3");
let ui = prototype.ui.unwrap();
assert_eq!(ui.orientation, Some(Orientation::Landscape));
assert_eq!(ui.ui_scale, Some(100));
assert!(!(ui.show_help_bar.unwrap()));
assert!(ui.show_preview_panel.unwrap());
assert_eq!(ui.input_bar_position, Some(InputPosition::Bottom));
assert_eq!(ui.preview_size, Some(66));
assert_eq!(ui.input_header.as_ref().unwrap().raw(), "Input: {}");
assert_eq!(ui.preview_header.as_ref().unwrap().raw(), "Preview: {}");
assert_eq!(
ui.preview_footer.as_ref().unwrap().raw(),
"Press 'q' to quit"
);
let keybindings = prototype.keybindings.unwrap();
assert_eq!(
keybindings.0.get(&Action::Quit),
@ -599,6 +546,7 @@ mod tests {
[ui]
layout = "landscape"
ui_scale = 40
preview_footer = "Press 'q' to quit"
"#;
let prototype: ChannelPrototype = from_str(toml_data).unwrap();
@ -623,5 +571,12 @@ mod tests {
assert!(ui.show_help_bar.is_none());
assert!(ui.show_preview_panel.is_none());
assert!(ui.input_bar_position.is_none());
assert!(ui.preview_size.is_none());
assert!(ui.input_header.is_none());
assert!(ui.preview_header.is_none());
assert_eq!(
ui.preview_footer.as_ref().unwrap().raw(),
"Press 'q' to quit"
);
}
}

View File

@ -69,13 +69,35 @@ pub struct Cli {
#[arg(short, long, value_name = "STRING", verbatim_doc_comment)]
pub input: Option<String>,
/// Input fields header title
/// Input field header template.
///
/// This can be used to give the input field a custom title e.g. the current
/// working directory.
/// The default value for the input header is the current channel.
#[arg(long, value_name = "STRING", verbatim_doc_comment)]
pub custom_header: Option<String>,
/// 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)]
pub input_header: Option<String>,
/// Preview header template
///
/// 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",
value_name = "STRING",
verbatim_doc_comment
)]
pub preview_header: Option<String>,
/// Preview footer template
///
/// 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",
value_name = "STRING",
verbatim_doc_comment
)]
pub preview_footer: Option<String>,
/// The working directory to start the application in.
///

View File

@ -28,7 +28,9 @@ pub struct PostProcessedCli {
pub tick_rate: Option<f64>,
pub frame_rate: Option<f64>,
pub input: Option<String>,
pub custom_header: Option<String>,
pub input_header: Option<String>,
pub preview_header: Option<String>,
pub preview_footer: Option<String>,
pub command: Option<Command>,
pub working_directory: Option<String>,
pub autocomplete_prompt: Option<String>,
@ -52,7 +54,9 @@ impl Default for PostProcessedCli {
tick_rate: None,
frame_rate: None,
input: None,
custom_header: None,
input_header: None,
preview_header: None,
preview_footer: None,
command: None,
working_directory: None,
autocomplete_prompt: None,
@ -115,7 +119,9 @@ pub fn post_process(cli: Cli) -> PostProcessedCli {
tick_rate: cli.tick_rate,
frame_rate: cli.frame_rate,
input: cli.input,
custom_header: cli.custom_header,
input_header: cli.input_header,
preview_header: cli.preview_header,
preview_footer: cli.preview_footer,
command: cli.command,
working_directory,
autocomplete_prompt: cli.autocomplete_prompt,

View File

@ -242,6 +242,17 @@ impl Config {
if let Some(preview_size) = &ui_spec.preview_size {
self.ui.preview_size = *preview_size;
}
if let Some(input_header) = &ui_spec.input_header {
self.ui.input_header = Some(input_header.clone());
}
if let Some(preview_header) = &ui_spec.preview_header {
self.ui.preview_header = Some(preview_header.clone());
}
if let Some(preview_footer) = &ui_spec.preview_footer {
self.ui.preview_footer = Some(preview_footer.clone());
}
}
}

View File

@ -1,3 +1,4 @@
use crate::channels::prototypes::Template;
use serde::{Deserialize, Serialize};
use crate::screen::layout::{
@ -23,9 +24,13 @@ pub struct UiConfig {
pub orientation: Orientation,
pub preview_title_position: Option<PreviewTitlePosition>,
pub theme: String,
pub custom_header: Option<String>,
pub preview_size: u16,
#[serde(default)]
pub input_header: Option<Template>,
#[serde(default)]
pub preview_header: Option<Template>,
#[serde(default)]
pub preview_footer: Option<Template>,
}
impl Default for UiConfig {
@ -40,8 +45,10 @@ impl Default for UiConfig {
orientation: Orientation::Landscape,
preview_title_position: None,
theme: String::from(DEFAULT_THEME),
custom_header: None,
preview_size: DEFAULT_PREVIEW_SIZE,
input_header: None,
preview_header: None,
preview_footer: None,
}
}
}

View File

@ -247,7 +247,7 @@ pub fn draw(ctx: &Ctx, f: &mut Frame<'_>, area: Rect) -> Result<Layout> {
&ctx.tv_state.channel_state.current_channel_name,
&ctx.tv_state.spinner,
&ctx.colorscheme,
&ctx.config.ui.custom_header,
&ctx.config.ui.input_header,
&ctx.config.ui.input_bar_position,
)?;

View File

@ -10,7 +10,7 @@ use television::cli::post_process;
use television::gh::update_local_channels;
use television::{
cable::Cable, channels::prototypes::ChannelPrototype,
utils::clipboard::CLIPBOARD,
channels::prototypes::Template, utils::clipboard::CLIPBOARD,
};
use tracing::{debug, error, info};
@ -81,8 +81,34 @@ async fn main() -> Result<()> {
args.preview_size,
config.application.tick_rate,
);
let mut app =
App::new(channel_prototype, config, args.input, options, cable);
let mut app = App::new(
channel_prototype,
config,
args.input.clone(),
options,
cable,
);
// Apply CLI template overrides last (highest priority). This must run
// AFTER the channel-specific UI spec has been merged (inside
// `Television::new`) so that CLI flags win over both global and channel
// configuration.
if let Some(ref s) = args.input_header {
if let Ok(t) = Template::parse(s) {
app.television.config.ui.input_header = Some(t);
}
}
if let Some(ref s) = args.preview_header {
if let Ok(t) = Template::parse(s) {
app.television.config.ui.preview_header = Some(t);
}
}
if let Some(ref s) = args.preview_footer {
if let Ok(t) = Template::parse(s) {
app.television.config.ui.preview_footer = Some(t);
}
}
stdout().flush()?;
debug!("Running application...");
let output = app.run(stdout().is_terminal(), false).await?;
@ -128,9 +154,6 @@ fn apply_cli_overrides(args: &PostProcessedCli, config: &mut Config) {
config.keybindings =
merge_keybindings(config.keybindings.clone(), keybindings);
}
if let Some(header) = &args.custom_header {
config.ui.custom_header = Some(header.to_string());
}
config.ui.ui_scale = args.ui_scale;
}

View File

@ -101,6 +101,7 @@ pub struct Preview {
pub content: String,
pub icon: Option<FileIcon>,
pub total_lines: u16,
pub footer: String,
}
const DEFAULT_PREVIEW_TITLE: &str = "Select an entry to preview";
@ -112,6 +113,7 @@ impl Default for Preview {
content: String::new(),
icon: None,
total_lines: 1,
footer: String::new(),
}
}
}
@ -122,12 +124,14 @@ impl Preview {
content: String,
icon: Option<FileIcon>,
total_lines: u16,
footer: String,
) -> Self {
Self {
title: title.to_string(),
content,
icon,
total_lines,
footer,
}
}
}
@ -243,6 +247,7 @@ pub fn try_preview(
content.to_string(),
None,
u16::try_from(content.lines().count()).unwrap_or(u16::MAX),
String::new(),
)
} else {
let (content, _) = replace_non_printable(
@ -256,6 +261,7 @@ pub fn try_preview(
content.to_string(),
None,
u16::try_from(content.lines().count()).unwrap_or(u16::MAX),
String::new(),
)
}
};

View File

@ -51,7 +51,11 @@ impl PreviewState {
scroll: u16,
target_line: Option<u16>,
) {
if self.preview.title != preview.title || self.scroll != scroll {
if self.preview.title != preview.title
|| self.preview.content != preview.content
|| self.preview.footer != preview.footer
|| self.scroll != scroll
{
self.preview = preview;
self.scroll = scroll;
self.target_line = target_line;
@ -91,6 +95,7 @@ impl PreviewState {
cropped_content,
self.preview.icon,
self.preview.total_lines,
self.preview.footer.clone(),
),
num_skipped_lines,
target_line,

View File

@ -1,3 +1,4 @@
use crate::channels::prototypes::Template;
use crate::utils::input::Input;
use anyhow::Result;
use ratatui::{
@ -28,10 +29,13 @@ pub fn draw_input_box(
channel_name: &str,
spinner: &Spinner,
colorscheme: &Colorscheme,
custom_header: &Option<String>,
input_header: &Option<Template>,
input_bar_position: &InputPosition,
) -> Result<()> {
let header = custom_header.as_deref().unwrap_or(channel_name);
let header = input_header
.as_ref()
.and_then(|tpl| tpl.format(channel_name).ok())
.unwrap_or_else(|| channel_name.to_string());
let input_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
@ -41,7 +45,7 @@ pub fn draw_input_box(
InputPosition::Bottom => Position::Bottom,
})
.title(
Line::from(String::from(" ") + header + " ")
Line::from(format!(" {} ", header))
.style(Style::default().fg(colorscheme.mode.channel).bold())
.centered(),
)

View File

@ -29,6 +29,7 @@ pub fn draw_preview_content_block(
colorscheme,
preview_state.preview.icon,
&preview_state.preview.title,
&preview_state.preview.footer,
use_nerd_font_icons,
)?;
// render the preview content
@ -138,6 +139,7 @@ fn draw_content_outer_block(
colorscheme: &Colorscheme,
icon: Option<FileIcon>,
title: &str,
footer: &str,
use_nerd_font_icons: bool,
) -> Result<Rect> {
let mut preview_title_spans = vec![Span::from(" ")];
@ -153,7 +155,7 @@ fn draw_content_outer_block(
Style::default().fg(Color::from_str(icon.color)?),
));
}
// preview title
// preview header
preview_title_spans.push(Span::styled(
shrink_with_ellipsis(
&replace_non_printable(
@ -167,13 +169,26 @@ fn draw_content_outer_block(
));
preview_title_spans.push(Span::from(" "));
// build the preview block
let preview_outer_block = Block::default()
.title_top(
Line::from(preview_title_spans)
.alignment(Alignment::Center)
.style(Style::default().fg(colorscheme.preview.title_fg)),
)
let mut block = Block::default();
block = block.title_top(
Line::from(preview_title_spans)
.alignment(Alignment::Center)
.style(Style::default().fg(colorscheme.preview.title_fg)),
);
// preview footer
if !footer.is_empty() {
let footer_line = Line::from(vec![
Span::from(" "),
Span::from(footer),
Span::from(" "),
])
.alignment(Alignment::Center)
.style(Style::default().fg(colorscheme.preview.title_fg));
block = block.title_bottom(footer_line);
}
let preview_outer_block = block
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(colorscheme.general.border_fg))

View File

@ -184,6 +184,21 @@ impl Television {
// ui
if let Some(ui_spec) = &channel_prototype.ui {
config.apply_prototype_ui_spec(ui_spec);
if config.ui.input_header.is_none() {
if let Some(header_tpl) = &ui_spec.input_header {
config.ui.input_header = Some(header_tpl.clone());
}
}
if config.ui.preview_header.is_none() {
if let Some(ph) = &ui_spec.preview_header {
config.ui.preview_header = Some(ph.clone());
}
}
if config.ui.preview_footer.is_none() {
if let Some(pf) = &ui_spec.preview_footer {
config.ui.preview_footer = Some(pf.clone());
}
}
}
config
}
@ -491,7 +506,21 @@ impl Television {
}
// available previews
let entry = selected_entry.as_ref().unwrap();
if let Ok(preview) = receiver.try_recv() {
if let Ok(mut preview) = receiver.try_recv() {
if let Some(tpl) = &self.config.ui.preview_header {
preview.title = tpl
.format(&entry.raw)
.unwrap_or_else(|_| entry.raw.clone());
} else {
preview.title.clone_from(&entry.raw);
}
if let Some(ftpl) = &self.config.ui.preview_footer {
preview.footer = ftpl
.format(&entry.raw)
.unwrap_or_else(|_| String::new());
}
let scroll = entry
.line_number
.unwrap_or(0)

View File

@ -211,6 +211,34 @@ fn tv_remote_control() {
assert_exit_ok(&mut child, DEFAULT_TIMEOUT);
}
#[test]
fn tv_custom_input_header_and_preview_size() {
let pty_system = native_pty_system();
// Create a new pty
let pair = pty_system.openpty(DEFAULT_PTY_SIZE).unwrap();
// Spawn a tv process in the pty
let mut cmd = tv_command("files");
cmd.args(["--input-header", "bagels"]);
let mut child = pair.slave.spawn_command(cmd).unwrap();
sleep(Duration::from_millis(200));
// Read the output from the pty
let mut buf = [0; 5096];
let mut reader = pair.master.try_clone_reader().unwrap();
let _ = reader.read(&mut buf).unwrap();
let output = String::from_utf8_lossy(&buf);
assert!(output.contains("bagels"));
// Send Ctrl-C to the process
let mut writer = pair.master.take_writer().unwrap();
writeln!(writer, "\x03").unwrap(); // Ctrl-C
sleep(Duration::from_millis(200));
assert_exit_ok(&mut child, DEFAULT_TIMEOUT);
}
macro_rules! test_channel {
($($name:ident: $channel_name:expr,)*) => {
$(