feat(ui): new keybindings panel and status bar (#559)

Add a new keybinding manager (à la which-key helix style)

it adapts to the current mode

channel mode

![image](https://github.com/user-attachments/assets/d1748d93-d62b-4377-a4cd-9825318300e0)

remote mode

![image](https://github.com/user-attachments/assets/fa6ab776-a9bd-421c-ae75-a0b6013401fa)

Add sorting for remote entries

---------

Co-authored-by: Alex Pasmantier <alex.pasmant@gmail.com>
This commit is contained in:
LM 2025-06-24 15:21:20 +02:00 committed by Alex Pasmantier
parent a81a86f1fd
commit ad4e254ae6
33 changed files with 769 additions and 959 deletions

View File

@ -42,19 +42,16 @@ use_nerd_font_icons = false
# │ │
# └───────────────────────────────────────┘
ui_scale = 100
# Whether to show the top help bar in the UI by default
# This option can be toggled with the (default) `ctrl-g` keybinding
show_help_bar = false
# Whether to show the preview panel in the UI by default
# This option can be toggled with the (default) `ctrl-o` keybinding
show_preview_panel = true
# Whether to show the keybinding panel in the UI by default
# This option can be toggled with the (default) `ctrl-h` keybinding
show_keybinding_panel = false
# Where to place the input bar in the UI (top or bottom)
input_bar_position = "top"
# What orientation should tv be (landscape or portrait)
orientation = "landscape"
# DEPRECATED: title is now always displayed at the top as part of the border
# Where to place the preview title in the UI (top or bottom)
# preview_title_position = "top"
# The theme to use for the UI
# A list of builtin themes can be found in the `themes` directory of the television
# repository. You may also create your own theme by creating a new file in a `themes`
@ -62,6 +59,16 @@ orientation = "landscape"
theme = "default"
# The default size of the preview panel (in percentage of the screen)
preview_size = 50
show_status_bar = true
# Status bar separators (bubble):
#status_separator_open = ""
#status_separator_close = ""
# Status bar separators (box):
status_separator_open = ""
status_separator_close = ""
# Keybindings
# ----------------------------------------------------------------------------
@ -89,10 +96,10 @@ confirm_selection = "enter"
copy_entry_to_clipboard = "ctrl-y"
# Toggle the remote control mode
toggle_remote_control = "ctrl-t"
# Toggle the help bar
toggle_help = "ctrl-g"
# Toggle the preview panel
toggle_preview = "ctrl-o"
# Toggle the keybinding panel
toggle_help = "ctrl-h"
# Reload the current source
reload_source = "ctrl-r"
# Cycle through the available sources for the current channel

View File

@ -450,7 +450,6 @@ pub fn draw(c: &mut Criterion) {
None,
false,
false,
false,
Some(50),
false,
cable.clone(),

View File

@ -229,12 +229,6 @@ Television's options are organized by functionality. Each option behaves differe
- **Range**: 10-100%
- **Use Case**: Adapt to different screen sizes or preferences
#### `--no-help`
**Purpose**: Hides the help panel showing keyboard shortcuts
- **Both Modes**: Removes help information from display
- **Use Case**: More screen space, cleaner interface for experienced users
#### `--no-remote`
**Purpose**: Hides the remote control panel

View File

@ -101,12 +101,12 @@ pub enum Action {
/// Quit the application.
#[serde(alias = "quit")]
Quit,
/// Toggle the help bar.
#[serde(alias = "toggle_help")]
ToggleHelp,
/// Toggle the preview panel.
#[serde(alias = "toggle_preview")]
TogglePreview,
/// Toggle the keybinding panel.
#[serde(alias = "toggle_help")]
ToggleHelp,
/// Signal an error with the given message.
#[serde(skip)]
Error(String),

View File

@ -29,8 +29,6 @@ pub struct AppOptions {
pub take_1_fast: bool,
/// Whether the application should disable the remote control feature.
pub no_remote: bool,
/// Whether the application should disable the help panel feature.
pub no_help: bool,
/// Whether the application should disable the preview panel feature.
pub no_preview: bool,
/// The size of the preview panel in lines/columns.
@ -49,7 +47,6 @@ impl Default for AppOptions {
take_1: false,
take_1_fast: false,
no_remote: false,
no_help: false,
no_preview: false,
preview_size: Some(DEFAULT_PREVIEW_SIZE),
tick_rate: default_tick_rate(),
@ -67,7 +64,6 @@ impl AppOptions {
take_1: bool,
take_1_fast: bool,
no_remote: bool,
no_help: bool,
no_preview: bool,
preview_size: Option<u16>,
tick_rate: f64,
@ -79,7 +75,6 @@ impl AppOptions {
take_1,
take_1_fast,
no_remote,
no_help,
no_preview,
preview_size,
tick_rate,
@ -182,7 +177,6 @@ impl App {
config,
input,
options.no_remote,
options.no_help,
options.no_preview,
options.preview_size,
options.exact,

View File

@ -332,8 +332,6 @@ pub struct UiSpec {
#[serde(default)]
pub ui_scale: Option<u16>,
#[serde(default)]
pub show_help_bar: Option<bool>,
#[serde(default)]
pub show_preview_panel: Option<bool>,
// `layout` is clearer for the user but collides with the overall `Layout` type.
#[serde(rename = "layout", alias = "orientation", default)]
@ -437,7 +435,6 @@ mod tests {
[ui]
layout = "landscape"
ui_scale = 100
show_help_bar = false
show_preview_panel = true
input_bar_position = "bottom"
preview_size = 66
@ -483,7 +480,6 @@ mod tests {
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));
@ -624,7 +620,6 @@ mod tests {
let ui = prototype.ui.unwrap();
assert_eq!(ui.orientation, Some(Orientation::Landscape));
assert_eq!(ui.ui_scale, Some(40));
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());

View File

@ -80,7 +80,12 @@ impl RemoteControl {
let matcher =
Matcher::new(&Config::default().n_threads(Some(NUM_THREADS)));
let injector = matcher.injector();
for (channel_name, prototype) in cable_channels.iter() {
// Sort channels alphabetically by name for consistent ordering
let mut sorted_channels: Vec<_> = cable_channels.iter().collect();
sorted_channels.sort_by(|a, b| a.0.cmp(b.0));
for (channel_name, prototype) in sorted_channels {
let channel_shortcut = prototype
.keybindings
.as_ref()

View File

@ -312,18 +312,6 @@ pub struct Cli {
#[arg(long, default_value = "false", verbatim_doc_comment)]
pub no_remote: bool,
/// Disable the help panel.
///
/// This flag works identically in both channel mode and ad-hoc mode.
///
/// This will disable the help panel and associated toggling actions
/// entirely. This is useful when the help panel is not needed or
/// when the user wants `tv` to run with a minimal interface (e.g. when
/// using it as a file picker for a script or embedding it in a larger
/// application).
#[arg(long, default_value = "false", verbatim_doc_comment)]
pub no_help: bool,
/// Change the display size in relation to the available area.
///
/// This flag works identically in both channel mode and ad-hoc mode.

View File

@ -66,7 +66,6 @@ pub struct PostProcessedCli {
pub layout: Option<Orientation>,
pub ui_scale: u16,
pub no_remote: bool,
pub no_help: bool,
// Behavior and matching configuration
pub exact: bool,
@ -114,7 +113,6 @@ impl Default for PostProcessedCli {
layout: None,
ui_scale: DEFAULT_UI_SCALE,
no_remote: false,
no_help: false,
// Behavior and matching configuration
exact: false,
@ -253,7 +251,6 @@ pub fn post_process(cli: Cli) -> PostProcessedCli {
layout,
ui_scale: cli.ui_scale,
no_remote: cli.no_remote,
no_help: cli.no_help,
// Behavior and matching configuration
exact: cli.exact,

View File

@ -359,8 +359,6 @@ mod tests {
toggle_remote_control = "ctrl-r"
# Toggle the send to channel mode
toggle_send_to_channel = "ctrl-s"
# Toggle the help bar
toggle_help = "ctrl-g"
# Toggle the preview panel
toggle_preview = "ctrl-o"
"#,
@ -415,7 +413,6 @@ mod tests {
Action::ToggleSendToChannel,
Binding::SingleKey(Key::Ctrl('s'))
),
(Action::ToggleHelp, Binding::SingleKey(Key::Ctrl('g'))),
(Action::TogglePreview, Binding::SingleKey(Key::Ctrl('o'))),
])
);

View File

@ -11,6 +11,7 @@ pub use keybindings::merge_keybindings;
pub use keybindings::{Binding, KeyBindings, parse_key};
use serde::{Deserialize, Serialize};
use shell_integration::ShellIntegrationConfig;
pub use themes::Theme;
use tracing::{debug, warn};
pub use ui::UiConfig;
@ -23,7 +24,7 @@ use crate::{
pub mod keybindings;
pub mod shell_integration;
mod themes;
mod ui;
pub mod ui;
const DEFAULT_CONFIG: &str = include_str!("../../.config/config.toml");
@ -224,9 +225,6 @@ impl Config {
if let Some(ui_scale) = &ui_spec.ui_scale {
self.ui.ui_scale = *ui_scale;
}
if let Some(show_help_bar) = &ui_spec.show_help_bar {
self.ui.show_help_bar = *show_help_bar;
}
if let Some(show_preview_panel) = &ui_spec.show_preview_panel {
self.ui.show_preview_panel = *show_preview_panel;
}
@ -394,7 +392,6 @@ mod tests {
theme = "something"
[keybindings]
toggle_help = ["ctrl-a", "ctrl-b"]
confirm_selection = "ctrl-enter"
[shell_integration.commands]
@ -426,10 +423,6 @@ mod tests {
default_config.ui.theme = "television".to_string();
default_config.keybindings.extend({
let mut map = FxHashMap::default();
map.insert(
Action::ToggleHelp,
Binding::MultipleKeys(vec![Key::Ctrl('a'), Key::Ctrl('b')]),
);
map.insert(
Action::ConfirmSelection,
Binding::SingleKey(Key::CtrlEnter),

View File

@ -108,7 +108,9 @@ pub struct Theme {
pub preview_title_fg: Color,
// modes
pub channel_mode_fg: Color,
pub channel_mode_bg: Color,
pub remote_control_mode_fg: Color,
pub remote_control_mode_bg: Color,
}
impl Theme {
@ -178,7 +180,9 @@ struct Inner {
preview_title_fg: String,
//modes
channel_mode_fg: String,
channel_mode_bg: String,
remote_control_mode_fg: String,
remote_control_mode_bg: String,
}
impl<'de> Deserialize<'de> for Theme {
@ -297,6 +301,13 @@ impl<'de> Deserialize<'de> for Theme {
&inner.channel_mode_fg
))
})?,
channel_mode_bg: Color::from_str(&inner.channel_mode_bg)
.ok_or_else(|| {
serde::de::Error::custom(format!(
"invalid color {}",
&inner.channel_mode_bg
))
})?,
remote_control_mode_fg: Color::from_str(
&inner.remote_control_mode_fg,
)
@ -306,6 +317,15 @@ impl<'de> Deserialize<'de> for Theme {
&inner.remote_control_mode_fg
))
})?,
remote_control_mode_bg: Color::from_str(
&inner.remote_control_mode_bg,
)
.ok_or_else(|| {
serde::de::Error::custom(format!(
"invalid color {}",
&inner.remote_control_mode_bg
))
})?,
})
}
}
@ -426,8 +446,10 @@ impl Into<InputColorscheme> for &Theme {
impl Into<ModeColorscheme> for &Theme {
fn into(self) -> ModeColorscheme {
ModeColorscheme {
channel: (&self.channel_mode_fg).into(),
remote_control: (&self.remote_control_mode_fg).into(),
channel: (&self.channel_mode_bg).into(),
channel_fg: (&self.channel_mode_fg).into(),
remote_control: (&self.remote_control_mode_bg).into(),
remote_control_fg: (&self.remote_control_mode_fg).into(),
}
}
}
@ -453,7 +475,9 @@ mod tests {
match_fg = "bright-white"
preview_title_fg = "bright-white"
channel_mode_fg = "bright-white"
channel_mode_bg = "bright-black"
remote_control_mode_fg = "bright-white"
remote_control_mode_bg = "bright-black"
"##;
let theme: Theme = toml::from_str(theme_content).unwrap();
assert_eq!(
@ -501,7 +525,9 @@ mod tests {
match_fg = "bright-white"
preview_title_fg = "bright-white"
channel_mode_fg = "bright-white"
channel_mode_bg = "bright-black"
remote_control_mode_fg = "bright-white"
remote_control_mode_bg = "bright-black"
"##;
let theme: Theme = toml::from_str(theme_content).unwrap();
assert_eq!(theme.background, None);

View File

@ -16,9 +16,13 @@ pub const DEFAULT_PREVIEW_SIZE: u16 = 50;
pub struct UiConfig {
pub use_nerd_font_icons: bool,
pub ui_scale: u16,
pub no_help: bool,
pub show_help_bar: bool,
pub show_preview_panel: bool,
pub show_keybinding_panel: bool,
pub show_status_bar: bool,
#[serde(default)]
pub status_separator_open: String,
#[serde(default)]
pub status_separator_close: String,
#[serde(default)]
pub input_bar_position: InputPosition,
pub orientation: Orientation,
@ -38,9 +42,11 @@ impl Default for UiConfig {
Self {
use_nerd_font_icons: false,
ui_scale: DEFAULT_UI_SCALE,
no_help: false,
show_help_bar: false,
show_preview_panel: true,
show_keybinding_panel: false,
show_status_bar: true,
status_separator_open: String::new(),
status_separator_close: String::new(),
input_bar_position: InputPosition::Top,
orientation: Orientation::Landscape,
preview_title_position: None,

View File

@ -5,17 +5,16 @@ use ratatui::{Frame, layout::Rect};
use rustc_hash::FxHashSet;
use crate::{
action::Action,
channels::{entry::Entry, remote_control::CableEntry},
config::Config,
picker::Picker,
previewer::state::PreviewState,
screen::{
colors::Colorscheme, help::draw_help_bar, input::draw_input_box,
keybindings::build_keybindings_table, layout::Layout,
colors::Colorscheme, input::draw_input_box,
keybinding_panel::draw_keybinding_panel, layout::Layout,
preview::draw_preview_content_block,
remote_control::draw_remote_control, results::draw_results_list,
spinner::Spinner,
spinner::Spinner, status_bar::draw_status_bar,
},
television::Mode,
utils::metadata::AppMetadata,
@ -135,38 +134,6 @@ impl Ctx {
}
}
// impl PartialEq for Ctx {
// fn eq(&self, other: &Self) -> bool {
// self.tv_state == other.tv_state
// && self.config == other.config
// && self.colorscheme == other.colorscheme
// && self.app_metadata == other.app_metadata
// }
// }
//
// impl Eq for Ctx {}
//
// impl Hash for Ctx {
// fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
// self.tv_state.hash(state);
// self.config.hash(state);
// self.colorscheme.hash(state);
// self.app_metadata.hash(state);
// }
// }
//
// impl PartialOrd for Ctx {
// fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
// Some(self.instant.cmp(&other.instant))
// }
// }
//
// impl Ord for Ctx {
// fn cmp(&self, other: &Self) -> std::cmp::Ordering {
// self.instant.cmp(&other.instant)
// }
// }
/// Draw the current UI frame based on the given context.
///
/// This function is responsible for drawing the entire UI frame based on the given context by
@ -175,8 +142,8 @@ impl Ctx {
/// This function is executed by the UI thread whenever it receives a render message from the main
/// thread.
///
/// It will draw the help bar, the results list, the input box, the preview content block, and the
/// remote control.
/// It will draw the results list, the input box, the preview content block, the remote control,
/// the keybinding panel, and the status bar.
///
/// # Returns
/// A `Result` containing the layout of the current frame if the drawing was successful.
@ -190,21 +157,8 @@ pub fn draw(ctx: &Ctx, f: &mut Frame<'_>, area: Rect) -> Result<Layout> {
&ctx.config.ui,
show_remote,
ctx.tv_state.preview_state.enabled,
);
// help bar (metadata, keymaps, logo)
draw_help_bar(
f,
&layout.help_bar,
&ctx.tv_state.channel_state.current_channel_name,
&ctx.tv_state.channel_state.current_command,
build_keybindings_table(
&ctx.config.keybindings.to_displayable(),
ctx.tv_state.mode,
&ctx.colorscheme,
),
Some(&ctx.config.keybindings),
ctx.tv_state.mode,
&ctx.app_metadata,
&ctx.colorscheme,
);
@ -218,21 +172,6 @@ pub fn draw(ctx: &Ctx, f: &mut Frame<'_>, area: Rect) -> Result<Layout> {
ctx.config.ui.input_bar_position,
ctx.config.ui.use_nerd_font_icons,
&ctx.colorscheme,
&ctx.config
.keybindings
.get(&Action::ToggleHelp)
// just display the first keybinding
.unwrap()
.to_string(),
&ctx.config
.keybindings
.get(&Action::TogglePreview)
// just display the first keybinding
.unwrap()
.to_string(),
// only show the preview keybinding hint if there's actually something to preview
ctx.tv_state.preview_state.enabled,
ctx.config.ui.no_help,
)?;
// input box
@ -274,5 +213,15 @@ pub fn draw(ctx: &Ctx, f: &mut Frame<'_>, area: Rect) -> Result<Layout> {
)?;
}
// floating keybinding panel (rendered last to appear on top)
if let Some(keybinding_area) = layout.keybinding_panel {
draw_keybinding_panel(f, keybinding_area, ctx);
}
// status bar at the bottom
if let Some(status_bar_area) = layout.status_bar {
draw_status_bar(f, status_bar_area, ctx);
}
Ok(layout)
}

View File

@ -87,7 +87,6 @@ async fn main() -> Result<()> {
args.take_1,
args.take_1_fast,
args.no_remote,
args.no_help,
args.no_preview,
args.preview_size,
config.application.tick_rate,
@ -132,10 +131,6 @@ fn apply_cli_overrides(args: &PostProcessedCli, config: &mut Config) {
if let Some(tick_rate) = args.tick_rate {
config.application.tick_rate = tick_rate;
}
if args.no_help {
config.ui.show_help_bar = false;
config.ui.no_help = true;
}
if args.no_preview {
config.ui.show_preview_panel = false;
}
@ -239,7 +234,6 @@ pub fn determine_channel(
// Set UI spec - only hide preview if no preview command is provided
prototype.ui = Some(UiSpec {
ui_scale: None,
show_help_bar: Some(false),
show_preview_panel: Some(args.preview_command_override.is_some()),
orientation: None,
input_bar_position: None,
@ -476,10 +470,9 @@ mod tests {
assert_eq!(channel.metadata.name, "custom");
assert_eq!(channel.source.command.inner[0].raw(), "fd -t f -H");
// Check that UI options are set to hide preview and help
// Check that UI options are set to hide preview
assert!(channel.ui.is_some());
let ui_spec = channel.ui.as_ref().unwrap();
assert_eq!(ui_spec.show_help_bar, Some(false));
assert_eq!(ui_spec.show_preview_panel, Some(false));
assert_eq!(
ui_spec.input_header,

View File

@ -1,79 +0,0 @@
use devicons::FileIcon;
use rustc_hash::FxHashMap;
use std::sync::Arc;
use crate::utils::cache::RingSet;
use ratatui::widgets::Paragraph;
const DEFAULT_RENDERED_PREVIEW_CACHE_SIZE: usize = 10;
#[derive(Clone, Debug)]
pub struct CachedPreview<'a> {
pub key: String,
pub icon: Option<FileIcon>,
pub title: String,
pub paragraph: Arc<Paragraph<'a>>,
}
impl<'a> CachedPreview<'a> {
pub fn new(
key: String,
icon: Option<FileIcon>,
title: String,
paragraph: Arc<Paragraph<'a>>,
) -> Self {
CachedPreview {
key,
icon,
title,
paragraph,
}
}
}
#[derive(Debug)]
pub struct RenderedPreviewCache<'a> {
previews: FxHashMap<String, CachedPreview<'a>>,
ring_set: RingSet<String>,
pub last_preview: Option<CachedPreview<'a>>,
}
impl<'a> RenderedPreviewCache<'a> {
pub fn new(capacity: usize) -> Self {
RenderedPreviewCache {
previews: FxHashMap::default(),
ring_set: RingSet::with_capacity(capacity),
last_preview: None,
}
}
pub fn get(&self, key: &str) -> Option<CachedPreview<'a>> {
self.previews.get(key).cloned()
}
pub fn insert(
&mut self,
key: String,
icon: Option<FileIcon>,
title: &str,
paragraph: &Arc<Paragraph<'a>>,
) {
let cached_preview = CachedPreview::new(
key.clone(),
icon,
title.to_string(),
paragraph.clone(),
);
self.last_preview = Some(cached_preview.clone());
self.previews.insert(key.clone(), cached_preview);
if let Some(oldest_key) = self.ring_set.push(key) {
self.previews.remove(&oldest_key);
}
}
}
impl Default for RenderedPreviewCache<'_> {
fn default() -> Self {
RenderedPreviewCache::new(DEFAULT_RENDERED_PREVIEW_CACHE_SIZE)
}
}

View File

@ -50,5 +50,7 @@ pub struct InputColorscheme {
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ModeColorscheme {
pub channel: Color,
pub channel_fg: Color,
pub remote_control: Color,
pub remote_control_fg: Color,
}

View File

@ -1,118 +0,0 @@
use super::layout::HelpBarLayout;
use crate::screen::colors::{Colorscheme, GeneralColorscheme};
use crate::screen::logo::build_logo_paragraph;
use crate::screen::metadata::build_metadata_table;
use crate::screen::mode::mode_color;
use crate::television::Mode;
use crate::utils::metadata::AppMetadata;
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::prelude::{Color, Style};
use ratatui::widgets::{Block, BorderType, Borders, Padding, Table};
pub fn draw_logo_block(
f: &mut Frame,
area: Rect,
mode_color: Color,
general_colorscheme: &GeneralColorscheme,
) {
let logo_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(general_colorscheme.border_fg))
.style(
Style::default()
.fg(mode_color)
.bg(general_colorscheme.background.unwrap_or_default()),
)
.padding(Padding::horizontal(1));
let logo_paragraph = build_logo_paragraph().block(logo_block);
f.render_widget(logo_paragraph, area);
}
fn draw_metadata_block(
f: &mut Frame,
area: Rect,
mode: Mode,
current_channel_name: &str,
current_command: &str,
app_metadata: &AppMetadata,
colorscheme: &Colorscheme,
) {
let metadata_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(colorscheme.general.border_fg))
.padding(Padding::horizontal(1))
.style(
Style::default()
.bg(colorscheme.general.background.unwrap_or_default()),
);
let metadata_table = build_metadata_table(
mode,
current_channel_name,
current_command,
app_metadata,
colorscheme,
)
.block(metadata_block);
f.render_widget(metadata_table, area);
}
fn draw_keymaps_block(
f: &mut Frame,
area: Rect,
keymap_table: Table,
colorscheme: &GeneralColorscheme,
) {
let keymaps_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(colorscheme.border_fg))
.style(Style::default().bg(colorscheme.background.unwrap_or_default()))
.padding(Padding::horizontal(1));
let table = keymap_table.block(keymaps_block);
f.render_widget(table, area);
}
#[allow(clippy::too_many_arguments)]
pub fn draw_help_bar(
f: &mut Frame,
layout: &Option<HelpBarLayout>,
current_channel_name: &str,
current_command: &str,
keymap_table: Table,
mode: Mode,
app_metadata: &AppMetadata,
colorscheme: &Colorscheme,
) {
if let Some(help_bar) = layout {
draw_metadata_block(
f,
help_bar.left,
mode,
current_channel_name,
current_command,
app_metadata,
colorscheme,
);
draw_keymaps_block(
f,
help_bar.middle,
keymap_table,
&colorscheme.general,
);
draw_logo_block(
f,
help_bar.right,
mode_color(mode, &colorscheme.mode),
&colorscheme.general,
);
}
}

View File

@ -0,0 +1,196 @@
use crate::{
config::KeyBindings,
draw::Ctx,
screen::colors::Colorscheme,
screen::keybinding_utils::{ActionMapping, extract_keys_from_binding},
television::Mode,
};
use ratatui::{
Frame,
layout::{Alignment, Rect},
style::{Style, Stylize},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Clear, Padding, Paragraph},
};
const MIN_PANEL_WIDTH: u16 = 25;
const MIN_PANEL_HEIGHT: u16 = 5;
/// Draws a Helix-style floating keybinding panel in the bottom-right corner
pub fn draw_keybinding_panel(f: &mut Frame<'_>, area: Rect, ctx: &Ctx) {
if area.width < MIN_PANEL_WIDTH || area.height < MIN_PANEL_HEIGHT {
return; // Too small to display anything meaningful
}
// Generate content
let content = generate_keybinding_content(
&ctx.config.keybindings,
ctx.tv_state.mode,
&ctx.colorscheme,
);
// Clear the area first to create the floating effect
f.render_widget(Clear, area);
// Create the main block with consistent styling
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(ctx.colorscheme.general.border_fg))
.title_top(Line::from(" Keybindings ").alignment(Alignment::Center))
.style(
Style::default().bg(ctx
.colorscheme
.general
.background
.unwrap_or_default()),
)
.padding(Padding::horizontal(1));
let paragraph = Paragraph::new(content)
.block(block)
.alignment(Alignment::Left);
f.render_widget(paragraph, area);
}
/// Adds keybinding lines for action mappings to the given lines vector
fn add_keybinding_lines_for_mappings(
lines: &mut Vec<Line<'static>>,
keybindings: &KeyBindings,
mappings: &[ActionMapping],
mode: Mode,
colorscheme: &Colorscheme,
) {
for mapping in mappings {
for (action, description) in &mapping.actions {
if let Some(binding) = keybindings.get(action) {
let keys = extract_keys_from_binding(binding);
for key in keys {
lines.push(create_compact_keybinding_line(
&key,
description,
mode,
colorscheme,
));
}
}
}
}
}
/// Generates the keybinding content organized into global and mode-specific groups
fn generate_keybinding_content(
keybindings: &KeyBindings,
mode: Mode,
colorscheme: &Colorscheme,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
// Global keybindings section header
lines.push(Line::from(vec![Span::styled(
"Global",
Style::default()
.fg(colorscheme.help.metadata_field_name_fg)
.bold()
.underlined(),
)]));
// Global actions using centralized system
let global_mappings = ActionMapping::global_actions();
add_keybinding_lines_for_mappings(
&mut lines,
keybindings,
&global_mappings,
mode,
colorscheme,
);
// Add spacing between Global and mode-specific sections
lines.push(Line::from(""));
// Mode-specific keybindings section header
let mode_name = match mode {
Mode::Channel => "Channel",
Mode::RemoteControl => "Remote",
};
lines.push(Line::from(vec![Span::styled(
mode_name,
Style::default()
.fg(colorscheme.help.metadata_field_name_fg)
.bold()
.underlined(),
)]));
// Navigation actions (common to both modes) using centralized system
let nav_mappings = ActionMapping::navigation_actions();
add_keybinding_lines_for_mappings(
&mut lines,
keybindings,
&nav_mappings,
mode,
colorscheme,
);
// Mode-specific actions using centralized system
let mode_mappings = ActionMapping::mode_specific_actions(mode);
add_keybinding_lines_for_mappings(
&mut lines,
keybindings,
&mode_mappings,
mode,
colorscheme,
);
lines
}
/// Creates a compact keybinding line with one space of left padding
fn create_compact_keybinding_line(
key: &str,
action: &str,
mode: Mode,
colorscheme: &Colorscheme,
) -> Line<'static> {
// Use the appropriate mode color
let key_color = match mode {
Mode::Channel => colorscheme.mode.channel,
Mode::RemoteControl => colorscheme.mode.remote_control,
};
Line::from(vec![
Span::styled(
format!("{}:", action),
Style::default().fg(colorscheme.help.metadata_field_name_fg),
),
Span::raw(" "), // Space between action and key
Span::styled(key.to_string(), Style::default().fg(key_color).bold()),
])
}
/// Calculates the required dimensions for the keybinding panel based on content
#[allow(clippy::cast_possible_truncation)]
pub fn calculate_keybinding_panel_size(
keybindings: &KeyBindings,
mode: Mode,
colorscheme: &Colorscheme,
) -> (u16, u16) {
// Generate content to count items and calculate width
let content = generate_keybinding_content(keybindings, mode, colorscheme);
// Calculate required width based on actual content
let max_content_width = content
.iter()
.map(Line::width) // Use Line's width method for sizing calculation
.max()
.unwrap_or(25);
// Calculate dimensions with proper padding:
// - Width: content + 3 (2 borders + 1 padding)
// - Height: content lines + 2 (2 borders, no title or padding)
let required_width = (max_content_width + 3).max(25) as u16;
let required_height = (content.len() + 2).max(8) as u16;
(required_width, required_height)
}

View File

@ -0,0 +1,176 @@
use crate::{action::Action, config::KeyBindings, television::Mode};
use std::fmt::Display;
/// Centralized action descriptions to avoid duplication between keybinding panel and help bar
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ActionCategory {
// Global actions
Quit,
TogglePreview,
ToggleHelp,
// Navigation actions (common to both modes)
ResultsNavigation,
PreviewNavigation,
// Selection actions
SelectEntry,
ToggleSelection,
// Channel-specific actions
CopyEntryToClipboard,
ToggleRemoteControl,
CycleSources,
ReloadSource,
}
impl Display for ActionCategory {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let description = match self {
ActionCategory::Quit => "Quit",
ActionCategory::TogglePreview => "Toggle preview",
ActionCategory::ToggleHelp => "Toggle help",
ActionCategory::ResultsNavigation => "Results navigation",
ActionCategory::PreviewNavigation => "Preview navigation",
ActionCategory::SelectEntry => "Select entry",
ActionCategory::ToggleSelection => "Toggle selection",
ActionCategory::CopyEntryToClipboard => "Copy entry to clipboard",
ActionCategory::ToggleRemoteControl => "Toggle Remote control",
ActionCategory::CycleSources => "Cycle through sources",
ActionCategory::ReloadSource => "Reload source",
};
write!(f, "{description}")
}
}
/// Defines what actions belong to each category and their individual descriptions
pub struct ActionMapping {
pub category: ActionCategory,
pub actions: Vec<(Action, &'static str)>,
}
impl ActionMapping {
/// Get all action mappings for global actions
pub fn global_actions() -> Vec<ActionMapping> {
vec![
ActionMapping {
category: ActionCategory::Quit,
actions: vec![(Action::Quit, "Quit")],
},
ActionMapping {
category: ActionCategory::TogglePreview,
actions: vec![(Action::TogglePreview, "Toggle preview")],
},
ActionMapping {
category: ActionCategory::ToggleHelp,
actions: vec![(Action::ToggleHelp, "Toggle help")],
},
]
}
/// Get all action mappings for navigation actions (common to both modes)
pub fn navigation_actions() -> Vec<ActionMapping> {
vec![
ActionMapping {
category: ActionCategory::ResultsNavigation,
actions: vec![
(Action::SelectPrevEntry, "Navigate up"),
(Action::SelectNextEntry, "Navigate down"),
(Action::SelectPrevPage, "Page up"),
(Action::SelectNextPage, "Page down"),
],
},
ActionMapping {
category: ActionCategory::PreviewNavigation,
actions: vec![
(Action::ScrollPreviewHalfPageUp, "Preview scroll up"),
(Action::ScrollPreviewHalfPageDown, "Preview scroll down"),
],
},
]
}
/// Get mode-specific action mappings
pub fn mode_specific_actions(mode: Mode) -> Vec<ActionMapping> {
match mode {
Mode::Channel => vec![
ActionMapping {
category: ActionCategory::SelectEntry,
actions: vec![
(Action::ConfirmSelection, "Select entry"),
(Action::ToggleSelectionDown, "Toggle selection down"),
(Action::ToggleSelectionUp, "Toggle selection up"),
],
},
ActionMapping {
category: ActionCategory::CopyEntryToClipboard,
actions: vec![(
Action::CopyEntryToClipboard,
"Copy to clipboard",
)],
},
ActionMapping {
category: ActionCategory::ToggleRemoteControl,
actions: vec![(
Action::ToggleRemoteControl,
"Toggle remote",
)],
},
ActionMapping {
category: ActionCategory::CycleSources,
actions: vec![(Action::CycleSources, "Cycle sources")],
},
ActionMapping {
category: ActionCategory::ReloadSource,
actions: vec![(Action::ReloadSource, "Reload source")],
},
],
Mode::RemoteControl => vec![
ActionMapping {
category: ActionCategory::SelectEntry,
actions: vec![(Action::ConfirmSelection, "Select entry")],
},
ActionMapping {
category: ActionCategory::ToggleRemoteControl,
actions: vec![(
Action::ToggleRemoteControl,
"Back to channel",
)],
},
],
}
}
/// Get all actions for a specific category, flattened for help bar usage
pub fn actions_for_category(&self) -> &[Action] {
// This is a bit of a hack to return just the Action part of the tuples
// We'll need to handle this differently in the help bar system
&[]
}
}
/// Unified key extraction function that works for both systems
pub fn extract_keys_from_binding(
binding: &crate::config::keybindings::Binding,
) -> Vec<String> {
match binding {
crate::config::keybindings::Binding::SingleKey(key) => {
vec![key.to_string()]
}
crate::config::keybindings::Binding::MultipleKeys(keys) => {
keys.iter().map(ToString::to_string).collect()
}
}
}
/// Extract keys for multiple actions and return them as a flat vector
pub fn extract_keys_for_actions(
keybindings: &KeyBindings,
actions: &[Action],
) -> Vec<String> {
actions
.iter()
.filter_map(|action| keybindings.get(action))
.flat_map(extract_keys_from_binding)
.collect()
}

View File

@ -1,381 +0,0 @@
use rustc_hash::FxHashMap;
use std::fmt::Display;
use crate::action::Action;
use crate::television::Mode;
use crate::{config::KeyBindings, screen::colors::Colorscheme};
use ratatui::{
layout::Constraint,
style::{Color, Style},
text::{Line, Span},
widgets::{Cell, Row, Table},
};
impl KeyBindings {
pub fn to_displayable(&self) -> FxHashMap<Mode, DisplayableKeybindings> {
// channel mode keybindings
let mut channel_bindings: FxHashMap<DisplayableAction, Vec<String>> =
FxHashMap::from_iter(vec![
(
DisplayableAction::ResultsNavigation,
serialized_keys_for_actions(
self,
&[
Action::SelectPrevEntry,
Action::SelectNextEntry,
Action::SelectPrevPage,
Action::SelectNextPage,
],
),
),
(
DisplayableAction::PreviewNavigation,
serialized_keys_for_actions(
self,
&[
Action::ScrollPreviewHalfPageUp,
Action::ScrollPreviewHalfPageDown,
],
),
),
(
DisplayableAction::SelectEntry,
serialized_keys_for_actions(
self,
&[
Action::ConfirmSelection,
Action::ToggleSelectionDown,
Action::ToggleSelectionUp,
],
),
),
(
DisplayableAction::CopyEntryToClipboard,
serialized_keys_for_actions(
self,
&[Action::CopyEntryToClipboard],
),
),
(
DisplayableAction::ToggleRemoteControl,
serialized_keys_for_actions(
self,
&[Action::ToggleRemoteControl],
),
),
(
DisplayableAction::ToggleHelpBar,
serialized_keys_for_actions(self, &[Action::ToggleHelp]),
),
]);
// Optional bindings only included if present in the configuration
if let Some(binding) = self.get(&Action::CycleSources) {
channel_bindings.insert(
DisplayableAction::CycleSources,
vec![binding.to_string()],
);
}
if let Some(binding) = self.get(&Action::ReloadSource) {
channel_bindings.insert(
DisplayableAction::ReloadSource,
vec![binding.to_string()],
);
}
// remote control mode keybindings
let remote_control_bindings: FxHashMap<
DisplayableAction,
Vec<String>,
> = FxHashMap::from_iter(vec![
(
DisplayableAction::ResultsNavigation,
serialized_keys_for_actions(
self,
&[Action::SelectPrevEntry, Action::SelectNextEntry],
),
),
(
DisplayableAction::SelectEntry,
serialized_keys_for_actions(self, &[Action::ConfirmSelection]),
),
(
DisplayableAction::ToggleRemoteControl,
serialized_keys_for_actions(
self,
&[Action::ToggleRemoteControl],
),
),
]);
FxHashMap::from_iter(vec![
(Mode::Channel, DisplayableKeybindings::new(channel_bindings)),
(
Mode::RemoteControl,
DisplayableKeybindings::new(remote_control_bindings),
),
])
}
}
fn serialized_keys_for_actions(
keybindings: &KeyBindings,
actions: &[Action],
) -> Vec<String> {
actions
.iter()
.filter_map(|a| keybindings.get(a).cloned())
.map(|binding| binding.to_string())
.collect()
}
#[derive(Debug, Clone)]
pub struct DisplayableKeybindings {
bindings: FxHashMap<DisplayableAction, Vec<String>>,
}
impl DisplayableKeybindings {
pub fn new(bindings: FxHashMap<DisplayableAction, Vec<String>>) -> Self {
Self { bindings }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum DisplayableAction {
ResultsNavigation,
PreviewNavigation,
SelectEntry,
CopyEntryToClipboard,
ToggleRemoteControl,
Cancel,
Quit,
ToggleHelpBar,
CycleSources,
ReloadSource,
}
impl Display for DisplayableAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let action = match self {
DisplayableAction::ResultsNavigation => "Results navigation",
DisplayableAction::PreviewNavigation => "Preview navigation",
DisplayableAction::SelectEntry => "Select entry",
DisplayableAction::CopyEntryToClipboard => {
"Copy entry to clipboard"
}
DisplayableAction::ToggleRemoteControl => "Toggle Remote control",
DisplayableAction::Cancel => "Cancel",
DisplayableAction::Quit => "Quit",
DisplayableAction::ToggleHelpBar => "Toggle help bar",
DisplayableAction::CycleSources => "Cycle through sources",
DisplayableAction::ReloadSource => "Reload source",
};
write!(f, "{action}")
}
}
pub fn build_keybindings_table<'a>(
keybindings: &'a FxHashMap<Mode, DisplayableKeybindings>,
mode: Mode,
colorscheme: &'a Colorscheme,
) -> Table<'a> {
match mode {
Mode::Channel => build_keybindings_table_for_channel(
&keybindings[&mode],
colorscheme,
),
Mode::RemoteControl => build_keybindings_table_for_channel_selection(
&keybindings[&mode],
colorscheme,
),
}
}
fn build_keybindings_table_for_channel<'a>(
keybindings: &'a DisplayableKeybindings,
colorscheme: &'a Colorscheme,
) -> Table<'a> {
// Results navigation
let results_navigation_keys = keybindings
.bindings
.get(&DisplayableAction::ResultsNavigation)
.unwrap();
let results_row = Row::new(build_cells_for_group(
"Results navigation",
results_navigation_keys,
colorscheme.help.metadata_field_name_fg,
colorscheme.mode.channel,
));
// Preview navigation
let preview_navigation_keys = keybindings
.bindings
.get(&DisplayableAction::PreviewNavigation)
.unwrap();
let preview_row = Row::new(build_cells_for_group(
"Preview navigation",
preview_navigation_keys,
colorscheme.help.metadata_field_name_fg,
colorscheme.mode.channel,
));
// Select entry
let select_entry_keys = keybindings
.bindings
.get(&DisplayableAction::SelectEntry)
.unwrap();
let select_entry_row = Row::new(build_cells_for_group(
"Select entry",
select_entry_keys,
colorscheme.help.metadata_field_name_fg,
colorscheme.mode.channel,
));
// Copy entry to clipboard
let copy_entry_keys = keybindings
.bindings
.get(&DisplayableAction::CopyEntryToClipboard)
.unwrap();
let copy_entry_row = Row::new(build_cells_for_group(
"Copy entry to clipboard",
copy_entry_keys,
colorscheme.help.metadata_field_name_fg,
colorscheme.mode.channel,
));
// Switch channels
let switch_channels_keys = keybindings
.bindings
.get(&DisplayableAction::ToggleRemoteControl)
.unwrap();
let switch_channels_row = Row::new(build_cells_for_group(
"Toggle Remote control",
switch_channels_keys,
colorscheme.help.metadata_field_name_fg,
colorscheme.mode.channel,
));
// Toggle source (optional)
let cycle_sources_row = keybindings
.bindings
.get(&DisplayableAction::CycleSources)
.map(|keys| {
Row::new(build_cells_for_group(
"Cycle through sources",
keys,
colorscheme.help.metadata_field_name_fg,
colorscheme.mode.channel,
))
});
// Reload source (optional)
let reload_source_row = keybindings
.bindings
.get(&DisplayableAction::ReloadSource)
.map(|reload_source_keys| {
Row::new(build_cells_for_group(
"Reload source",
reload_source_keys,
colorscheme.help.metadata_field_name_fg,
colorscheme.mode.channel,
))
});
let widths = vec![Constraint::Fill(1), Constraint::Fill(2)];
let mut rows = vec![
results_row,
preview_row,
select_entry_row,
copy_entry_row,
switch_channels_row,
];
if let Some(row) = cycle_sources_row {
rows.push(row);
}
if let Some(row) = reload_source_row {
rows.push(row);
}
Table::new(rows, widths)
}
fn build_keybindings_table_for_channel_selection<'a>(
keybindings: &'a DisplayableKeybindings,
colorscheme: &'a Colorscheme,
) -> Table<'a> {
// Results navigation
let navigation_keys = keybindings
.bindings
.get(&DisplayableAction::ResultsNavigation)
.unwrap();
let results_row = Row::new(build_cells_for_group(
"Browse channels",
navigation_keys,
colorscheme.help.metadata_field_name_fg,
colorscheme.mode.remote_control,
));
// Select entry
let select_entry_keys = keybindings
.bindings
.get(&DisplayableAction::SelectEntry)
.unwrap();
let select_entry_row = Row::new(build_cells_for_group(
"Select channel",
select_entry_keys,
colorscheme.help.metadata_field_name_fg,
colorscheme.mode.remote_control,
));
// Remote control
let switch_channels_keys = keybindings
.bindings
.get(&DisplayableAction::ToggleRemoteControl)
.unwrap();
let switch_channels_row = Row::new(build_cells_for_group(
"Toggle Remote control",
switch_channels_keys,
colorscheme.help.metadata_field_name_fg,
colorscheme.mode.remote_control,
));
Table::new(
vec![results_row, select_entry_row, switch_channels_row],
vec![Constraint::Fill(1), Constraint::Fill(2)],
)
}
fn build_cells_for_group<'a>(
group_name: &str,
keys: &'a [String],
key_color: Color,
value_color: Color,
) -> Vec<Cell<'a>> {
// group name
let mut cells = vec![Cell::from(Span::styled(
group_name.to_owned() + ": ",
Style::default().fg(key_color),
))];
let spans = keys.iter().skip(1).fold(
vec![Span::styled(
keys[0].clone(),
Style::default().fg(value_color),
)],
|mut acc, key| {
acc.push(Span::raw(" / "));
acc.push(Span::styled(
key.to_owned(),
Style::default().fg(value_color),
));
acc
},
);
cells.push(Cell::from(Line::from(spans)));
cells
}

View File

@ -1,13 +1,14 @@
use std::fmt::Display;
use clap::ValueEnum;
use ratatui::layout;
use ratatui::layout::{Constraint, Direction, Rect};
use serde::{Deserialize, Serialize};
use crate::config::UiConfig;
use crate::screen::constants::LOGO_WIDTH;
use crate::config::{KeyBindings, UiConfig};
use crate::screen::colors::Colorscheme;
use crate::screen::keybinding_panel::calculate_keybinding_panel_size;
use crate::screen::logo::REMOTE_LOGO_HEIGHT_U16;
use crate::television::Mode;
use clap::ValueEnum;
use ratatui::layout::{
self, Constraint, Direction, Layout as RatatuiLayout, Rect,
};
use serde::{Deserialize, Serialize};
use std::fmt::Display;
pub struct Dimensions {
pub x: u16,
@ -26,23 +27,6 @@ impl From<u16> for Dimensions {
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct HelpBarLayout {
pub left: Rect,
pub middle: Rect,
pub right: Rect,
}
impl HelpBarLayout {
pub fn new(left: Rect, middle: Rect, right: Rect) -> Self {
Self {
left,
middle,
right,
}
}
}
#[derive(
Debug, Clone, Copy, Deserialize, Serialize, Default, PartialEq, Hash,
)]
@ -104,11 +88,12 @@ impl Display for PreviewTitlePosition {
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Layout {
pub help_bar: Option<HelpBarLayout>,
pub results: Rect,
pub input: Rect,
pub preview_window: Option<Rect>,
pub remote_control: Option<Rect>,
pub keybinding_panel: Option<Rect>,
pub status_bar: Option<Rect>,
}
const REMOTE_PANEL_WIDTH_PERCENTAGE: u16 = 62;
@ -120,25 +105,33 @@ impl Default for Layout {
/// depend on the height of the results area which is not known until the first
/// frame is rendered.
fn default() -> Self {
Self::new(None, Rect::new(0, 0, 0, 100), Rect::default(), None, None)
Self::new(
Rect::new(0, 0, 0, 100),
Rect::default(),
None,
None,
None,
None,
)
}
}
impl Layout {
#[allow(clippy::too_many_arguments)]
pub fn new(
help_bar: Option<HelpBarLayout>,
results: Rect,
input: Rect,
preview_window: Option<Rect>,
remote_control: Option<Rect>,
keybinding_panel: Option<Rect>,
status_bar: Option<Rect>,
) -> Self {
Self {
help_bar,
results,
input,
preview_window,
remote_control,
keybinding_panel,
status_bar,
}
}
@ -147,43 +140,30 @@ impl Layout {
ui_config: &UiConfig,
show_remote: bool,
show_preview: bool,
keybindings: Option<&KeyBindings>,
mode: Mode,
colorscheme: &Colorscheme,
) -> Self {
let show_preview = show_preview && ui_config.show_preview_panel;
let dimensions = Dimensions::from(ui_config.ui_scale);
let main_block = centered_rect(dimensions.x, dimensions.y, area);
// split the main block into two vertical chunks (help bar + rest)
let main_rect: Rect;
let help_bar_layout: Option<HelpBarLayout>;
if ui_config.show_help_bar {
let hz_chunks = layout::Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Max(9), Constraint::Fill(1)])
.split(main_block);
main_rect = hz_chunks[1];
// split the help bar into three horizontal chunks (left + center + right)
let help_bar_chunks = layout::Layout::default()
.direction(Direction::Horizontal)
.constraints([
// metadata
Constraint::Fill(1),
// keymaps
Constraint::Fill(1),
// logo
Constraint::Length(LOGO_WIDTH),
])
.split(hz_chunks[0]);
help_bar_layout = Some(HelpBarLayout {
left: help_bar_chunks[0],
middle: help_bar_chunks[1],
right: help_bar_chunks[2],
});
// Reserve space for status bar if enabled
let working_area = if ui_config.show_status_bar {
Rect {
x: area.x,
y: area.y,
width: area.width,
height: area.height.saturating_sub(1), // Reserve exactly 1 line for status bar
}
} else {
main_rect = main_block;
help_bar_layout = None;
}
area
};
let main_block =
centered_rect(dimensions.x, dimensions.y, working_area);
// Use the entire main block since help bar is removed
let main_rect = main_block;
// Define the constraints for the results area (results list + input bar).
// We keep this near the top so we can derive the input-bar height before
@ -247,7 +227,7 @@ impl Layout {
vec![Constraint::Fill(1)]
};
let main_chunks = layout::Layout::default()
let main_chunks = RatatuiLayout::default()
.direction(match ui_config.orientation {
Orientation::Portrait => Direction::Vertical,
Orientation::Landscape => Direction::Horizontal,
@ -401,12 +381,51 @@ impl Layout {
None
};
// the keybinding panel is positioned at bottom-right, accounting for status bar
let keybinding_panel = if ui_config.show_keybinding_panel {
// Calculate available area for keybinding panel (excluding status bar if enabled)
let kb_area = if ui_config.show_status_bar {
Rect {
x: area.x,
y: area.y,
width: area.width,
height: area.height.saturating_sub(1), // Account for single line status bar
}
} else {
area
};
if let Some(kb) = keybindings {
let (width, height) =
calculate_keybinding_panel_size(kb, mode, colorscheme);
Some(bottom_right_rect(width, height, kb_area))
} else {
// Fallback to reasonable default if keybindings not available
Some(bottom_right_rect(45, 25, kb_area))
}
} else {
None
};
// Create status bar at the bottom if enabled
let status_bar = if ui_config.show_status_bar {
Some(Rect {
x: area.x,
y: area.y + area.height - 1, // Position at the very last line
width: area.width,
height: 1, // Single line status bar
})
} else {
None
};
Self::new(
help_bar_layout,
results,
input,
preview_window,
remote_control,
keybinding_panel,
status_bar,
)
}
}
@ -440,3 +459,16 @@ fn centered_rect_with_dimensions(dimensions: &Dimensions, r: Rect) -> Rect {
])
.split(popup_layout[1])[1] // Return the middle chunk
}
/// helper function to create a floating rect positioned at the bottom-right corner
fn bottom_right_rect(width: u16, height: u16, r: Rect) -> Rect {
let x = r.width.saturating_sub(width + 2); // 2 for padding from edge
let y = r.height.saturating_sub(height + 1); // 1 for padding from edge
Rect {
x: r.x + x,
y: r.y + y,
width: width.min(r.width.saturating_sub(2)),
height: height.min(r.height.saturating_sub(2)),
}
}

View File

@ -1,99 +0,0 @@
use std::fmt::Display;
use crate::screen::{colors::Colorscheme, mode::mode_color};
use crate::television::Mode;
use crate::utils::metadata::AppMetadata;
use ratatui::{
layout::Constraint,
style::Style,
text::Span,
widgets::{Cell, Row, Table},
};
impl Display for Mode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Mode::Channel => write!(f, "Channel"),
Mode::RemoteControl => write!(f, "Remote Control"),
}
}
}
pub fn build_metadata_table<'a>(
mode: Mode,
current_channel_name: &'a str,
current_command: &'a str,
app_metadata: &'a AppMetadata,
colorscheme: &'a Colorscheme,
) -> Table<'a> {
let version_row = Row::new(vec![
Cell::from(Span::styled(
"version: ",
Style::default().fg(colorscheme.help.metadata_field_name_fg),
)),
Cell::from(Span::styled(
&app_metadata.version,
Style::default().fg(colorscheme.help.metadata_field_value_fg),
)),
]);
let current_dir_row = Row::new(vec![
Cell::from(Span::styled(
"current directory: ",
Style::default().fg(colorscheme.help.metadata_field_name_fg),
)),
Cell::from(Span::styled(
std::env::current_dir()
.expect("Could not get current directory")
.display()
.to_string(),
Style::default().fg(colorscheme.help.metadata_field_value_fg),
)),
]);
let current_channel_row = Row::new(vec![
Cell::from(Span::styled(
"current channel: ",
Style::default().fg(colorscheme.help.metadata_field_name_fg),
)),
Cell::from(Span::styled(
current_channel_name,
Style::default().fg(colorscheme.help.metadata_field_value_fg),
)),
]);
let current_command_row = Row::new(vec![
Cell::from(Span::styled(
"current command: ",
Style::default().fg(colorscheme.help.metadata_field_name_fg),
)),
Cell::from(Span::styled(
current_command,
Style::default().fg(colorscheme.help.metadata_field_value_fg),
)),
]);
let current_mode_row = Row::new(vec![
Cell::from(Span::styled(
"current mode: ",
Style::default().fg(colorscheme.help.metadata_field_name_fg),
)),
Cell::from(Span::styled(
mode.to_string(),
Style::default().fg(mode_color(mode, &colorscheme.mode)),
)),
]);
let widths = vec![Constraint::Fill(1), Constraint::Fill(2)];
Table::new(
vec![
version_row,
current_dir_row,
current_channel_row,
current_command_row,
current_mode_row,
],
widths,
)
}

View File

@ -1,15 +1,14 @@
pub mod cache;
pub mod colors;
pub mod constants;
pub mod help;
pub mod input;
pub mod keybindings;
pub mod keybinding_panel;
pub mod keybinding_utils;
pub mod layout;
pub mod logo;
pub mod metadata;
pub mod mode;
pub mod preview;
pub mod remote_control;
pub mod result_item;
pub mod results;
pub mod spinner;
pub mod status_bar;

View File

@ -9,7 +9,6 @@ use ratatui::prelude::Style;
use ratatui::text::Line;
use ratatui::widgets::{Block, BorderType, Borders, ListState, Padding};
use rustc_hash::FxHashSet;
use std::fmt::Write;
#[allow(clippy::too_many_arguments)]
pub fn draw_results_list(
@ -21,22 +20,9 @@ pub fn draw_results_list(
input_bar_position: InputPosition,
use_nerd_font_icons: bool,
colorscheme: &Colorscheme,
help_keybinding: &str,
preview_keybinding: &str,
preview_togglable: bool,
no_help: bool,
) -> Result<()> {
let mut toggle_hints = String::new();
if !no_help {
write!(toggle_hints, " help: <{}> ", help_keybinding)?;
}
if preview_togglable {
write!(toggle_hints, " preview: <{}> ", preview_keybinding)?;
}
let results_block = Block::default()
.title_top(Line::from(" Results ").alignment(Alignment::Center))
.title_bottom(Line::from(toggle_hints).alignment(Alignment::Center))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(colorscheme.general.border_fg))

View File

@ -0,0 +1,185 @@
use crate::{action::Action, draw::Ctx, television::Mode};
use ratatui::{
Frame,
layout::{
Alignment, Constraint, Direction, Layout as RatatuiLayout, Rect,
},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::Paragraph,
};
/// Draw the status bar at the bottom of the screen
pub fn draw_status_bar(f: &mut Frame<'_>, area: Rect, ctx: &Ctx) {
// Split status bar into three sections
let chunks = RatatuiLayout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Fill(1), // Left: mode + channel info
Constraint::Fill(3), // Middle: hints
Constraint::Fill(1), // Right: version
])
.split(area);
// === LEFT SECTION: Mode bubble and channel info ===
let mut left_spans = vec![Span::raw(" ")]; // Initial spacing
// Get mode-specific styling
let (mode_text, mode_fg, mode_bg) = match ctx.tv_state.mode {
Mode::Channel => (
"CHANNEL",
ctx.colorscheme.mode.channel_fg,
ctx.colorscheme.mode.channel,
),
Mode::RemoteControl => (
"REMOTE",
ctx.colorscheme.mode.remote_control_fg,
ctx.colorscheme.mode.remote_control,
),
};
// Create mode bubble with separators
let separator_style = Style::default().fg(mode_bg).bg(Color::Reset);
let mode_style = Style::default()
.fg(mode_fg)
.bg(mode_bg)
.add_modifier(Modifier::BOLD);
// Add opening separator
if !ctx.config.ui.status_separator_open.is_empty() {
left_spans.push(Span::styled(
ctx.config.ui.status_separator_open.clone(),
separator_style,
));
}
// Add mode text
left_spans.push(Span::styled(format!(" {} ", mode_text), mode_style));
// Add closing separator
if !ctx.config.ui.status_separator_close.is_empty() {
left_spans.push(Span::styled(
ctx.config.ui.status_separator_close.clone(),
separator_style,
));
}
// Add channel-specific info in Channel mode
if ctx.tv_state.mode == Mode::Channel {
let name_style = Style::default()
.fg(ctx.colorscheme.results.result_name_fg)
.add_modifier(Modifier::BOLD);
// Channel name
left_spans.push(Span::styled(
format!(" {}", ctx.tv_state.channel_state.current_channel_name),
name_style,
));
// Selected count indicator
let selected_count = ctx.tv_state.channel_state.selected_entries.len();
if selected_count > 0 {
left_spans.extend([
Span::styled(
"",
Style::default().fg(ctx.colorscheme.general.border_fg),
),
Span::styled(
format!("{} selected", selected_count),
Style::default()
.fg(ctx.colorscheme.results.result_name_fg)
.add_modifier(Modifier::ITALIC),
),
]);
}
}
// === MIDDLE SECTION: Hints ===
let mut middle_spans = Vec::new();
let mut hint_spans = Vec::new();
// Use mode color for keybinding hints
let key_color = match ctx.tv_state.mode {
Mode::Channel => ctx.colorscheme.mode.channel,
Mode::RemoteControl => ctx.colorscheme.mode.remote_control,
};
// Helper to add a hint with consistent styling
let mut add_hint = |description: &str, keybinding: &str| {
if !hint_spans.is_empty() {
hint_spans.push(Span::raw(""));
}
hint_spans.extend([
Span::styled(
format!("{}:", description),
Style::default()
.fg(ctx.colorscheme.help.metadata_field_name_fg),
),
Span::raw(" "),
Span::styled(
keybinding.to_string(),
Style::default().fg(key_color).add_modifier(Modifier::BOLD),
),
]);
};
// Add remote control hint (Channel mode only)
if ctx.tv_state.mode == Mode::Channel {
if let Some(binding) =
ctx.config.keybindings.get(&Action::ToggleRemoteControl)
{
add_hint("Remote Control", &binding.to_string());
}
}
// Add preview hint (Channel mode only, when enabled)
if ctx.tv_state.mode == Mode::Channel && ctx.tv_state.preview_state.enabled
{
if let Some(binding) =
ctx.config.keybindings.get(&Action::TogglePreview)
{
add_hint("Preview", &binding.to_string());
}
}
// Add keybinding help hint (available in both modes)
if let Some(binding) = ctx.config.keybindings.get(&Action::ToggleHelp) {
add_hint("Help", &binding.to_string());
}
// Build middle section if we have hints
if !hint_spans.is_empty() {
middle_spans.extend([
Span::styled(
"[Hint]",
Style::default()
.fg(ctx.colorscheme.general.border_fg)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
]);
middle_spans.extend(hint_spans);
}
// === RIGHT SECTION: Version ===
let right_spans = vec![Span::styled(
format!("v{} ", ctx.app_metadata.version),
Style::default()
.fg(ctx.colorscheme.results.result_name_fg)
.add_modifier(Modifier::ITALIC),
)];
// Render all sections
f.render_widget(
Paragraph::new(Line::from(left_spans)).alignment(Alignment::Left),
chunks[0],
);
f.render_widget(
Paragraph::new(Line::from(middle_spans)).alignment(Alignment::Center),
chunks[1],
);
f.render_widget(
Paragraph::new(Line::from(right_spans)).alignment(Alignment::Right),
chunks[2],
);
}

View File

@ -1,3 +1,5 @@
use std::fmt::Display;
use crate::{
action::Action,
cable::Cable,
@ -39,6 +41,15 @@ pub enum Mode {
RemoteControl,
}
impl Display for Mode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Mode::Channel => write!(f, "Channel"),
Mode::RemoteControl => write!(f, "Remote Control"),
}
}
}
#[derive(PartialEq, Copy, Clone, Hash, Eq, Debug, Serialize, Deserialize)]
pub enum MatchingMode {
Substring,
@ -66,7 +77,6 @@ pub struct Television {
pub colorscheme: Colorscheme,
pub ticks: u64,
pub ui_state: UiState,
pub no_help: bool,
pub no_preview: bool,
pub preview_size: Option<u16>,
pub current_command_index: usize,
@ -83,7 +93,6 @@ impl Television {
base_config: Config,
input: Option<String>,
no_remote: bool,
no_help: bool,
no_preview: bool,
preview_size: Option<u16>,
exact: bool,
@ -95,12 +104,7 @@ impl Television {
);
// Apply CLI overrides after prototype merging to ensure they take precedence
Self::apply_cli_overrides(
&mut config,
no_help,
no_preview,
preview_size,
);
Self::apply_cli_overrides(&mut config, no_preview, preview_size);
debug!("Merged config: {:?}", config);
@ -166,7 +170,6 @@ impl Television {
colorscheme,
ticks: 0,
ui_state: UiState::default(),
no_help,
no_preview,
preview_size,
current_command_index: 0,
@ -208,14 +211,9 @@ impl Television {
/// Apply CLI overrides to ensure they take precedence over channel prototype settings
fn apply_cli_overrides(
config: &mut Config,
no_help: bool,
no_preview: bool,
preview_size: Option<u16>,
) {
if no_help {
config.ui.show_help_bar = false;
config.ui.no_help = true;
}
if no_preview {
config.ui.show_preview_panel = false;
}
@ -303,7 +301,6 @@ impl Television {
// Reapply CLI overrides to ensure they persist across channel changes
Self::apply_cli_overrides(
&mut self.config,
self.no_help,
self.no_preview,
self.preview_size,
);
@ -487,8 +484,8 @@ impl Television {
| Action::ScrollPreviewHalfPageUp
| Action::ToggleRemoteControl
| Action::ToggleSendToChannel
| Action::ToggleHelp
| Action::TogglePreview
| Action::ToggleHelp
| Action::CopyEntryToClipboard
| Action::CycleSources
| Action::ReloadSource
@ -803,16 +800,14 @@ impl Television {
self.change_channel(prototype);
}
}
Action::ToggleHelp => {
if self.no_help {
return Ok(());
}
self.config.ui.show_help_bar = !self.config.ui.show_help_bar;
}
Action::TogglePreview => {
self.config.ui.show_preview_panel =
!self.config.ui.show_preview_panel;
}
Action::ToggleHelp => {
self.config.ui.show_keybinding_panel =
!self.config.ui.show_keybinding_panel;
}
_ => {}
}
Ok(())
@ -889,14 +884,12 @@ mod test {
None,
true,
false,
false,
Some(50),
true,
Cable::from_prototypes(vec![]),
);
assert_eq!(tv.matching_mode, MatchingMode::Substring);
assert!(!tv.no_help);
assert!(!tv.no_preview);
assert!(tv.remote_control.is_none());
}
@ -931,7 +924,6 @@ mod test {
None,
true,
false,
false,
Some(50),
true,
Cable::from_prototypes(vec![]),

View File

@ -52,7 +52,6 @@ fn setup_app(
false,
false,
false,
false,
Some(50),
config.application.tick_rate,
0.0, // watch_interval

View File

@ -16,20 +16,6 @@ fn custom_input_header_and_preview_size() {
PtyTester::assert_exit_ok(&mut child, DEFAULT_DELAY);
}
#[test]
fn no_help() {
let mut tester = PtyTester::new();
let cmd = tv_local_config_and_cable_with_args(&["--no-help"]);
let mut child = tester.spawn_command_tui(cmd);
// Check that the help panel is not shown
tester.assert_not_tui_frame_contains("current mode:");
// Exit the application
tester.send(&ctrl('c'));
PtyTester::assert_exit_ok(&mut child, DEFAULT_DELAY);
}
#[test]
fn no_preview() {
let mut tester = PtyTester::new();

View File

@ -2,21 +2,6 @@ mod common;
use common::*;
#[test]
fn toggle_help() {
let mut tester = PtyTester::new();
let mut child =
tester.spawn_command_tui(tv_local_config_and_cable_with_args(&[]));
tester.send(&ctrl('g'));
tester.assert_tui_frame_contains("current mode:");
// Exit the application
tester.send(&ctrl('c'));
PtyTester::assert_exit_ok(&mut child, DEFAULT_DELAY);
}
#[test]
// FIXME: was lazy, this should be more robust
fn toggle_preview() {

View File

@ -16,6 +16,8 @@ match_fg = '#f38ba8'
# preview
preview_title_fg = '#fab387'
# modes
channel_mode_fg = '#f5c2e7'
remote_control_mode_fg = '#a6e3a1'
channel_mode_fg = '#1e1e2e'
channel_mode_bg = '#f5c2e7'
remote_control_mode_fg = '#1e1e2e'
remote_control_mode_bg = '#a6e3a1'
send_to_channel_mode_fg = '#89dceb'

View File

@ -16,6 +16,8 @@ match_fg = 'bright-red'
# preview
preview_title_fg = 'bright-magenta'
# modes
channel_mode_fg = 'green'
remote_control_mode_fg = 'yellow'
channel_mode_fg = 'black'
channel_mode_bg = 'green'
remote_control_mode_fg = 'black'
remote_control_mode_bg = 'yellow'
send_to_channel_mode_fg = 'cyan'

View File

@ -16,6 +16,8 @@ match_fg = '#fb4934'
# preview
preview_title_fg = '#b8bb26'
# modes
channel_mode_fg = '#b16286'
remote_control_mode_fg = '#8ec07c'
channel_mode_fg = '#282828'
channel_mode_bg = '#b16286'
remote_control_mode_fg = '#282828'
remote_control_mode_bg = '#8ec07c'
send_to_channel_mode_fg = '#458588'