mirror of
https://github.com/alexpasmantier/television.git
synced 2025-07-29 14:21:43 +00:00
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  remote mode  Add sorting for remote entries --------- Co-authored-by: Alex Pasmantier <alex.pasmant@gmail.com>
This commit is contained in:
parent
a81a86f1fd
commit
ad4e254ae6
@ -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
|
||||
|
@ -450,7 +450,6 @@ pub fn draw(c: &mut Criterion) {
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
Some(50),
|
||||
false,
|
||||
cable.clone(),
|
||||
|
@ -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
|
||||
|
||||
|
@ -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),
|
||||
|
@ -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,
|
||||
|
@ -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());
|
||||
|
@ -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()
|
||||
|
@ -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.
|
||||
|
@ -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,
|
||||
|
@ -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'))),
|
||||
])
|
||||
);
|
||||
|
@ -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),
|
||||
|
@ -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);
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -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,
|
||||
}
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
196
television/screen/keybinding_panel.rs
Normal file
196
television/screen/keybinding_panel.rs
Normal 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)
|
||||
}
|
176
television/screen/keybinding_utils.rs
Normal file
176
television/screen/keybinding_utils.rs
Normal 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()
|
||||
}
|
@ -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
|
||||
}
|
@ -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)),
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
)
|
||||
}
|
@ -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;
|
||||
|
@ -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))
|
||||
|
185
television/screen/status_bar.rs
Normal file
185
television/screen/status_bar.rs
Normal 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],
|
||||
);
|
||||
}
|
@ -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![]),
|
||||
|
@ -52,7 +52,6 @@ fn setup_app(
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
Some(50),
|
||||
config.application.tick_rate,
|
||||
0.0, // watch_interval
|
||||
|
14
tests/cli.rs
14
tests/cli.rs
@ -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();
|
||||
|
15
tests/ui.rs
15
tests/ui.rs
@ -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() {
|
||||
|
@ -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'
|
||||
|
@ -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'
|
||||
|
@ -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'
|
||||
|
Loading…
x
Reference in New Issue
Block a user