feat(config): border styles and padding are now configurable (#642)

## 📺 PR Description

this allows changing (or removing) the border styles

tv 0.12.3:
<img width="773" height="407" alt="image"
src="https://github.com/user-attachments/assets/8b74fc2f-7abc-4bb8-b645-c6243f40230c"
/>

this PR with
```toml
[ui.input_bar]
border_type = 'none'
[ui.results_panel]
border_type = 'none'
[ui.preview_panel]
border_type = 'plain'
```
<img width="778" height="407" alt="image"
src="https://github.com/user-attachments/assets/481fd0fa-a90a-4be8-bbe8-6e0ee5b002e3"
/>

see #638

## Checklist

- [ ] my commits **and PR title** follow the [conventional
commits](https://www.conventionalcommits.org/en/v1.0.0/#summary) format
- [x] if this is a new feature, I have added tests to consolidate the
feature and prevent regressions
- [ ] if this is a bug fix, I have added a test that reproduces the bug
(if applicable)
- [x] I have added a reasonable amount of documentation to the code
where appropriate

---------

Co-authored-by: alexandre pasmantier <alex.pasmant@gmail.com>
This commit is contained in:
raylu 2025-07-24 15:08:03 -07:00 committed by GitHub
parent 39610e145d
commit a0e3063702
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 668 additions and 155 deletions

View File

@ -54,10 +54,6 @@ use_nerd_font_icons = false
# └───────────────────────────────────────┘
ui_scale = 100
# Where to place the input bar in the UI (top or bottom)
input_bar_position = "top"
# The input prompt string (defaults to ">" if not specified)
input_prompt = ">"
# What orientation should tv be (landscape or portrait)
orientation = "landscape"
# The theme to use for the UI
@ -100,6 +96,14 @@ remote_control = { enabled = true, visible = false }
# Feature-specific configurations
# Each feature can have its own configuration section
[ui.input_bar]
# Where to place the input bar in the UI (top or bottom)
position = "top"
# The input prompt string (defaults to ">" if not specified)
prompt = ">"
# header = "{}"
border_type = "rounded" # https://docs.rs/ratatui/latest/ratatui/widgets/block/enum.BorderType.html#variants
[ui.status_bar]
# Status bar separators (bubble):
#separator_open = ""
@ -108,12 +112,16 @@ remote_control = { enabled = true, visible = false }
separator_open = ""
separator_close = ""
[ui.results_panel]
border_type = "rounded"
[ui.preview_panel]
# Preview panel size (percentage of screen width/height)
size = 50
#header = "{}"
#footer = ""
scrollbar = true
border_type = "rounded"
[ui.remote_control]
# Whether to show channel descriptions in remote control mode

View File

@ -1,9 +1,10 @@
use crate::cli::parse_source_entry_delimiter;
use crate::config::ui::InputBarConfig;
use crate::{
config::{KeyBindings, ui},
event::Key,
features::Features,
screen::layout::{InputPosition, Orientation},
screen::layout::Orientation,
};
use anyhow::Result;
use rustc_hash::FxHashMap;
@ -387,16 +388,14 @@ pub struct UiSpec {
// `layout` is clearer for the user but collides with the overall `Layout` type.
#[serde(rename = "layout", alias = "orientation", default)]
pub orientation: Option<Orientation>,
#[serde(default)]
pub input_bar_position: Option<InputPosition>,
#[serde(default)]
pub input_header: Option<Template>,
#[serde(default)]
pub input_prompt: Option<String>,
// Feature-specific configurations
#[serde(default)]
pub input_bar: Option<InputBarConfig>,
#[serde(default)]
pub preview_panel: Option<ui::PreviewPanelConfig>,
#[serde(default)]
pub results_panel: Option<ui::ResultsPanelConfig>,
#[serde(default)]
pub status_bar: Option<ui::StatusBarConfig>,
#[serde(default)]
pub help_panel: Option<ui::HelpPanelConfig>,
@ -412,10 +411,9 @@ impl From<&crate::config::UiConfig> for UiSpec {
ui_scale: Some(config.ui_scale),
features: Some(config.features.clone()),
orientation: Some(config.orientation),
input_bar_position: Some(config.input_bar_position),
input_header: config.input_header.clone(),
input_prompt: Some(config.input_prompt.clone()),
input_bar: Some(config.input_bar.clone()),
preview_panel: Some(config.preview_panel.clone()),
results_panel: Some(config.results_panel.clone()),
status_bar: Some(config.status_bar.clone()),
help_panel: Some(config.help_panel.clone()),
remote_control: Some(config.remote_control.clone()),
@ -425,7 +423,10 @@ impl From<&crate::config::UiConfig> for UiSpec {
#[cfg(test)]
mod tests {
use crate::{action::Action, event::Key};
use crate::{
action::Action, config::ui::BorderType, event::Key,
screen::layout::InputPosition,
};
use super::*;
use toml::from_str;
@ -508,16 +509,23 @@ mod tests {
[ui]
layout = "landscape"
ui_scale = 100
input_bar_position = "bottom"
input_header = "Input: {}"
[ui.features]
preview_panel = { enabled = true, visible = true }
[ui.input_bar]
position = "bottom"
header = "Input: {}"
border_type = "plain"
[ui.preview_panel]
size = 66
header = "Preview: {}"
footer = "Press 'q' to quit"
border_type = "thick"
[ui.results_panel]
border_type = "none"
[keybindings]
esc = "quit"
@ -565,29 +573,23 @@ mod tests {
assert!(ui.features.is_some());
let features = ui.features.as_ref().unwrap();
assert!(features.preview_panel.enabled);
assert_eq!(ui.input_bar_position, Some(InputPosition::Bottom));
assert_eq!(ui.preview_panel.as_ref().unwrap().size, 66);
assert_eq!(ui.input_header.as_ref().unwrap().raw(), "Input: {}");
let input_bar = ui.input_bar.as_ref().unwrap();
assert_eq!(input_bar.position, InputPosition::Bottom);
assert_eq!(input_bar.header.as_ref().unwrap().raw(), "Input: {}");
assert_eq!(input_bar.border_type, BorderType::Plain);
let preview_panel = ui.preview_panel.as_ref().unwrap();
assert_eq!(
ui.preview_panel
.as_ref()
.unwrap()
.header
.as_ref()
.unwrap()
.raw(),
preview_panel.header.as_ref().unwrap().raw(),
"Preview: {}"
);
assert_eq!(
ui.preview_panel
.as_ref()
.unwrap()
.footer
.as_ref()
.unwrap()
.raw(),
preview_panel.footer.as_ref().unwrap().raw(),
"Press 'q' to quit"
);
assert_eq!(preview_panel.border_type, BorderType::Thick);
assert_eq!(ui.results_panel.unwrap().border_type, BorderType::None);
let keybindings = prototype.keybindings.unwrap();
assert_eq!(
@ -710,6 +712,9 @@ mod tests {
layout = "landscape"
ui_scale = 40
[ui.input_bar]
border_type = "none"
[ui.preview_panel]
footer = "Press 'q' to quit"
"#;
@ -734,9 +739,11 @@ mod tests {
assert_eq!(ui.orientation, Some(Orientation::Landscape));
assert_eq!(ui.ui_scale, Some(40));
assert!(ui.features.is_none());
assert!(ui.input_bar_position.is_none());
assert_eq!(
ui.input_bar.as_ref().unwrap().border_type,
BorderType::None
);
assert!(ui.preview_panel.is_some());
assert!(ui.input_header.is_none());
assert_eq!(
ui.preview_panel
.as_ref()

View File

@ -33,7 +33,7 @@ pub struct Cli {
/// A list of the available channels can be displayed using the
/// `list-channels` command. The channel can also be changed from within
/// the application.
#[arg(value_enum, index = 1, verbatim_doc_comment)]
#[arg(index = 1, verbatim_doc_comment)]
pub channel: Option<String>,
/// A preview line number offset template to use to scroll the preview to for each
@ -151,6 +151,20 @@ pub struct Cli {
#[arg(long, value_name = "STRING", verbatim_doc_comment)]
pub input_prompt: Option<String>,
/// Sets the input panel border type.
///
/// Available options are: `none`, `plain`, `rounded`, `thick`.
#[arg(long, value_enum, verbatim_doc_comment)]
pub input_border: Option<BorderType>,
/// Sets the input panel padding.
///
/// Format: `top=INTEGER;left=INTEGER;bottom=INTEGER;right=INTEGER`
///
/// Example: `--input-padding='top=1;left=2;bottom=1;right=2'`
#[arg(long, value_name = "STRING", verbatim_doc_comment)]
pub input_padding: Option<String>,
/// Preview header template
///
/// When a channel is specified: This overrides the header defined in the channel prototype.
@ -181,6 +195,30 @@ pub struct Cli {
)]
pub preview_footer: Option<String>,
/// Sets the preview panel border type.
///
/// Available options are: `none`, `plain`, `rounded`, `thick`.
#[arg(
long,
value_enum,
verbatim_doc_comment,
conflicts_with = "no_preview"
)]
pub preview_border: Option<BorderType>,
/// Sets the preview panel padding.
///
/// Format: `top=INTEGER;left=INTEGER;bottom=INTEGER;right=INTEGER`
///
/// Example: `--preview-padding='top=1;left=2;bottom=1;right=2'`
#[arg(
long,
value_name = "STRING",
verbatim_doc_comment,
conflicts_with = "no_preview"
)]
pub preview_padding: Option<String>,
/// Source command to use for the current channel.
///
/// When a channel is specified: This overrides the command defined in the channel prototype.
@ -220,6 +258,20 @@ pub struct Cli {
#[arg(long, value_name = "STRING", verbatim_doc_comment)]
pub source_output: Option<String>,
/// Sets the results panel border type.
///
/// Available options are: `none`, `plain`, `rounded`, `thick`.
#[arg(long, value_enum, verbatim_doc_comment)]
pub results_border: Option<BorderType>,
/// Sets the results panel padding.
///
/// Format: `top=INTEGER;left=INTEGER;bottom=INTEGER;right=INTEGER`
///
/// Example: `--results-padding='top=1;left=2;bottom=1;right=2'`
#[arg(long, value_name = "STRING", verbatim_doc_comment)]
pub results_padding: 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
@ -509,6 +561,14 @@ pub enum LayoutOrientation {
Portrait,
}
#[derive(Debug, Clone, Copy, PartialEq, ValueEnum)]
pub enum BorderType {
None,
Plain,
Rounded,
Thick,
}
// Add validator functions
fn validate_positive_float(s: &str) -> Result<f64, String> {
match s.parse::<f64>() {

View File

@ -4,6 +4,7 @@ use crate::{
cli::args::{Cli, Command},
config::{
DEFAULT_PREVIEW_SIZE, KeyBindings, get_config_dir, get_data_dir,
ui::{BorderType, Padding},
},
errors::cli_parsing_error_exit,
screen::layout::Orientation,
@ -62,6 +63,12 @@ pub struct PostProcessedCli {
pub preview_size: Option<u16>,
pub preview_header: Option<String>,
pub preview_footer: Option<String>,
pub preview_border: Option<BorderType>,
pub preview_padding: Option<Padding>,
// Results panel configuration
pub results_border: Option<BorderType>,
pub results_padding: Option<Padding>,
// Status bar configuration
pub no_status_bar: bool,
@ -82,6 +89,8 @@ pub struct PostProcessedCli {
pub input: Option<String>,
pub input_header: Option<String>,
pub input_prompt: Option<String>,
pub input_border: Option<BorderType>,
pub input_padding: Option<Padding>,
// UI and layout configuration
pub layout: Option<Orientation>,
@ -112,6 +121,8 @@ pub struct PostProcessedCli {
pub command: Option<Command>,
}
const DEFAULT_BORDER_TYPE: BorderType = BorderType::Rounded;
impl Default for PostProcessedCli {
fn default() -> Self {
Self {
@ -134,6 +145,12 @@ impl Default for PostProcessedCli {
preview_size: Some(DEFAULT_PREVIEW_SIZE),
preview_header: None,
preview_footer: None,
preview_border: Some(DEFAULT_BORDER_TYPE),
preview_padding: None,
// Results panel configuration
results_border: Some(DEFAULT_BORDER_TYPE),
results_padding: None,
// Status bar configuration
no_status_bar: false,
@ -154,6 +171,8 @@ impl Default for PostProcessedCli {
input: None,
input_header: None,
input_prompt: None,
input_border: Some(DEFAULT_BORDER_TYPE),
input_padding: None,
// UI and layout configuration
layout: None,
@ -300,11 +319,26 @@ pub fn post_process(cli: Cli, readable_stdin: bool) -> PostProcessedCli {
});
// Determine layout
let layout: Option<Orientation> =
cli.layout.map(|layout_enum| match layout_enum {
args::LayoutOrientation::Landscape => Orientation::Landscape,
args::LayoutOrientation::Portrait => Orientation::Portrait,
});
let layout: Option<Orientation> = cli.layout.map(Orientation::from);
// borders
let input_border = cli.input_border.map(BorderType::from);
let results_border = cli.results_border.map(BorderType::from);
let preview_border = cli.preview_border.map(BorderType::from);
// padding
let input_padding = cli.input_padding.map(|p| {
parse_padding_literal(&p, CLI_PADDING_DELIMITER)
.unwrap_or_else(|e| cli_parsing_error_exit(&e.to_string()))
});
let results_padding = cli.results_padding.map(|p| {
parse_padding_literal(&p, CLI_PADDING_DELIMITER)
.unwrap_or_else(|e| cli_parsing_error_exit(&e.to_string()))
});
let preview_padding = cli.preview_padding.map(|p| {
parse_padding_literal(&p, CLI_PADDING_DELIMITER)
.unwrap_or_else(|e| cli_parsing_error_exit(&e.to_string()))
});
PostProcessedCli {
// Channel and source configuration
@ -326,6 +360,12 @@ pub fn post_process(cli: Cli, readable_stdin: bool) -> PostProcessedCli {
preview_size: cli.preview_size,
preview_header: cli.preview_header,
preview_footer: cli.preview_footer,
preview_border,
preview_padding,
// Results configuration
results_border,
results_padding,
// Status bar configuration
no_status_bar: cli.no_status_bar,
@ -346,6 +386,8 @@ pub fn post_process(cli: Cli, readable_stdin: bool) -> PostProcessedCli {
input: cli.input,
input_header: cli.input_header,
input_prompt: cli.input_prompt,
input_border,
input_padding,
// UI and layout configuration
layout,
@ -432,6 +474,7 @@ fn validate_adhoc_mode_constraints(cli: &Cli, readable_stdin: bool) {
}
const CLI_KEYBINDINGS_DELIMITER: char = ';';
const CLI_PADDING_DELIMITER: char = ';';
/// Parse a keybindings literal into a `KeyBindings` struct.
///
@ -452,6 +495,17 @@ fn parse_keybindings_literal(
toml::from_str(&toml_definition).map_err(|e| anyhow!(e))
}
/// Parses the CLI padding values into `config::ui::Padding`
fn parse_padding_literal(
cli_padding: &str,
delimiter: char,
) -> Result<Padding> {
let toml_definition = cli_padding
.split(delimiter)
.fold(String::new(), |acc, p| acc + p + "\n");
toml::from_str(&toml_definition).map_err(|e| anyhow!(e))
}
pub fn list_channels<P>(cable_dir: P)
where
P: AsRef<Path>,

View File

@ -255,9 +255,6 @@ impl Config {
if let Some(value) = ui_spec.orientation {
self.ui.orientation = value;
}
if let Some(value) = ui_spec.input_bar_position {
self.ui.input_bar_position = value;
}
// Apply clone fields
if let Some(value) = &ui_spec.features {
@ -273,17 +270,26 @@ impl Config {
self.ui.remote_control = value.clone();
}
// Apply input_header
if let Some(value) = &ui_spec.input_header {
self.ui.input_header = Some(value.clone());
// Handle input, results, and preview with field merging
if let Some(input_bar) = &ui_spec.input_bar {
self.ui.input_bar.position = input_bar.position;
if input_bar.header.is_some() {
self.ui.input_bar.header.clone_from(&input_bar.header);
}
self.ui.input_bar.prompt.clone_from(&input_bar.prompt);
self.ui
.input_bar
.border_type
.clone_from(&input_bar.border_type);
self.ui.input_bar.padding = input_bar.padding;
}
// Apply input_prompt
if let Some(value) = &ui_spec.input_prompt {
self.ui.input_prompt.clone_from(value);
if let Some(results_panel) = &ui_spec.results_panel {
self.ui
.results_panel
.border_type
.clone_from(&results_panel.border_type);
self.ui.results_panel.padding = results_panel.padding;
}
// Handle preview_panel with field merging
if let Some(preview_panel) = &ui_spec.preview_panel {
self.ui.preview_panel.size = preview_panel.size;
if let Some(header) = &preview_panel.header {
@ -293,6 +299,11 @@ impl Config {
self.ui.preview_panel.footer = Some(footer.clone());
}
self.ui.preview_panel.scrollbar = preview_panel.scrollbar;
self.ui
.preview_panel
.border_type
.clone_from(&preview_panel.border_type);
self.ui.preview_panel.padding = preview_panel.padding;
}
}
}
@ -364,6 +375,7 @@ pub use ui::{DEFAULT_PREVIEW_SIZE, DEFAULT_UI_SCALE};
#[cfg(test)]
mod tests {
use crate::action::Action;
use crate::config::ui::Padding;
use crate::event::Key;
use super::*;
@ -498,8 +510,8 @@ mod tests {
}
const USER_CONFIG_INPUT_PROMPT: &str = r#"
[ui]
input_prompt = ""
[ui.input_bar]
prompt = ""
"#;
#[test]
@ -518,7 +530,7 @@ mod tests {
let config = Config::new(&config_env, None).unwrap();
// Verify that input_prompt was loaded from user config
assert_eq!(config.ui.input_prompt, "");
assert_eq!(config.ui.input_bar.prompt, "");
}
#[test]
@ -584,4 +596,91 @@ mod tests {
assert_eq!(config.shell_integration.keybindings, expected);
}
#[test]
fn test_apply_prototype_ui_spec() {
use crate::channels::prototypes::Template;
use crate::features::Features;
use crate::screen::layout::{InputPosition, Orientation};
use ui::{
BorderType, HelpPanelConfig, InputBarConfig, PreviewPanelConfig,
RemoteControlConfig, ResultsPanelConfig, StatusBarConfig,
};
let mut features = Features::default();
features.help_panel.disable();
let mut config = Config::default();
config.ui.preview_panel.header =
Some(Template::Raw("cow".to_string()));
let ui_spec = UiSpec {
ui_scale: Some(12),
features: Some(features),
orientation: Some(Orientation::Portrait),
input_bar: Some(InputBarConfig {
position: InputPosition::Bottom,
header: Some(Template::Raw("hello".to_string())),
prompt: "world".to_string(),
border_type: BorderType::Thick,
padding: Padding::uniform(2),
}),
results_panel: Some(ResultsPanelConfig {
border_type: BorderType::None,
padding: Padding::uniform(2),
}),
preview_panel: Some(PreviewPanelConfig {
size: 42,
header: None, // does not overwrite "cow"
footer: Some(Template::Raw("moo".to_string())),
scrollbar: true,
border_type: BorderType::Plain,
padding: Padding::uniform(2),
}),
status_bar: Some(StatusBarConfig {
separator_open: "open".to_string(),
separator_close: "close".to_string(),
}),
help_panel: Some(HelpPanelConfig {
show_categories: true,
}),
remote_control: Some(RemoteControlConfig {
show_channel_descriptions: true,
sort_alphabetically: true,
}),
};
config.apply_prototype_ui_spec(&ui_spec);
assert_eq!(config.ui.ui_scale, 12);
assert!(!config.ui.features.help_panel.enabled);
assert_eq!(config.ui.input_bar.position, InputPosition::Bottom);
assert_eq!(
config.ui.input_bar.header.as_ref().unwrap().raw(),
"hello"
);
assert_eq!(config.ui.input_bar.prompt, "world");
assert_eq!(config.ui.input_bar.border_type, BorderType::Thick);
assert_eq!(config.ui.input_bar.padding, Padding::uniform(2));
assert_eq!(config.ui.results_panel.border_type, BorderType::None);
assert_eq!(config.ui.results_panel.padding, Padding::uniform(2));
assert_eq!(config.ui.preview_panel.size, 42);
assert_eq!(
config.ui.preview_panel.header.as_ref().unwrap().raw(),
"cow"
);
assert_eq!(
config.ui.preview_panel.footer.as_ref().unwrap().raw(),
"moo"
);
assert!(config.ui.preview_panel.scrollbar);
assert_eq!(config.ui.preview_panel.border_type, BorderType::Plain);
assert_eq!(config.ui.preview_panel.padding, Padding::uniform(2));
assert_eq!(config.ui.status_bar.separator_open, "open");
assert_eq!(config.ui.status_bar.separator_close, "close");
assert!(config.ui.remote_control.show_channel_descriptions);
assert!(config.ui.remote_control.sort_alphabetically);
}
}

View File

@ -232,7 +232,7 @@ struct Inner {
// input
input_text_fg: String,
result_count_fg: String,
//results
// results
result_name_fg: String,
result_line_number_fg: String,
result_value_fg: String,
@ -241,9 +241,9 @@ struct Inner {
// and falls back to match_fg
selection_fg: Option<String>,
match_fg: String,
//preview
// preview
preview_title_fg: String,
//modes
// modes
channel_mode_fg: String,
channel_mode_bg: Option<String>,
remote_control_mode_fg: String,

View File

@ -9,6 +9,28 @@ use serde::{Deserialize, Serialize};
pub const DEFAULT_UI_SCALE: u16 = 100;
pub const DEFAULT_PREVIEW_SIZE: u16 = 50;
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Hash)]
#[serde(default)]
pub struct InputBarConfig {
pub position: InputPosition,
pub header: Option<Template>,
pub prompt: String,
pub border_type: BorderType,
pub padding: Padding,
}
impl Default for InputBarConfig {
fn default() -> Self {
Self {
position: InputPosition::default(),
header: None,
prompt: ">".to_string(),
border_type: BorderType::default(),
padding: Padding::uniform(0),
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Hash, Default)]
#[serde(default)]
pub struct StatusBarConfig {
@ -16,6 +38,13 @@ pub struct StatusBarConfig {
pub separator_close: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Hash, Default)]
#[serde(default)]
pub struct ResultsPanelConfig {
pub border_type: BorderType,
pub padding: Padding,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Hash)]
#[serde(default)]
pub struct PreviewPanelConfig {
@ -23,6 +52,8 @@ pub struct PreviewPanelConfig {
pub header: Option<Template>,
pub footer: Option<Template>,
pub scrollbar: bool,
pub border_type: BorderType,
pub padding: Padding,
}
impl Default for PreviewPanelConfig {
@ -32,6 +63,8 @@ impl Default for PreviewPanelConfig {
header: None,
footer: None,
scrollbar: true,
border_type: BorderType::default(),
padding: Padding::uniform(0),
}
}
}
@ -104,17 +137,15 @@ pub struct ThemeOverrides {
pub struct UiConfig {
pub use_nerd_font_icons: bool,
pub ui_scale: u16,
pub input_bar_position: InputPosition,
pub orientation: Orientation,
pub theme: String,
pub input_header: Option<Template>,
#[serde(default = "default_input_prompt")]
pub input_prompt: String,
pub features: Features,
// Feature-specific configurations
pub input_bar: InputBarConfig,
pub status_bar: StatusBarConfig,
pub preview_panel: PreviewPanelConfig,
pub results_panel: ResultsPanelConfig,
pub help_panel: HelpPanelConfig,
pub remote_control: RemoteControlConfig,
@ -123,27 +154,117 @@ pub struct UiConfig {
pub theme_overrides: ThemeOverrides,
}
const DEFAULT_INPUT_PROMPT: &str = ">";
fn default_input_prompt() -> String {
String::from(DEFAULT_INPUT_PROMPT)
}
impl Default for UiConfig {
fn default() -> Self {
Self {
use_nerd_font_icons: false,
ui_scale: DEFAULT_UI_SCALE,
input_bar_position: InputPosition::Top,
orientation: Orientation::Landscape,
theme: String::from(DEFAULT_THEME),
input_header: None,
input_prompt: String::from(DEFAULT_INPUT_PROMPT),
features: Features::default(),
input_bar: InputBarConfig::default(),
status_bar: StatusBarConfig::default(),
preview_panel: PreviewPanelConfig::default(),
results_panel: ResultsPanelConfig::default(),
help_panel: HelpPanelConfig::default(),
remote_control: RemoteControlConfig::default(),
theme_overrides: ThemeOverrides::default(),
}
}
}
#[derive(
Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Hash, Default,
)]
#[serde(rename_all = "snake_case")]
pub enum BorderType {
None,
Plain,
#[default]
Rounded,
Thick,
}
impl BorderType {
pub fn to_ratatui_border_type(
&self,
) -> Option<ratatui::widgets::BorderType> {
match self {
BorderType::None => None,
BorderType::Plain => Some(ratatui::widgets::BorderType::Plain),
BorderType::Rounded => Some(ratatui::widgets::BorderType::Rounded),
BorderType::Thick => Some(ratatui::widgets::BorderType::Thick),
}
}
}
impl From<crate::cli::args::BorderType> for BorderType {
fn from(border_type: crate::cli::args::BorderType) -> Self {
match border_type {
crate::cli::args::BorderType::None => BorderType::None,
crate::cli::args::BorderType::Plain => BorderType::Plain,
crate::cli::args::BorderType::Rounded => BorderType::Rounded,
crate::cli::args::BorderType::Thick => BorderType::Thick,
}
}
}
#[derive(
Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Hash, Default,
)]
#[serde(default)]
pub struct Padding {
pub top: u16,
pub bottom: u16,
pub left: u16,
pub right: u16,
}
impl Padding {
pub fn new(top: u16, bottom: u16, left: u16, right: u16) -> Self {
Self {
top,
bottom,
left,
right,
}
}
pub fn uniform(padding: u16) -> Self {
Self {
top: padding,
bottom: padding,
left: padding,
right: padding,
}
}
pub fn horizontal(padding: u16) -> Self {
Self {
top: 0,
bottom: 0,
left: padding,
right: padding,
}
}
pub fn vertical(padding: u16) -> Self {
Self {
top: padding,
bottom: padding,
left: 0,
right: 0,
}
}
}
impl From<Padding> for ratatui::widgets::Padding {
fn from(padding: Padding) -> Self {
ratatui::widgets::Padding {
top: padding.top,
bottom: padding.bottom,
left: padding.left,
right: padding.right,
}
}
}

View File

@ -191,14 +191,12 @@ pub fn draw(ctx: Box<Ctx>, f: &mut Frame<'_>, area: Rect) -> Result<Layout> {
&ctx.tv_state.results_picker.entries,
&ctx.tv_state.channel_state.selected_entries,
&mut ctx.tv_state.results_picker.relative_state.clone(),
ctx.config.ui.input_bar_position,
ctx.config.ui.input_bar.position,
ctx.config.ui.use_nerd_font_icons,
&ctx.colorscheme,
&ctx.config.ui.results_panel,
)?;
// input box
let input_prompt = ctx.config.ui.input_prompt.clone();
draw_input_box(
f,
layout.input,
@ -210,9 +208,7 @@ pub fn draw(ctx: Box<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.input_header,
&input_prompt,
&ctx.config.ui.input_bar_position,
&ctx.config.ui.input_bar,
)?;
// status bar at the bottom
@ -228,7 +224,7 @@ pub fn draw(ctx: Box<Ctx>, f: &mut Frame<'_>, area: Rect) -> Result<Layout> {
ctx.tv_state.preview_state,
ctx.config.ui.use_nerd_font_icons,
&ctx.colorscheme,
ctx.config.ui.preview_panel.scrollbar,
&ctx.config.ui.preview_panel,
)?;
}

View File

@ -4,11 +4,10 @@ use std::env;
use std::io::{BufWriter, IsTerminal, Write, stdout};
use std::path::PathBuf;
use std::process::exit;
use television::cable::cable_empty_exit;
use television::{
action::Action,
app::{App, AppOptions},
cable::{Cable, load_cable},
cable::{Cable, cable_empty_exit, load_cable},
channels::prototypes::{
ChannelPrototype, CommandSpec, PreviewSpec, Template, UiSpec,
},
@ -18,7 +17,7 @@ use television::{
args::{Cli, Command},
guess_channel_from_prompt, list_channels,
},
config::{Config, ConfigEnv, merge_bindings},
config::{Config, ConfigEnv, merge_bindings, ui::InputBarConfig},
errors::os_error_exit,
features::FeatureFlags,
gh::update_local_channels,
@ -212,11 +211,11 @@ fn apply_cli_overrides(args: &PostProcessedCli, config: &mut Config) {
config.ui.ui_scale = args.ui_scale.unwrap_or(config.ui.ui_scale);
if let Some(input_header) = &args.input_header {
if let Ok(t) = Template::parse(input_header) {
config.ui.input_header = Some(t);
config.ui.input_bar.header = Some(t);
}
}
if let Some(input_prompt) = &args.input_prompt {
config.ui.input_prompt.clone_from(input_prompt);
config.ui.input_bar.prompt.clone_from(input_prompt);
}
if let Some(preview_header) = &args.preview_header {
if let Ok(t) = Template::parse(preview_header) {
@ -231,6 +230,24 @@ fn apply_cli_overrides(args: &PostProcessedCli, config: &mut Config) {
if let Some(layout) = args.layout {
config.ui.orientation = layout;
}
if let Some(input_border) = args.input_border {
config.ui.input_bar.border_type = input_border;
}
if let Some(preview_border) = args.preview_border {
config.ui.preview_panel.border_type = preview_border;
}
if let Some(results_border) = args.results_border {
config.ui.results_panel.border_type = results_border;
}
if let Some(input_padding) = args.input_padding {
config.ui.input_bar.padding = input_padding;
}
if let Some(preview_padding) = args.preview_padding {
config.ui.preview_panel.padding = preview_padding;
}
if let Some(results_padding) = args.results_padding {
config.ui.results_panel.padding = results_padding;
}
}
pub fn set_current_dir(path: &PathBuf) -> Result<()> {
@ -330,7 +347,10 @@ fn create_adhoc_channel(
// Set UI specification
let mut ui_spec = UiSpec::from(&config.ui);
ui_spec.input_header = Some(input_header);
let input_bar = ui_spec
.input_bar
.get_or_insert_with(InputBarConfig::default);
input_bar.header = Some(input_header);
ui_spec.features = Some(features);
prototype.ui = Some(ui_spec);
@ -389,10 +409,9 @@ fn apply_ui_overrides(
ui_scale: None,
features: None,
orientation: None,
input_bar_position: None,
input_header: None,
input_prompt: None,
input_bar: None,
preview_panel: None,
results_panel: None,
status_bar: None,
help_panel: None,
remote_control: None,
@ -401,14 +420,74 @@ fn apply_ui_overrides(
// Apply input header override
if let Some(input_header_str) = &args.input_header {
if let Ok(template) = Template::parse(input_header_str) {
ui_spec.input_header = Some(template);
let input_bar = ui_spec
.input_bar
.get_or_insert_with(InputBarConfig::default);
input_bar.header = Some(template);
ui_changes_needed = true;
}
}
// Apply input prompt override
if let Some(input_prompt_str) = &args.input_prompt {
ui_spec.input_prompt = Some(input_prompt_str.clone());
let input_bar = ui_spec
.input_bar
.get_or_insert_with(InputBarConfig::default);
input_bar.prompt.clone_from(input_prompt_str);
ui_changes_needed = true;
}
// Apply input bar border override
if let Some(input_border) = args.input_border {
let input_bar = ui_spec
.input_bar
.get_or_insert_with(InputBarConfig::default);
input_bar.border_type = input_border;
ui_changes_needed = true;
}
// Apply input bar padding override
if let Some(input_padding) = &args.input_padding {
let input_bar = ui_spec
.input_bar
.get_or_insert_with(InputBarConfig::default);
input_bar.padding = *input_padding;
ui_changes_needed = true;
}
// Apply preview panel border override
if let Some(preview_border) = args.preview_border {
let preview_panel = ui_spec.preview_panel.get_or_insert_with(
television::config::ui::PreviewPanelConfig::default,
);
preview_panel.border_type = preview_border;
ui_changes_needed = true;
}
// Apply preview panel padding override
if let Some(preview_padding) = &args.preview_padding {
let preview_panel = ui_spec.preview_panel.get_or_insert_with(
television::config::ui::PreviewPanelConfig::default,
);
preview_panel.padding = *preview_padding;
ui_changes_needed = true;
}
// Apply results panel border override
if let Some(results_border) = args.results_border {
let results_panel = ui_spec.results_panel.get_or_insert_with(
television::config::ui::ResultsPanelConfig::default,
);
results_panel.border_type = results_border;
ui_changes_needed = true;
}
// Apply results panel padding override
if let Some(results_padding) = &args.results_padding {
let results_panel = ui_spec.results_panel.get_or_insert_with(
television::config::ui::ResultsPanelConfig::default,
);
results_panel.padding = *results_padding;
ui_changes_needed = true;
}
@ -507,8 +586,12 @@ pub fn determine_channel(
#[cfg(test)]
mod tests {
use rustc_hash::FxHashMap;
use television::channels::prototypes::{
ChannelPrototype, CommandSpec, PreviewSpec, Template,
use television::{
channels::prototypes::{
ChannelPrototype, CommandSpec, PreviewSpec, Template,
},
config::ui::{BorderType, InputBarConfig, Padding},
screen::layout::InputPosition,
};
use super::*;
@ -716,7 +799,7 @@ mod tests {
// Preview should be disabled since no preview command was provided
assert!(!features.is_enabled(FeatureFlags::PreviewPanel));
assert_eq!(
ui_spec.input_header,
ui_spec.input_bar.as_ref().unwrap().header,
Some(Template::parse("Custom Channel").unwrap())
);
}
@ -730,6 +813,12 @@ mod tests {
input_header: Some("Input Header".to_string()),
preview_header: Some("Preview Header".to_string()),
preview_footer: Some("Preview Footer".to_string()),
preview_border: Some(BorderType::Thick),
input_border: Some(BorderType::Thick),
results_border: Some(BorderType::Thick),
input_padding: Some(Padding::new(1, 2, 3, 4)),
preview_padding: Some(Padding::new(5, 6, 7, 8)),
results_padding: Some(Padding::new(9, 10, 11, 12)),
..Default::default()
};
apply_cli_overrides(&args, &mut config);
@ -737,7 +826,7 @@ mod tests {
assert_eq!(config.application.tick_rate, 100_f64);
assert!(!config.ui.features.is_enabled(FeatureFlags::PreviewPanel));
assert_eq!(
config.ui.input_header,
config.ui.input_bar.header,
Some(Template::parse("Input Header").unwrap())
);
assert_eq!(
@ -748,6 +837,15 @@ mod tests {
config.ui.preview_panel.footer,
Some(Template::parse("Preview Footer").unwrap())
);
assert_eq!(config.ui.preview_panel.border_type, BorderType::Thick);
assert_eq!(config.ui.input_bar.border_type, BorderType::Thick);
assert_eq!(config.ui.results_panel.border_type, BorderType::Thick);
assert_eq!(config.ui.input_bar.padding, Padding::new(1, 2, 3, 4));
assert_eq!(config.ui.preview_panel.padding, Padding::new(5, 6, 7, 8));
assert_eq!(
config.ui.results_panel.padding,
Padding::new(9, 10, 11, 12)
);
}
#[test]
@ -761,11 +859,15 @@ mod tests {
ui_scale: None,
features: None,
orientation: Some(Orientation::Portrait),
input_bar_position: None,
input_header: Some(Template::parse("Original Header").unwrap()),
input_prompt: None,
input_bar: Some(InputBarConfig {
position: InputPosition::default(),
header: Some(Template::parse("Original Header").unwrap()),
prompt: ">".to_string(),
border_type: BorderType::Thick,
padding: Padding::uniform(1),
}),
preview_panel: Some(television::config::ui::PreviewPanelConfig {
size: 50,
size: 60,
header: Some(
Template::parse("Original Preview Header").unwrap(),
),
@ -773,6 +875,12 @@ mod tests {
Template::parse("Original Preview Footer").unwrap(),
),
scrollbar: false,
border_type: BorderType::Thick,
padding: Padding::uniform(2),
}),
results_panel: Some(television::config::ui::ResultsPanelConfig {
border_type: BorderType::Thick,
padding: Padding::uniform(2),
}),
status_bar: None,
help_panel: None,
@ -788,6 +896,12 @@ mod tests {
preview_header: Some("CLI Preview Header".to_string()),
preview_footer: Some("CLI Preview Footer".to_string()),
layout: Some(Orientation::Landscape),
input_border: Some(BorderType::Plain),
preview_border: Some(BorderType::Plain),
results_border: Some(BorderType::Plain),
input_padding: Some(Padding::new(1, 2, 3, 4)),
preview_padding: Some(Padding::new(5, 6, 7, 8)),
results_padding: Some(Padding::new(9, 10, 11, 12)),
..Default::default()
};
let config = Config::default();
@ -800,7 +914,7 @@ mod tests {
let ui_spec = result_channel.ui.as_ref().unwrap();
assert_eq!(
ui_spec.input_header,
ui_spec.input_bar.as_ref().unwrap().header,
Some(Template::parse("CLI Input Header").unwrap())
);
assert_eq!(ui_spec.orientation, Some(Orientation::Landscape));
@ -815,6 +929,26 @@ mod tests {
preview_panel.footer,
Some(Template::parse("CLI Preview Footer").unwrap())
);
assert_eq!(preview_panel.border_type, BorderType::Plain);
assert_eq!(preview_panel.padding, Padding::new(5, 6, 7, 8));
assert_eq!(
ui_spec.results_panel.as_ref().unwrap().border_type,
BorderType::Plain
);
assert_eq!(
ui_spec.results_panel.as_ref().unwrap().padding,
Padding::new(9, 10, 11, 12)
);
assert_eq!(
ui_spec.input_bar.as_ref().unwrap().border_type,
BorderType::Plain
);
assert_eq!(
ui_spec.input_bar.as_ref().unwrap().padding,
Padding::new(1, 2, 3, 4)
);
}
#[test]

View File

@ -1,5 +1,5 @@
use crate::{
channels::prototypes::Template,
config::ui::InputBarConfig,
screen::{colors::Colorscheme, layout::InputPosition, spinner::Spinner},
utils::input::Input,
};
@ -12,7 +12,7 @@ use ratatui::{
style::{Style, Stylize},
text::{Line, Span},
widgets::{
Block, BorderType, Borders, ListState, Paragraph, block::Position,
Block, Borders, ListState, Padding, Paragraph, block::Position,
},
};
@ -28,19 +28,15 @@ pub fn draw_input_box(
channel_name: &str,
spinner: &Spinner,
colorscheme: &Colorscheme,
input_header: &Option<Template>,
input_prompt: &str,
input_bar_position: &InputPosition,
input_bar_config: &InputBarConfig,
) -> Result<()> {
let header = input_header
let header = input_bar_config
.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)
.border_style(Style::default().fg(colorscheme.general.border_fg))
.title_position(match input_bar_position {
let mut input_block = Block::default()
.title_position(match input_bar_config.position {
InputPosition::Top => Position::Top,
InputPosition::Bottom => Position::Bottom,
})
@ -52,7 +48,16 @@ pub fn draw_input_box(
.style(
Style::default()
.bg(colorscheme.general.background.unwrap_or_default()),
);
)
.padding(Padding::from(input_bar_config.padding));
if let Some(border_type) =
input_bar_config.border_type.to_ratatui_border_type()
{
input_block = input_block
.borders(Borders::ALL)
.border_type(border_type)
.border_style(Style::default().fg(colorscheme.general.border_fg));
}
let input_block_inner = input_block.inner(rect);
if input_block_inner.area() == 0 {
@ -67,7 +72,8 @@ pub fn draw_input_box(
.constraints([
// prompt symbol + space
Constraint::Length(
u16::try_from(input_prompt.chars().count() + 1).unwrap_or(2),
u16::try_from(input_bar_config.prompt.chars().count() + 1)
.unwrap_or(2),
),
// input field
Constraint::Fill(1),
@ -84,7 +90,7 @@ pub fn draw_input_box(
let arrow_block = Block::default();
let arrow = Paragraph::new(Span::styled(
format!("{} ", input_prompt),
format!("{} ", input_bar_config.prompt),
Style::default().fg(colorscheme.input.input_fg).bold(),
))
.block(arrow_block);

View File

@ -70,6 +70,19 @@ pub enum Orientation {
Portrait,
}
impl From<crate::cli::args::LayoutOrientation> for Orientation {
fn from(value: crate::cli::args::LayoutOrientation) -> Self {
match value {
crate::cli::args::LayoutOrientation::Landscape => {
Orientation::Landscape
}
crate::cli::args::LayoutOrientation::Portrait => {
Orientation::Portrait
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Layout {
pub results: Rect,
@ -194,7 +207,7 @@ impl Layout {
// results percentage is whatever remains
let results_percentage = 100u16.saturating_sub(preview_percentage);
match (ui_config.orientation, ui_config.input_bar_position) {
match (ui_config.orientation, ui_config.input_bar.position) {
// Preview is rendered on the right or bottom depending on orientation
(Orientation::Landscape, _)
| (Orientation::Portrait, InputPosition::Top) => {
@ -243,7 +256,7 @@ impl Layout {
// Now split the results window vertically into results list + input
let result_chunks = layout::Layout::default()
.direction(Direction::Vertical)
.constraints(match ui_config.input_bar_position {
.constraints(match ui_config.input_bar.position {
InputPosition::Top => results_constraints
.clone()
.into_iter()
@ -254,7 +267,8 @@ impl Layout {
.split(result_window);
let (input_rect, results_rect) = match ui_config
.input_bar_position
.input_bar
.position
{
InputPosition::Bottom => {
(result_chunks[1], result_chunks[0])
@ -278,7 +292,7 @@ impl Layout {
let mut portrait_constraints: Vec<Constraint> = Vec::new();
match ui_config.input_bar_position {
match ui_config.input_bar.position {
InputPosition::Top => {
// Input bar is always the first chunk
portrait_constraints

View File

@ -1,5 +1,6 @@
use crate::{
previewer::state::PreviewState,
config::ui::{BorderType, Padding, PreviewPanelConfig},
previewer::{Preview, state::PreviewState},
screen::colors::Colorscheme,
utils::strings::{
EMPTY_STRING, ReplaceNonPrintableConfig, replace_non_printable,
@ -7,14 +8,13 @@ use crate::{
},
};
use anyhow::Result;
use devicons::FileIcon;
use ratatui::{
Frame,
layout::{Alignment, Rect},
prelude::{Color, Line, Span, Style, Stylize, Text},
widgets::{
Block, BorderType, Borders, Clear, Padding, Paragraph, Scrollbar,
ScrollbarOrientation, ScrollbarState, StatefulWidget,
Block, Borders, Clear, Padding as RatatuiPadding, Paragraph,
Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget,
},
};
use std::str::FromStr;
@ -26,15 +26,15 @@ pub fn draw_preview_content_block(
preview_state: PreviewState,
use_nerd_font_icons: bool,
colorscheme: &Colorscheme,
scrollbar_enabled: bool,
preview_panel_config: &PreviewPanelConfig,
) -> Result<()> {
let inner = draw_content_outer_block(
f,
rect,
colorscheme,
preview_state.preview.icon,
&preview_state.preview.title,
&preview_state.preview.footer,
preview_panel_config.border_type,
preview_panel_config.padding,
&preview_state.preview,
use_nerd_font_icons,
)?;
let scroll = preview_state.scroll as usize;
@ -50,7 +50,7 @@ pub fn draw_preview_content_block(
f.render_widget(rp, inner);
// render scrollbar if enabled
if scrollbar_enabled {
if preview_panel_config.scrollbar {
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.style(Style::default().fg(colorscheme.general.border_fg));
@ -76,12 +76,14 @@ pub fn build_preview_paragraph(
highlight_bg: Color,
) -> Paragraph<'static> {
let preview_block =
Block::default().style(Style::default()).padding(Padding {
top: 0,
right: 1,
bottom: 0,
left: 1,
});
Block::default()
.style(Style::default())
.padding(RatatuiPadding {
top: 0,
right: 1,
bottom: 0,
left: 1,
});
build_ansi_text_paragraph(
preview_state.preview.content,
@ -165,15 +167,15 @@ fn draw_content_outer_block(
f: &mut Frame,
rect: Rect,
colorscheme: &Colorscheme,
icon: Option<FileIcon>,
title: &str,
footer: &str,
border_type: BorderType,
padding: Padding,
preview: &Preview,
use_nerd_font_icons: bool,
) -> Result<Rect> {
let mut preview_title_spans = vec![Span::from(" ")];
// optional icon
if icon.is_some() && use_nerd_font_icons {
let icon = icon.as_ref().unwrap();
if preview.icon.is_some() && use_nerd_font_icons {
let icon = preview.icon.as_ref().unwrap();
preview_title_spans.push(Span::styled(
{
let mut icon_str = String::from(icon.icon);
@ -187,7 +189,7 @@ fn draw_content_outer_block(
preview_title_spans.push(Span::styled(
shrink_with_ellipsis(
&replace_non_printable(
title.as_bytes(),
preview.title.as_bytes(),
&ReplaceNonPrintableConfig::default(),
)
.0,
@ -205,10 +207,10 @@ fn draw_content_outer_block(
);
// preview footer
if !footer.is_empty() {
if !preview.footer.is_empty() {
let footer_line = Line::from(vec![
Span::from(" "),
Span::from(footer),
Span::from(preview.footer.as_str()),
Span::from(" "),
])
.alignment(Alignment::Center)
@ -216,15 +218,18 @@ fn draw_content_outer_block(
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))
let mut preview_outer_block = block
.style(
Style::default()
.bg(colorscheme.general.background.unwrap_or_default()),
)
.padding(Padding::new(0, 1, 1, 0));
.padding(RatatuiPadding::from(padding));
if let Some(border_type) = border_type.to_ratatui_border_type() {
preview_outer_block = preview_outer_block
.borders(Borders::ALL)
.border_type(border_type)
.border_style(Style::default().fg(colorscheme.general.border_fg));
}
let inner = preview_outer_block.inner(rect);
f.render_widget(preview_outer_block, rect);

View File

@ -1,5 +1,6 @@
use crate::{
channels::entry::Entry,
config::ui::ResultsPanelConfig,
screen::{colors::Colorscheme, layout::InputPosition, result_item},
};
use anyhow::Result;
@ -8,7 +9,7 @@ use ratatui::{
layout::{Alignment, Rect},
prelude::Style,
text::Line,
widgets::{Block, BorderType, Borders, ListState, Padding},
widgets::{Block, Borders, ListState, Padding},
};
use rustc_hash::FxHashSet;
@ -22,17 +23,23 @@ pub fn draw_results_list(
input_bar_position: InputPosition,
use_nerd_font_icons: bool,
colorscheme: &Colorscheme,
results_panel_config: &ResultsPanelConfig,
) -> Result<()> {
let results_block = Block::default()
let mut results_block = Block::default()
.title_top(Line::from(" Results ").alignment(Alignment::Center))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(colorscheme.general.border_fg))
.style(
Style::default()
.bg(colorscheme.general.background.unwrap_or_default()),
)
.padding(Padding::right(1));
.padding(Padding::from(results_panel_config.padding));
if let Some(border_type) =
results_panel_config.border_type.to_ratatui_border_type()
{
results_block = results_block
.borders(Borders::ALL)
.border_type(border_type)
.border_style(Style::default().fg(colorscheme.general.border_fg));
}
let list_direction = match input_bar_position {
InputPosition::Bottom => ratatui::widgets::ListDirection::BottomToTop,

View File

@ -111,7 +111,7 @@ impl Television {
debug!("Merged config: {:?}", config);
let mut results_picker = Picker::new(input.clone());
if config.ui.input_bar_position == InputPosition::Bottom {
if config.ui.input_bar.position == InputPosition::Bottom {
results_picker = results_picker.inverted();
}
@ -206,9 +206,11 @@ 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.input_bar.header.is_none() {
if let Some(input_bar) = &ui_spec.input_bar {
if let Some(header_tpl) = &input_bar.header {
config.ui.input_bar.header = Some(header_tpl.clone());
}
}
}
if config.ui.preview_panel.header.is_none() {