diff --git a/Cargo.lock b/Cargo.lock index 55aef9b..e6611c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -344,7 +344,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.4", + "thiserror 2.0.5", ] [[package]] @@ -364,9 +364,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f34d93e62b03caf570cccc334cbc6c2fceca82f39211051345108adcba3eebdc" +checksum = "27f657647bcff5394bf56c7317665bbf790a137a50eaaa5c6bfbb9e27a518f2d" dependencies = [ "shlex", ] @@ -379,9 +379,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "clap" -version = "4.5.22" +version = "4.5.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69371e34337c4c984bbe322360c2547210bf632eb2814bbe78a6e87a2935bd2b" +checksum = "3135e7ec2ef7b10c6ed8950f0f792ed96ee093fa088608f1c76e569722700c84" dependencies = [ "clap_builder", "clap_derive", @@ -389,9 +389,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.22" +version = "4.5.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e24c1b4099818523236a8ca881d2b45db98dadfb4625cf6608c12069fcbbde1" +checksum = "30582fc632330df2bd26877bde0c1f4470d57c582bbc070376afcd04d8cb4838" dependencies = [ "anstream", "anstyle", @@ -413,9 +413,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afb84c814227b90d6895e01398aee0d8033c00e7466aca416fb6a8e0eb19d8a7" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" [[package]] name = "clipboard-win" @@ -1041,7 +1041,7 @@ dependencies = [ "parking_lot", "signal-hook", "smallvec", - "thiserror 2.0.4", + "thiserror 2.0.5", ] [[package]] @@ -1054,7 +1054,7 @@ dependencies = [ "gix-date", "gix-utils", "itoa", - "thiserror 2.0.4", + "thiserror 2.0.5", "winnow", ] @@ -1064,7 +1064,7 @@ version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d48b897b4bbc881aea994b4a5bbb340a04979d7be9089791304e04a9fbc66b53" dependencies = [ - "thiserror 2.0.4", + "thiserror 2.0.5", ] [[package]] @@ -1073,7 +1073,7 @@ version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6ffbeb3a5c0b8b84c3fe4133a6f8c82fa962f4caefe8d0762eced025d3eb4f7" dependencies = [ - "thiserror 2.0.4", + "thiserror 2.0.5", ] [[package]] @@ -1087,7 +1087,7 @@ dependencies = [ "gix-features", "gix-hash", "memmap2", - "thiserror 2.0.4", + "thiserror 2.0.5", ] [[package]] @@ -1106,7 +1106,7 @@ dependencies = [ "memchr", "once_cell", "smallvec", - "thiserror 2.0.4", + "thiserror 2.0.5", "unicode-bom", "winnow", ] @@ -1121,7 +1121,7 @@ dependencies = [ "bstr", "gix-path", "libc", - "thiserror 2.0.4", + "thiserror 2.0.5", ] [[package]] @@ -1133,7 +1133,7 @@ dependencies = [ "bstr", "itoa", "jiff", - "thiserror 2.0.4", + "thiserror 2.0.5", ] [[package]] @@ -1145,7 +1145,7 @@ dependencies = [ "bstr", "gix-hash", "gix-object", - "thiserror 2.0.4", + "thiserror 2.0.5", ] [[package]] @@ -1161,7 +1161,7 @@ dependencies = [ "gix-path", "gix-ref", "gix-sec", - "thiserror 2.0.4", + "thiserror 2.0.5", ] [[package]] @@ -1179,7 +1179,7 @@ dependencies = [ "once_cell", "prodash", "sha1_smol", - "thiserror 2.0.4", + "thiserror 2.0.5", "walkdir", ] @@ -1213,7 +1213,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b5eccc17194ed0e67d49285e4853307e4147e95407f91c1c3e4a13ba9f4e4ce" dependencies = [ "faster-hex", - "thiserror 2.0.4", + "thiserror 2.0.5", ] [[package]] @@ -1252,7 +1252,7 @@ dependencies = [ "memmap2", "rustix", "smallvec", - "thiserror 2.0.4", + "thiserror 2.0.5", ] [[package]] @@ -1263,7 +1263,7 @@ checksum = "1cd3ab68a452db63d9f3ebdacb10f30dba1fa0d31ac64f4203d395ed1102d940" dependencies = [ "gix-tempfile", "gix-utils", - "thiserror 2.0.4", + "thiserror 2.0.5", ] [[package]] @@ -1282,7 +1282,7 @@ dependencies = [ "gix-validate", "itoa", "smallvec", - "thiserror 2.0.4", + "thiserror 2.0.5", "winnow", ] @@ -1304,7 +1304,7 @@ dependencies = [ "gix-quote", "parking_lot", "tempfile", - "thiserror 2.0.4", + "thiserror 2.0.5", ] [[package]] @@ -1322,7 +1322,7 @@ dependencies = [ "gix-path", "memmap2", "smallvec", - "thiserror 2.0.4", + "thiserror 2.0.5", ] [[package]] @@ -1335,7 +1335,7 @@ dependencies = [ "gix-trace", "home", "once_cell", - "thiserror 2.0.4", + "thiserror 2.0.5", ] [[package]] @@ -1346,7 +1346,7 @@ checksum = "64a1e282216ec2ab2816cd57e6ed88f8009e634aec47562883c05ac8a7009a63" dependencies = [ "bstr", "gix-utils", - "thiserror 2.0.4", + "thiserror 2.0.5", ] [[package]] @@ -1366,7 +1366,7 @@ dependencies = [ "gix-utils", "gix-validate", "memmap2", - "thiserror 2.0.4", + "thiserror 2.0.5", "winnow", ] @@ -1381,7 +1381,7 @@ dependencies = [ "gix-revision", "gix-validate", "smallvec", - "thiserror 2.0.4", + "thiserror 2.0.5", ] [[package]] @@ -1399,7 +1399,7 @@ dependencies = [ "gix-object", "gix-revwalk", "gix-trace", - "thiserror 2.0.4", + "thiserror 2.0.5", ] [[package]] @@ -1414,7 +1414,7 @@ dependencies = [ "gix-hashtable", "gix-object", "smallvec", - "thiserror 2.0.4", + "thiserror 2.0.5", ] [[package]] @@ -1464,7 +1464,7 @@ dependencies = [ "gix-object", "gix-revwalk", "smallvec", - "thiserror 2.0.4", + "thiserror 2.0.5", ] [[package]] @@ -1476,7 +1476,7 @@ dependencies = [ "bstr", "gix-features", "gix-path", - "thiserror 2.0.4", + "thiserror 2.0.5", "url", ] @@ -1497,7 +1497,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd520d09f9f585b34b32aba1d0b36ada89ab7fefb54a8ca3fe37fc482a750937" dependencies = [ "bstr", - "thiserror 2.0.4", + "thiserror 2.0.5", ] [[package]] @@ -2207,20 +2207,20 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "pest" -version = "2.7.14" +version = "2.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879952a81a83930934cbf1786752d6dedc3b1f29e8f8fb2ad1d0a36f377cf442" +checksum = "8b7cafe60d6cf8e62e1b9b2ea516a089c008945bb5a275416789e7db0bc199dc" dependencies = [ "memchr", - "thiserror 1.0.69", + "thiserror 2.0.5", "ucd-trie", ] [[package]] name = "pest_derive" -version = "2.7.14" +version = "2.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d214365f632b123a47fd913301e14c946c61d1c183ee245fa76eb752e59a02dd" +checksum = "816518421cfc6887a0d62bf441b6ffb4536fcc926395a69e1a85852d4363f57e" dependencies = [ "pest", "pest_generator", @@ -2228,9 +2228,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.7.14" +version = "2.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb55586734301717aea2ac313f50b2eb8f60d2fc3dc01d190eefa2e625f60c4e" +checksum = "7d1396fd3a870fc7838768d171b4616d5c91f6cc25e377b673d714567d99377b" dependencies = [ "pest", "pest_meta", @@ -2241,9 +2241,9 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.7.14" +version = "2.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b75da2a70cf4d9cb76833c990ac9cd3923c9a8905a8929789ce347c84564d03d" +checksum = "e1e58089ea25d717bfd31fb534e4f3afcc2cc569c70de3e239778991ea3b7dea" dependencies = [ "once_cell", "pest", @@ -2854,6 +2854,7 @@ dependencies = [ "television-derive", "television-fuzzy", "television-previewers", + "television-screen", "television-utils", "tokio", "toml", @@ -2917,6 +2918,20 @@ dependencies = [ "tracing", ] +[[package]] +name = "television-screen" +version = "0.0.10" +dependencies = [ + "ansi-to-tui", + "color-eyre", + "ratatui", + "serde", + "syntect", + "television-channels", + "television-previewers", + "television-utils", +] + [[package]] name = "television-utils" version = "0.0.10" @@ -2929,6 +2944,7 @@ dependencies = [ "lazy_static", "syntect", "tracing", + "unicode-width 0.2.0", "winapi-util", ] @@ -2956,11 +2972,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.4" +version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f49a1853cf82743e3b7950f77e0f4d622ca36cf4317cba00c767838bac8d490" +checksum = "643caef17e3128658ff44d85923ef2d28af81bb71e0d67bbfe1d76f19a73e053" dependencies = [ - "thiserror-impl 2.0.4", + "thiserror-impl 2.0.5", ] [[package]] @@ -2976,9 +2992,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.4" +version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8381894bb3efe0c4acac3ded651301ceee58a15d47c2e34885ed1908ad667061" +checksum = "995d0bbc9995d1f19d28b7215a9352b0fc3cd3a2d2ec95c2cadc485cdedbcdde" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 38accda..a3c3223 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,17 +10,17 @@ repository = "https://github.com/alexpasmantier/television" homepage = "https://github.com/alexpasmantier/television" keywords = ["search", "fuzzy", "preview", "tui", "terminal"] categories = [ - "command-line-utilities", - "command-line-interface", - "concurrency", - "development-tools", + "command-line-utilities", + "command-line-interface", + "concurrency", + "development-tools", ] include = [ - "LICENSE", - "README.md", - "crates/television/**/*.rs", - "build.rs", - ".config/config.toml", + "LICENSE", + "README.md", + "crates/television/**/*.rs", + "build.rs", + ".config/config.toml", ] rust-version = "1.80.0" @@ -37,10 +37,10 @@ repository = "https://github.com/alexpasmantier/television" homepage = "https://github.com/alexpasmantier/television" keywords = ["search", "fuzzy", "preview", "tui", "terminal"] categories = [ - "command-line-utilities", - "command-line-interface", - "concurrency", - "development-tools", + "command-line-utilities", + "command-line-interface", + "concurrency", + "development-tools", ] include = ["LICENSE", "README.md", "crates/television/**/*.rs", "build.rs"] rust-version = "1.80.0" @@ -56,6 +56,7 @@ name = "tv" # workspace dependencies television-fuzzy = { path = "crates/television-fuzzy", version = "0.0.10" } television-derive = { path = "crates/television-derive", version = "0.0.10" } +television-screen = { path = "crates/television-screen", version = "0.0.10" } television-channels = { path = "crates/television-channels", version = "0.0.10" } television-previewers = { path = "crates/television-previewers", version = "0.0.10" } television-utils = { path = "crates/television-utils", version = "0.0.10" } diff --git a/crates/television-screen/Cargo.toml b/crates/television-screen/Cargo.toml new file mode 100644 index 0000000..f1799d0 --- /dev/null +++ b/crates/television-screen/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "television-screen" +version = "0.0.10" +edition.workspace = true +description.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true +keywords.workspace = true +categories.workspace = true +include.workspace = true +rust-version.workspace = true +readme.workspace = true + +[dependencies] +ratatui = "0.29.0" +serde = "1.0.215" +television-utils = { path = "../television-utils", version="0.0.10" } +television-channels = { path = "../television-channels", version="0.0.10" } +television-previewers = { path = "../television-previewers", version="0.0.10" } +color-eyre = "0.6.3" +ansi-to-tui = "7.0.0" +syntect = "5.2.0" diff --git a/crates/television/ui/cache.rs b/crates/television-screen/src/cache.rs similarity index 100% rename from crates/television/ui/cache.rs rename to crates/television-screen/src/cache.rs diff --git a/crates/television-screen/src/colors.rs b/crates/television-screen/src/colors.rs new file mode 100644 index 0000000..309f545 --- /dev/null +++ b/crates/television-screen/src/colors.rs @@ -0,0 +1,63 @@ +use ratatui::style::Color; + +pub const BORDER_COLOR: Color = Color::Blue; +pub const ACTION_COLOR: Color = Color::DarkGray; +// Styles +// input +pub const DEFAULT_INPUT_FG: Color = Color::LightRed; +pub const DEFAULT_RESULTS_COUNT_FG: Color = Color::LightRed; +// preview +pub const DEFAULT_PREVIEW_TITLE_FG: Color = Color::Blue; +pub const DEFAULT_SELECTED_PREVIEW_BG: Color = Color::Rgb(50, 50, 50); +pub const DEFAULT_PREVIEW_CONTENT_FG: Color = Color::Rgb(150, 150, 180); +pub const DEFAULT_PREVIEW_GUTTER_FG: Color = Color::Rgb(70, 70, 70); +pub const DEFAULT_PREVIEW_GUTTER_SELECTED_FG: Color = + Color::Rgb(255, 150, 150); +// Styles +pub const DEFAULT_RESULT_NAME_FG: Color = Color::Blue; +pub const DEFAULT_RESULT_PREVIEW_FG: Color = Color::Rgb(150, 150, 150); +pub const DEFAULT_RESULT_LINE_NUMBER_FG: Color = Color::Yellow; +pub const DEFAULT_RESULT_SELECTED_BG: Color = Color::Rgb(50, 50, 50); + +pub const DEFAULT_RESULTS_LIST_MATCH_FOREGROUND_COLOR: Color = Color::Red; + +pub struct ResultsListColors { + pub result_name_fg: Color, + pub result_preview_fg: Color, + pub result_line_number_fg: Color, + pub result_selected_bg: Color, +} + +impl Default for ResultsListColors { + fn default() -> Self { + Self { + result_name_fg: DEFAULT_RESULT_NAME_FG, + result_preview_fg: DEFAULT_RESULT_PREVIEW_FG, + result_line_number_fg: DEFAULT_RESULT_LINE_NUMBER_FG, + result_selected_bg: DEFAULT_RESULT_SELECTED_BG, + } + } +} + +#[allow(dead_code)] +impl ResultsListColors { + pub fn result_name_fg(mut self, color: Color) -> Self { + self.result_name_fg = color; + self + } + + pub fn result_preview_fg(mut self, color: Color) -> Self { + self.result_preview_fg = color; + self + } + + pub fn result_line_number_fg(mut self, color: Color) -> Self { + self.result_line_number_fg = color; + self + } + + pub fn result_selected_bg(mut self, color: Color) -> Self { + self.result_selected_bg = color; + self + } +} diff --git a/crates/television-screen/src/help.rs b/crates/television-screen/src/help.rs new file mode 100644 index 0000000..c956ec5 --- /dev/null +++ b/crates/television-screen/src/help.rs @@ -0,0 +1,79 @@ +use super::layout::HelpBarLayout; +use crate::colors::BORDER_COLOR; +use crate::logo::build_logo_paragraph; +use crate::metadata::build_metadata_table; +use crate::mode::{mode_color, Mode}; +use ratatui::layout::Rect; +use ratatui::prelude::{Color, Style}; +use ratatui::widgets::{Block, BorderType, Borders, Padding, Table}; +use ratatui::Frame; +use television_channels::channels::UnitChannel; +use television_utils::metadata::AppMetadata; + +pub fn draw_logo_block(f: &mut Frame, area: Rect, color: Color) { + let logo_block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(BORDER_COLOR)) + .style(Style::default().fg(color)) + .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: UnitChannel, + app_metadata: &AppMetadata, +) { + let metadata_block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(Color::Blue)) + .padding(Padding::horizontal(1)) + .style(Style::default()); + + let metadata_table = + build_metadata_table(mode, current_channel, app_metadata) + .block(metadata_block); + + f.render_widget(metadata_table, area); +} + +fn draw_keymaps_block(f: &mut Frame, area: Rect, keymap_table: Table) { + let keymaps_block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(Color::Blue)) + .style(Style::default()) + .padding(Padding::horizontal(1)); + + let keymaps_table = keymap_table.block(keymaps_block); + + f.render_widget(keymaps_table, area); +} + +pub fn draw_help_bar( + f: &mut Frame, + layout: &Option, + current_channel: UnitChannel, + keymap_table: Table, + mode: Mode, + app_metadata: &AppMetadata, +) { + if let Some(help_bar) = layout { + draw_metadata_block( + f, + help_bar.left, + mode, + current_channel, + app_metadata, + ); + draw_keymaps_block(f, help_bar.middle, keymap_table); + draw_logo_block(f, help_bar.right, mode_color(mode)); + } +} diff --git a/crates/television-screen/src/input.rs b/crates/television-screen/src/input.rs new file mode 100644 index 0000000..30a8219 --- /dev/null +++ b/crates/television-screen/src/input.rs @@ -0,0 +1,116 @@ +use color_eyre::Result; +use ratatui::{ + layout::{ + Alignment, Constraint, Direction, Layout as RatatuiLayout, Rect, + }, + style::{Style, Stylize}, + text::{Line, Span}, + widgets::{Block, BorderType, Borders, ListState, Paragraph}, + Frame, +}; +use television_utils::input::Input; + +use crate::{ + colors::{BORDER_COLOR, DEFAULT_INPUT_FG, DEFAULT_RESULTS_COUNT_FG}, + spinner::{Spinner, SpinnerState}, +}; + +// TODO: refactor arguments (e.g. use a struct for the spinner+state, same +#[allow(clippy::too_many_arguments)] +pub fn draw_input_box( + f: &mut Frame, + rect: Rect, + results_count: u32, + total_count: u32, + input_state: &mut Input, + results_picker_state: &mut ListState, + matcher_running: bool, + spinner: &Spinner, + spinner_state: &mut SpinnerState, +) -> Result<()> { + let input_block = Block::default() + .title_top(Line::from(" Pattern ").alignment(Alignment::Center)) + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(BORDER_COLOR)) + .style(Style::default()); + + let input_block_inner = input_block.inner(rect); + if input_block_inner.area() == 0 { + return Ok(()); + } + + f.render_widget(input_block, rect); + + // split input block into 4 parts: prompt symbol, input, result count, spinner + let inner_input_chunks = RatatuiLayout::default() + .direction(Direction::Horizontal) + .constraints([ + // prompt symbol + Constraint::Length(2), + // input field + Constraint::Fill(1), + // result count + Constraint::Length( + 3 * ((total_count as f32).log10().ceil() as u16 + 1) + 3, + ), + // spinner + Constraint::Length(1), + ]) + .split(input_block_inner); + + let arrow_block = Block::default(); + let arrow = Paragraph::new(Span::styled( + "> ", + Style::default().fg(DEFAULT_INPUT_FG).bold(), + )) + .block(arrow_block); + f.render_widget(arrow, inner_input_chunks[0]); + + let interactive_input_block = Block::default(); + // keep 2 for borders and 1 for cursor + let width = inner_input_chunks[1].width.max(3) - 3; + let scroll = input_state.visual_scroll(width as usize); + let input = Paragraph::new(input_state.value()) + .scroll((0, u16::try_from(scroll)?)) + .block(interactive_input_block) + .style(Style::default().fg(DEFAULT_INPUT_FG).bold().italic()) + .alignment(Alignment::Left); + f.render_widget(input, inner_input_chunks[1]); + + if matcher_running { + f.render_stateful_widget( + spinner, + inner_input_chunks[3], + spinner_state, + ); + } + + let result_count_block = Block::default(); + let result_count_paragraph = Paragraph::new(Span::styled( + format!( + " {} / {} ", + if results_count == 0 { + 0 + } else { + results_picker_state.selected().unwrap_or(0) + 1 + }, + results_count, + ), + Style::default().fg(DEFAULT_RESULTS_COUNT_FG).italic(), + )) + .block(result_count_block) + .alignment(Alignment::Right); + f.render_widget(result_count_paragraph, inner_input_chunks[2]); + + // Make the cursor visible and ask tui-rs to put it at the + // specified coordinates after rendering + f.set_cursor_position(( + // Put cursor past the end of the input text + inner_input_chunks[1].x + + u16::try_from(input_state.visual_cursor().max(scroll) - scroll)?, + // Move one line down, from the border to the input line + inner_input_chunks[1].y, + )); + Ok(()) +} diff --git a/crates/television-screen/src/keybindings.rs b/crates/television-screen/src/keybindings.rs new file mode 100644 index 0000000..0394e02 --- /dev/null +++ b/crates/television-screen/src/keybindings.rs @@ -0,0 +1,279 @@ +use std::{collections::HashMap, fmt::Display}; + +use crate::{ + colors::ACTION_COLOR, + mode::{Mode, CHANNEL_COLOR, REMOTE_CONTROL_COLOR, SEND_TO_CHANNEL_COLOR}, +}; +use ratatui::{ + layout::Constraint, + style::{Color, Style}, + text::{Line, Span}, + widgets::{Cell, Row, Table}, +}; + +#[derive(Debug, Clone)] +pub struct DisplayableKeybindings { + bindings: HashMap>, +} + +impl DisplayableKeybindings { + pub fn new(bindings: HashMap>) -> Self { + Self { bindings } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum DisplayableAction { + ResultsNavigation, + PreviewNavigation, + SelectEntry, + CopyEntryToClipboard, + SendToChannel, + ToggleRemoteControl, + Cancel, + Quit, +} + +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::SendToChannel => "Send to channel", + DisplayableAction::ToggleRemoteControl => "Toggle Remote control", + DisplayableAction::Cancel => "Cancel", + DisplayableAction::Quit => "Quit", + }; + write!(f, "{}", action) + } +} + +pub fn build_keybindings_table( + keybindings: &HashMap, + mode: Mode, +) -> Table<'_> { + match mode { + Mode::Channel => { + build_keybindings_table_for_channel(&keybindings[&mode]) + } + Mode::RemoteControl => { + build_keybindings_table_for_channel_selection(&keybindings[&mode]) + } + Mode::SendToChannel => { + build_keybindings_table_for_channel_transitions( + &keybindings[&mode], + ) + } + } +} + +fn build_keybindings_table_for_channel( + keybindings: &DisplayableKeybindings, +) -> Table<'_> { + // 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, + CHANNEL_COLOR, + )); + + // 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, + CHANNEL_COLOR, + )); + + // 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, + CHANNEL_COLOR, + )); + + // 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, + CHANNEL_COLOR, + )); + + // Send to channel + let send_to_channel_keys = keybindings + .bindings + .get(&DisplayableAction::SendToChannel) + .unwrap(); + let send_to_channel_row = Row::new(build_cells_for_group( + "Send results to", + send_to_channel_keys, + CHANNEL_COLOR, + )); + + // 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, + CHANNEL_COLOR, + )); + + // MISC line (quit, help, etc.) + // Quit ⏼ + let quit_keys = + keybindings.bindings.get(&DisplayableAction::Quit).unwrap(); + let quit_row = + Row::new(build_cells_for_group("Quit", quit_keys, CHANNEL_COLOR)); + + let widths = vec![Constraint::Fill(1), Constraint::Fill(2)]; + + Table::new( + vec![ + results_row, + preview_row, + select_entry_row, + copy_entry_row, + send_to_channel_row, + switch_channels_row, + quit_row, + ], + widths, + ) +} + +fn build_keybindings_table_for_channel_selection( + keybindings: &DisplayableKeybindings, +) -> Table<'_> { + // Results navigation + let navigation_keys = keybindings + .bindings + .get(&DisplayableAction::ResultsNavigation) + .unwrap(); + let results_row = Row::new(build_cells_for_group( + "Browse channels", + navigation_keys, + REMOTE_CONTROL_COLOR, + )); + + // 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, + REMOTE_CONTROL_COLOR, + )); + + // 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, + REMOTE_CONTROL_COLOR, + )); + + Table::new( + vec![results_row, select_entry_row, switch_channels_row], + vec![Constraint::Fill(1), Constraint::Fill(2)], + ) +} + +fn build_keybindings_table_for_channel_transitions( + keybindings: &DisplayableKeybindings, +) -> Table<'_> { + // Results navigation + let results_navigation_keys = keybindings + .bindings + .get(&DisplayableAction::ResultsNavigation) + .unwrap(); + let results_row = Row::new(build_cells_for_group( + "Browse channels", + results_navigation_keys, + SEND_TO_CHANNEL_COLOR, + )); + + // Select entry + let select_entry_keys = keybindings + .bindings + .get(&DisplayableAction::SelectEntry) + .unwrap(); + let select_entry_row = Row::new(build_cells_for_group( + "Send to channel", + select_entry_keys, + SEND_TO_CHANNEL_COLOR, + )); + + // Cancel + let cancel_keys = keybindings + .bindings + .get(&DisplayableAction::Cancel) + .unwrap(); + let cancel_row = Row::new(build_cells_for_group( + "Cancel", + cancel_keys, + SEND_TO_CHANNEL_COLOR, + )); + + Table::new( + vec![results_row, select_entry_row, cancel_row], + vec![Constraint::Fill(1), Constraint::Fill(2)], + ) +} + +fn build_cells_for_group<'a>( + group_name: &str, + keys: &'a [String], + key_color: Color, +) -> Vec> { + // group name + let mut cells = vec![Cell::from(Span::styled( + group_name.to_owned() + ": ", + Style::default().fg(ACTION_COLOR), + ))]; + + let spans = keys.iter().skip(1).fold( + vec![Span::styled( + keys[0].clone(), + Style::default().fg(key_color), + )], + |mut acc, key| { + acc.push(Span::raw(" / ")); + acc.push(Span::styled( + key.to_owned(), + Style::default().fg(key_color), + )); + acc + }, + ); + + cells.push(Cell::from(Line::from(spans))); + + cells +} diff --git a/crates/television/ui/layout.rs b/crates/television-screen/src/layout.rs similarity index 100% rename from crates/television/ui/layout.rs rename to crates/television-screen/src/layout.rs diff --git a/crates/television-screen/src/lib.rs b/crates/television-screen/src/lib.rs new file mode 100644 index 0000000..56c364c --- /dev/null +++ b/crates/television-screen/src/lib.rs @@ -0,0 +1,13 @@ +pub mod cache; +pub mod colors; +pub mod help; +pub mod input; +pub mod keybindings; +pub mod layout; +pub mod logo; +pub mod metadata; +pub mod mode; +pub mod preview; +pub mod remote_control; +pub mod results; +pub mod spinner; diff --git a/crates/television/ui/logo.rs b/crates/television-screen/src/logo.rs similarity index 100% rename from crates/television/ui/logo.rs rename to crates/television-screen/src/logo.rs diff --git a/crates/television-screen/src/metadata.rs b/crates/television-screen/src/metadata.rs new file mode 100644 index 0000000..c84979d --- /dev/null +++ b/crates/television-screen/src/metadata.rs @@ -0,0 +1,125 @@ +use std::fmt::Display; + +use crate::mode::{mode_color, Mode}; +use ratatui::{ + layout::Constraint, + style::{Color, Style}, + text::Span, + widgets::{Cell, Row, Table}, +}; +use television_channels::channels::UnitChannel; +use television_utils::metadata::AppMetadata; + +const METADATA_FIELD_NAME_COLOR: Color = Color::DarkGray; +const METADATA_FIELD_VALUE_COLOR: Color = Color::Gray; + +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"), + Mode::SendToChannel => write!(f, "Send to Channel"), + } + } +} + +pub fn build_metadata_table( + mode: Mode, + current_channel: UnitChannel, + app_metadata: &AppMetadata, +) -> Table<'_> { + let version_row = Row::new(vec![ + Cell::from(Span::styled( + "version: ", + Style::default().fg(METADATA_FIELD_NAME_COLOR), + )), + Cell::from(Span::styled( + &app_metadata.version, + Style::default().fg(METADATA_FIELD_VALUE_COLOR), + )), + ]); + + let target_triple_row = Row::new(vec![ + Cell::from(Span::styled( + "target triple: ", + Style::default().fg(METADATA_FIELD_NAME_COLOR), + )), + Cell::from(Span::styled( + &app_metadata.build.target_triple, + Style::default().fg(METADATA_FIELD_VALUE_COLOR), + )), + ]); + + let build_row = Row::new(vec![ + Cell::from(Span::styled( + "build: ", + Style::default().fg(METADATA_FIELD_NAME_COLOR), + )), + Cell::from(Span::styled( + &app_metadata.build.rustc_version, + Style::default().fg(METADATA_FIELD_VALUE_COLOR), + )), + Cell::from(Span::styled( + " (", + Style::default().fg(METADATA_FIELD_NAME_COLOR), + )), + Cell::from(Span::styled( + &app_metadata.build.build_date, + Style::default().fg(METADATA_FIELD_VALUE_COLOR), + )), + Cell::from(Span::styled( + ")", + Style::default().fg(METADATA_FIELD_NAME_COLOR), + )), + ]); + + let current_dir_row = Row::new(vec![ + Cell::from(Span::styled( + "current directory: ", + Style::default().fg(METADATA_FIELD_NAME_COLOR), + )), + Cell::from(Span::styled( + std::env::current_dir() + .expect("Could not get current directory") + .display() + .to_string(), + Style::default().fg(METADATA_FIELD_VALUE_COLOR), + )), + ]); + + let current_channel_row = Row::new(vec![ + Cell::from(Span::styled( + "current channel: ", + Style::default().fg(METADATA_FIELD_NAME_COLOR), + )), + Cell::from(Span::styled( + current_channel.to_string(), + Style::default().fg(METADATA_FIELD_VALUE_COLOR), + )), + ]); + + let current_mode_row = Row::new(vec![ + Cell::from(Span::styled( + "current mode: ", + Style::default().fg(METADATA_FIELD_NAME_COLOR), + )), + Cell::from(Span::styled( + mode.to_string(), + Style::default().fg(mode_color(mode)), + )), + ]); + + let widths = vec![Constraint::Fill(1), Constraint::Fill(2)]; + + Table::new( + vec![ + version_row, + target_triple_row, + build_row, + current_dir_row, + current_channel_row, + current_mode_row, + ], + widths, + ) +} diff --git a/crates/television-screen/src/mode.rs b/crates/television-screen/src/mode.rs new file mode 100644 index 0000000..ae02769 --- /dev/null +++ b/crates/television-screen/src/mode.rs @@ -0,0 +1,22 @@ +use ratatui::style::Color; +use serde::{Deserialize, Serialize}; + +pub const CHANNEL_COLOR: Color = Color::Indexed(222); +pub const REMOTE_CONTROL_COLOR: Color = Color::Indexed(1); +pub const SEND_TO_CHANNEL_COLOR: Color = Color::Indexed(105); + +pub fn mode_color(mode: Mode) -> Color { + match mode { + Mode::Channel => CHANNEL_COLOR, + Mode::RemoteControl => REMOTE_CONTROL_COLOR, + Mode::SendToChannel => SEND_TO_CHANNEL_COLOR, + } +} + +// FIXME: Mode shouldn't be in the screen crate +#[derive(PartialEq, Copy, Clone, Hash, Eq, Debug, Serialize, Deserialize)] +pub enum Mode { + Channel, + RemoteControl, + SendToChannel, +} diff --git a/crates/television-screen/src/preview.rs b/crates/television-screen/src/preview.rs new file mode 100644 index 0000000..313a4f0 --- /dev/null +++ b/crates/television-screen/src/preview.rs @@ -0,0 +1,413 @@ +use crate::cache::RenderedPreviewCache; +use crate::colors::{ + BORDER_COLOR, DEFAULT_PREVIEW_CONTENT_FG, DEFAULT_PREVIEW_GUTTER_FG, + DEFAULT_PREVIEW_GUTTER_SELECTED_FG, DEFAULT_PREVIEW_TITLE_FG, + DEFAULT_SELECTED_PREVIEW_BG, +}; +use ansi_to_tui::IntoText; +use color_eyre::eyre::Result; +use ratatui::layout::{Alignment, Rect}; +use ratatui::prelude::{Color, Line, Modifier, Span, Style, Stylize, Text}; +use ratatui::widgets::{Block, BorderType, Borders, Padding, Paragraph, Wrap}; +use ratatui::Frame; +use std::str::FromStr; +use std::sync::{Arc, Mutex}; +use syntect::highlighting::Color as SyntectColor; +use television_channels::entry::Entry; +use television_previewers::previewers::{ + Preview, PreviewContent, FILE_TOO_LARGE_MSG, PREVIEW_NOT_SUPPORTED_MSG, +}; +use television_utils::strings::{ + replace_non_printable, shrink_with_ellipsis, ReplaceNonPrintableConfig, + EMPTY_STRING, +}; + +#[allow(dead_code)] +const FILL_CHAR_SLANTED: char = '╱'; +const FILL_CHAR_EMPTY: char = ' '; + +pub fn build_preview_paragraph( + preview_block: Block, + inner: Rect, + preview_content: PreviewContent, + target_line: Option, + preview_scroll: u16, +) -> Paragraph { + match preview_content { + PreviewContent::AnsiText(text) => { + build_ansi_text_paragraph(text, preview_block, preview_scroll) + } + PreviewContent::PlainText(content) => build_plain_text_paragraph( + content, + preview_block, + target_line, + preview_scroll, + ), + PreviewContent::PlainTextWrapped(content) => { + build_plain_text_wrapped_paragraph(content, preview_block) + } + PreviewContent::SyntectHighlightedText(highlighted_lines) => { + build_syntect_highlighted_paragraph( + highlighted_lines, + preview_block, + target_line, + preview_scroll, + ) + } + // meta + PreviewContent::Loading => { + build_meta_preview_paragraph(inner, "Loading...", FILL_CHAR_EMPTY) + .block(preview_block) + .alignment(Alignment::Left) + .style(Style::default().add_modifier(Modifier::ITALIC)) + } + PreviewContent::NotSupported => build_meta_preview_paragraph( + inner, + PREVIEW_NOT_SUPPORTED_MSG, + FILL_CHAR_EMPTY, + ) + .block(preview_block) + .alignment(Alignment::Left) + .style(Style::default().add_modifier(Modifier::ITALIC)), + PreviewContent::FileTooLarge => build_meta_preview_paragraph( + inner, + FILE_TOO_LARGE_MSG, + FILL_CHAR_EMPTY, + ) + .block(preview_block) + .alignment(Alignment::Left) + .style(Style::default().add_modifier(Modifier::ITALIC)), + PreviewContent::Empty => Paragraph::new(Text::raw(EMPTY_STRING)), + } +} + +fn build_ansi_text_paragraph( + text: String, + preview_block: Block, + preview_scroll: u16, +) -> Paragraph { + let text = replace_non_printable( + text.as_bytes(), + &ReplaceNonPrintableConfig { + replace_line_feed: false, + replace_control_characters: false, + ..Default::default() + }, + ) + .0 + .into_text() + .unwrap(); + Paragraph::new(text) + .block(preview_block) + .scroll((preview_scroll, 0)) +} + +fn build_plain_text_paragraph( + text: Vec, + preview_block: Block, + target_line: Option, + preview_scroll: u16, +) -> Paragraph { + let mut lines = Vec::new(); + for (i, line) in text.iter().enumerate() { + lines.push(Line::from(vec![ + build_line_number_span(i + 1).style(Style::default().fg( + if matches!( + target_line, + Some(l) if l == u16::try_from(i).unwrap_or(0) + 1 + ) + { + DEFAULT_PREVIEW_GUTTER_SELECTED_FG + } else { + DEFAULT_PREVIEW_GUTTER_FG + }, + )), + Span::styled(" │ ", + Style::default().fg(DEFAULT_PREVIEW_GUTTER_FG).dim()), + Span::styled( + line.to_string(), + Style::default().fg(DEFAULT_PREVIEW_CONTENT_FG).bg( + if matches!(target_line, Some(l) if l == u16::try_from(i).unwrap() + 1) { + DEFAULT_SELECTED_PREVIEW_BG + } else { + Color::Reset + }, + ), + ), + ])); + } + let text = Text::from(lines); + Paragraph::new(text) + .block(preview_block) + .scroll((preview_scroll, 0)) +} + +fn build_plain_text_wrapped_paragraph( + text: String, + preview_block: Block, +) -> Paragraph { + let mut lines = Vec::new(); + for line in text.lines() { + lines.push(Line::styled( + line.to_string(), + Style::default().fg(DEFAULT_PREVIEW_CONTENT_FG), + )); + } + let text = Text::from(lines); + Paragraph::new(text) + .block(preview_block) + .wrap(Wrap { trim: true }) +} + +fn build_syntect_highlighted_paragraph( + highlighted_lines: Vec>, + preview_block: Block, + target_line: Option, + preview_scroll: u16, +) -> Paragraph { + compute_paragraph_from_highlighted_lines( + &highlighted_lines, + target_line.map(|l| l as usize), + ) + .block(preview_block) + .alignment(Alignment::Left) + .scroll((preview_scroll, 0)) +} + +pub fn build_meta_preview_paragraph<'a>( + inner: Rect, + message: &str, + fill_char: char, +) -> Paragraph<'a> { + let message_len = message.len(); + if message_len + 8 > inner.width as usize { + return Paragraph::new(Text::from(EMPTY_STRING)); + } + let fill_char_str = fill_char.to_string(); + let fill_line = fill_char_str.repeat(inner.width as usize); + + // Build the paragraph content with slanted lines and center the custom message + let mut lines = Vec::new(); + + // Calculate the vertical center + let vertical_center = inner.height as usize / 2; + let horizontal_padding = (inner.width as usize - message_len) / 2 - 4; + + // Fill the paragraph with slanted lines and insert the centered custom message + for i in 0..inner.height { + if i as usize == vertical_center { + // Center the message horizontally in the middle line + let line = format!( + "{} {} {}", + fill_char_str.repeat(horizontal_padding), + message, + fill_char_str.repeat( + inner.width as usize - horizontal_padding - message_len + ) + ); + lines.push(Line::from(line)); + } else if i as usize + 1 == vertical_center + || (i as usize).saturating_sub(1) == vertical_center + { + let line = format!( + "{} {} {}", + fill_char_str.repeat(horizontal_padding), + " ".repeat(message_len), + fill_char_str.repeat( + inner.width as usize - horizontal_padding - message_len + ) + ); + lines.push(Line::from(line)); + } else { + lines.push(Line::from(fill_line.clone())); + } + } + + // Create a paragraph with the generated content + Paragraph::new(Text::from(lines)) +} + +pub fn draw_preview_title_block( + f: &mut Frame, + rect: Rect, + preview: &Arc, + use_nerd_font_icons: bool, +) -> Result<()> { + let mut preview_title_spans = Vec::new(); + if preview.icon.is_some() && use_nerd_font_icons { + let icon = preview.icon.as_ref().unwrap(); + preview_title_spans.push(Span::styled( + { + let mut icon_str = String::from(icon.icon); + icon_str.push(' '); + icon_str + }, + Style::default().fg(Color::from_str(icon.color)?), + )); + } + preview_title_spans.push(Span::styled( + shrink_with_ellipsis( + &replace_non_printable( + preview.title.as_bytes(), + &ReplaceNonPrintableConfig::default(), + ) + .0, + rect.width.saturating_sub(4) as usize, + ), + Style::default().fg(DEFAULT_PREVIEW_TITLE_FG).bold(), + )); + let preview_title = Paragraph::new(Line::from(preview_title_spans)) + .block( + Block::default() + .padding(Padding::horizontal(1)) + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(BORDER_COLOR)), + ) + .alignment(Alignment::Left); + f.render_widget(preview_title, rect); + Ok(()) +} + +pub fn draw_preview_content_block( + f: &mut Frame, + rect: Rect, + entry: &Entry, + preview: &Arc, + rendered_preview_cache: &Arc>>, + preview_scroll: u16, +) { + let preview_outer_block = Block::default() + .title_top(Line::from(" Preview ").alignment(Alignment::Center)) + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(BORDER_COLOR)) + .style(Style::default()) + .padding(Padding::right(1)); + + let preview_inner_block = + Block::default().style(Style::default()).padding(Padding { + top: 0, + right: 1, + bottom: 0, + left: 1, + }); + let inner = preview_outer_block.inner(rect); + f.render_widget(preview_outer_block, rect); + + let target_line = entry.line_number.map(|l| u16::try_from(l).unwrap_or(0)); + let cache_key = compute_cache_key(entry); + + // Check if the rendered preview content is already in the cache + if let Some(preview_paragraph) = + rendered_preview_cache.lock().unwrap().get(&cache_key) + { + let p = preview_paragraph.as_ref().clone(); + f.render_widget(p.scroll((preview_scroll, 0)), inner); + return; + } + // If not, render the preview content and cache it if not empty + let rp = build_preview_paragraph( + preview_inner_block, + inner, + preview.content.clone(), + target_line, + preview_scroll, + ); + if !preview.stale { + rendered_preview_cache + .lock() + .unwrap() + .insert(cache_key, &Arc::new(rp.clone())); + } + f.render_widget( + Arc::new(rp).as_ref().clone().scroll((preview_scroll, 0)), + inner, + ); +} + +fn build_line_number_span<'a>(line_number: usize) -> Span<'a> { + Span::from(format!("{line_number:5} ")) +} + +fn compute_paragraph_from_highlighted_lines( + highlighted_lines: &[Vec<(syntect::highlighting::Style, String)>], + line_specifier: Option, +) -> Paragraph<'static> { + let preview_lines: Vec = highlighted_lines + .iter() + .enumerate() + .map(|(i, l)| { + let line_number = + build_line_number_span(i + 1).style(Style::default().fg( + if line_specifier.is_some() + && i == line_specifier.unwrap().saturating_sub(1) + { + DEFAULT_PREVIEW_GUTTER_SELECTED_FG + } else { + DEFAULT_PREVIEW_GUTTER_FG + }, + )); + Line::from_iter( + std::iter::once(line_number) + .chain(std::iter::once(Span::styled( + " │ ", + Style::default().fg(DEFAULT_PREVIEW_GUTTER_FG).dim(), + ))) + .chain(l.iter().cloned().map(|sr| { + convert_syn_region_to_span( + &(sr.0, sr.1), + if line_specifier.is_some() + && i == line_specifier + .unwrap() + .saturating_sub(1) + { + Some(SyntectColor { + r: 50, + g: 50, + b: 50, + a: 255, + }) + } else { + None + }, + ) + })), + ) + }) + .collect(); + + Paragraph::new(preview_lines) +} + +pub fn convert_syn_region_to_span<'a>( + syn_region: &(syntect::highlighting::Style, String), + background: Option, +) -> Span<'a> { + let mut style = Style::default() + .fg(convert_syn_color_to_ratatui_color(syn_region.0.foreground)); + if let Some(background) = background { + style = style.bg(convert_syn_color_to_ratatui_color(background)); + } + style = match syn_region.0.font_style { + syntect::highlighting::FontStyle::BOLD => style.bold(), + syntect::highlighting::FontStyle::ITALIC => style.italic(), + syntect::highlighting::FontStyle::UNDERLINE => style.underlined(), + _ => style, + }; + Span::styled(syn_region.1.clone(), style) +} + +fn convert_syn_color_to_ratatui_color( + color: syntect::highlighting::Color, +) -> Color { + Color::Rgb(color.r, color.g, color.b) +} + +fn compute_cache_key(entry: &Entry) -> String { + let mut cache_key = entry.name.clone(); + if let Some(line_number) = entry.line_number { + cache_key.push_str(&line_number.to_string()); + } + cache_key +} diff --git a/crates/television-screen/src/remote_control.rs b/crates/television-screen/src/remote_control.rs new file mode 100644 index 0000000..3488782 --- /dev/null +++ b/crates/television-screen/src/remote_control.rs @@ -0,0 +1,144 @@ +use std::collections::HashMap; + +use crate::logo::build_remote_logo_paragraph; +use crate::mode::REMOTE_CONTROL_COLOR; +use crate::results::build_results_list; +use television_channels::entry::Entry; +use television_utils::input::Input; + +use crate::colors::{ResultsListColors, BORDER_COLOR, DEFAULT_INPUT_FG}; +use color_eyre::eyre::Result; +use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; +use ratatui::prelude::Style; +use ratatui::style::{Color, Stylize}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{ + Block, BorderType, Borders, ListDirection, ListState, Padding, Paragraph, +}; +use ratatui::Frame; + +pub fn draw_remote_control( + f: &mut Frame, + rect: Rect, + entries: &[Entry], + use_nerd_font_icons: bool, + picker_state: &mut ListState, + input_state: &mut Input, + icon_color_cache: &mut HashMap, +) -> Result<()> { + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Min(3), + Constraint::Length(3), + Constraint::Length(20), + ] + .as_ref(), + ) + .split(rect); + draw_rc_channels( + f, + layout[0], + entries, + use_nerd_font_icons, + picker_state, + icon_color_cache, + ); + draw_rc_input(f, layout[1], input_state)?; + draw_rc_logo(f, layout[2]); + Ok(()) +} + +fn draw_rc_channels( + f: &mut Frame, + area: Rect, + entries: &[Entry], + use_nerd_font_icons: bool, + picker_state: &mut ListState, + icon_color_cache: &mut HashMap, +) { + let rc_block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(BORDER_COLOR)) + .style(Style::default()) + .padding(Padding::right(1)); + + let channel_list = build_results_list( + rc_block, + entries, + ListDirection::TopToBottom, + Some( + ResultsListColors::default().result_name_fg(REMOTE_CONTROL_COLOR), + ), + use_nerd_font_icons, + icon_color_cache, + ); + + f.render_stateful_widget(channel_list, area, picker_state); +} + +fn draw_rc_input(f: &mut Frame, area: Rect, input: &mut Input) -> Result<()> { + let input_block = Block::default() + .title_top(Line::from("Remote Control").alignment(Alignment::Center)) + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(BORDER_COLOR)) + .style(Style::default()); + + let input_block_inner = input_block.inner(area); + + f.render_widget(input_block, area); + + // split input block into 2 parts: prompt symbol, input + let inner_input_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + // prompt symbol + Constraint::Length(2), + // input field + Constraint::Fill(1), + ]) + .split(input_block_inner); + + let prompt_symbol_block = Block::default(); + let arrow = Paragraph::new(Span::styled( + "> ", + Style::default().fg(DEFAULT_INPUT_FG).bold(), + )) + .block(prompt_symbol_block); + f.render_widget(arrow, inner_input_chunks[0]); + + let interactive_input_block = Block::default(); + // keep 2 for borders and 1 for cursor + let width = inner_input_chunks[1].width.max(3) - 3; + let scroll = input.visual_scroll(width as usize); + let input_paragraph = Paragraph::new(input.value()) + .scroll((0, u16::try_from(scroll)?)) + .block(interactive_input_block) + .style(Style::default().fg(DEFAULT_INPUT_FG).bold().italic()) + .alignment(Alignment::Left); + f.render_widget(input_paragraph, inner_input_chunks[1]); + + // Make the cursor visible and ask tui-rs to put it at the + // specified coordinates after rendering + f.set_cursor_position(( + // Put cursor past the end of the input text + inner_input_chunks[1].x + + u16::try_from(input.visual_cursor().max(scroll) - scroll)?, + // Move one line down, from the border to the input line + inner_input_chunks[1].y, + )); + Ok(()) +} +fn draw_rc_logo(f: &mut Frame, area: Rect) { + let logo_block = + Block::default().style(Style::default().fg(REMOTE_CONTROL_COLOR)); + + let logo_paragraph = build_remote_logo_paragraph() + .alignment(Alignment::Center) + .block(logo_block); + + f.render_widget(logo_paragraph, area); +} diff --git a/crates/television/ui/results.rs b/crates/television-screen/src/results.rs similarity index 62% rename from crates/television/ui/results.rs rename to crates/television-screen/src/results.rs index 319cf28..5303924 100644 --- a/crates/television/ui/results.rs +++ b/crates/television-screen/src/results.rs @@ -1,71 +1,23 @@ -use crate::television::Television; -use crate::ui::layout::InputPosition; -use crate::ui::BORDER_COLOR; +use crate::colors::{ + ResultsListColors, BORDER_COLOR, + DEFAULT_RESULTS_LIST_MATCH_FOREGROUND_COLOR, +}; +use crate::layout::InputPosition; use color_eyre::eyre::Result; use ratatui::layout::{Alignment, Rect}; use ratatui::prelude::{Color, Line, Span, Style}; use ratatui::widgets::{ - Block, BorderType, Borders, List, ListDirection, Padding, + Block, BorderType, Borders, List, ListDirection, ListState, Padding, }; use ratatui::Frame; use std::collections::HashMap; use std::str::FromStr; -use television_channels::channels::OnAir; use television_channels::entry::Entry; use television_utils::strings::{ make_matched_string_printable, next_char_boundary, slice_at_char_boundaries, }; -// Styles -const DEFAULT_RESULT_NAME_FG: Color = Color::Blue; -const DEFAULT_RESULT_PREVIEW_FG: Color = Color::Rgb(150, 150, 150); -const DEFAULT_RESULT_LINE_NUMBER_FG: Color = Color::Yellow; -const DEFAULT_RESULT_SELECTED_BG: Color = Color::Rgb(50, 50, 50); - -const DEFAULT_RESULTS_LIST_MATCH_FOREGROUND_COLOR: Color = Color::Red; - -pub struct ResultsListColors { - pub result_name_fg: Color, - pub result_preview_fg: Color, - pub result_line_number_fg: Color, - pub result_selected_bg: Color, -} - -impl Default for ResultsListColors { - fn default() -> Self { - Self { - result_name_fg: DEFAULT_RESULT_NAME_FG, - result_preview_fg: DEFAULT_RESULT_PREVIEW_FG, - result_line_number_fg: DEFAULT_RESULT_LINE_NUMBER_FG, - result_selected_bg: DEFAULT_RESULT_SELECTED_BG, - } - } -} - -#[allow(dead_code)] -impl ResultsListColors { - pub fn result_name_fg(mut self, color: Color) -> Self { - self.result_name_fg = color; - self - } - - pub fn result_preview_fg(mut self, color: Color) -> Self { - self.result_preview_fg = color; - self - } - - pub fn result_line_number_fg(mut self, color: Color) -> Self { - self.result_line_number_fg = color; - self - } - - pub fn result_selected_bg(mut self, color: Color) -> Self { - self.result_selected_bg = color; - self - } -} - pub fn build_results_list<'a, 'b>( results_block: Block<'b>, entries: &'a [Entry], @@ -186,48 +138,35 @@ where .block(results_block) } -impl Television { - pub(crate) fn draw_results_list( - &mut self, - f: &mut Frame, - rect: Rect, - ) -> Result<()> { - let results_block = Block::default() - .title_top(Line::from(" Results ").alignment(Alignment::Center)) - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(BORDER_COLOR)) - .style(Style::default()) - .padding(Padding::right(1)); +pub fn draw_results_list( + f: &mut Frame, + rect: Rect, + entries: &[Entry], + relative_picker_state: &mut ListState, + input_bar_position: InputPosition, + use_nerd_font_icons: bool, + icon_color_cache: &mut HashMap, +) -> Result<()> { + let results_block = Block::default() + .title_top(Line::from(" Results ").alignment(Alignment::Center)) + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(BORDER_COLOR)) + .style(Style::default()) + .padding(Padding::right(1)); - let result_count = self.channel.result_count(); - if result_count > 0 && self.results_picker.selected().is_none() { - self.results_picker.select(Some(0)); - self.results_picker.relative_select(Some(0)); - } + let results_list = build_results_list( + results_block, + entries, + match input_bar_position { + InputPosition::Bottom => ListDirection::BottomToTop, + InputPosition::Top => ListDirection::TopToBottom, + }, + None, + use_nerd_font_icons, + icon_color_cache, + ); - let entries = self.channel.results( - rect.height.saturating_sub(2).into(), - u32::try_from(self.results_picker.offset())?, - ); - - let results_list = build_results_list( - results_block, - &entries, - match self.config.ui.input_bar_position { - InputPosition::Bottom => ListDirection::BottomToTop, - InputPosition::Top => ListDirection::TopToBottom, - }, - None, - self.config.ui.use_nerd_font_icons, - &mut self.icon_color_cache, - ); - - f.render_stateful_widget( - results_list, - rect, - &mut self.results_picker.relative_state, - ); - Ok(()) - } + f.render_stateful_widget(results_list, rect, relative_picker_state); + Ok(()) } diff --git a/crates/television/ui/spinner.rs b/crates/television-screen/src/spinner.rs similarity index 79% rename from crates/television/ui/spinner.rs rename to crates/television-screen/src/spinner.rs index 3b0f32a..c863d00 100644 --- a/crates/television/ui/spinner.rs +++ b/crates/television-screen/src/spinner.rs @@ -2,10 +2,6 @@ use ratatui::{ buffer::Buffer, layout::Rect, style::Style, widgets::StatefulWidget, }; -//const FRAMES: &[char] = &[ -// '⠄', '⠆', '⠇', '⠋', '⠙', '⠸', '⠰', '⠠', '⠰', '⠸', '⠙', '⠋', '⠇', '⠆', -//]; - const FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; /// A spinner widget. @@ -69,3 +65,17 @@ impl StatefulWidget for Spinner { state.tick(); } } +impl StatefulWidget for &Spinner { + type State = SpinnerState; + + /// Renders the spinner in the given area. + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + buf.set_string( + area.left(), + area.top(), + self.frame(state.current_frame), + Style::default(), + ); + state.tick(); + } +} diff --git a/crates/television-utils/Cargo.toml b/crates/television-utils/Cargo.toml index daa2344..fcbcc65 100644 --- a/crates/television-utils/Cargo.toml +++ b/crates/television-utils/Cargo.toml @@ -21,6 +21,7 @@ bat = { version = "0.24.0", default-features = false, features = ["regex-onig"] directories = "5.0.1" syntect = "5.2.0" gag = "1.0.0" +unicode-width = "0.2.0" [target.'cfg(windows)'.dependencies] winapi-util = "0.1.9" diff --git a/crates/television/ui/input.rs b/crates/television-utils/src/input.rs similarity index 78% rename from crates/television/ui/input.rs rename to crates/television-utils/src/input.rs index 7b7c7f2..390682d 100644 --- a/crates/television/ui/input.rs +++ b/crates/television-utils/src/input.rs @@ -1,19 +1,3 @@ -use crate::television::Television; -use crate::ui::BORDER_COLOR; -use color_eyre::eyre::Result; -use ratatui::layout::{ - Alignment, Constraint, Direction, Layout as RatatuiLayout, Rect, -}; -use ratatui::prelude::{Span, Style}; -use ratatui::style::Stylize; -use ratatui::text::Line; -use ratatui::widgets::{Block, BorderType, Borders, Paragraph}; -use ratatui::Frame; -use television_channels::channels::OnAir; - -pub mod actions; -pub mod backend; - /// Input requests are used to change the input state. /// /// Different backends can be used to convert events into requests. @@ -45,18 +29,7 @@ pub struct StateChanged { #[allow(clippy::module_name_repetitions)] pub type InputResponse = Option; -/// The input buffer with cursor support. -/// -/// Example: -/// -/// ``` -/// use tui_input::Input; -/// -/// let input: Input = "Hello World".into(); -/// -/// assert_eq!(input.cursor(), 11); -/// assert_eq!(input.to_string(), "Hello World"); -/// ``` +/// An input buffer with cursor support. #[derive(Default, Debug, Clone)] pub struct Input { value: String, @@ -411,114 +384,6 @@ impl std::fmt::Display for Input { } } -impl Television { - pub(crate) fn draw_input_box( - &mut self, - f: &mut Frame, - rect: Rect, - ) -> Result<()> { - let input_block = Block::default() - .title_top(Line::from(" Pattern ").alignment(Alignment::Center)) - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(BORDER_COLOR)) - .style(Style::default()); - - let input_block_inner = input_block.inner(rect); - if input_block_inner.area() == 0 { - return Ok(()); - } - - f.render_widget(input_block, rect); - - // split input block into 4 parts: prompt symbol, input, result count, spinner - let total_count = self.channel.total_count(); - let inner_input_chunks = RatatuiLayout::default() - .direction(Direction::Horizontal) - .constraints([ - // prompt symbol - Constraint::Length(2), - // input field - Constraint::Fill(1), - // result count - Constraint::Length( - 3 * ((total_count as f32).log10().ceil() as u16 + 1) + 3, - ), - // spinner - Constraint::Length(1), - ]) - .split(input_block_inner); - - let arrow_block = Block::default(); - let arrow = Paragraph::new(Span::styled( - "> ", - Style::default() - .fg(crate::television::DEFAULT_INPUT_FG) - .bold(), - )) - .block(arrow_block); - f.render_widget(arrow, inner_input_chunks[0]); - - let interactive_input_block = Block::default(); - // keep 2 for borders and 1 for cursor - let width = inner_input_chunks[1].width.max(3) - 3; - let scroll = self.results_picker.input.visual_scroll(width as usize); - let input = Paragraph::new(self.results_picker.input.value()) - .scroll((0, u16::try_from(scroll)?)) - .block(interactive_input_block) - .style( - Style::default() - .fg(crate::television::DEFAULT_INPUT_FG) - .bold() - .italic(), - ) - .alignment(Alignment::Left); - f.render_widget(input, inner_input_chunks[1]); - - if self.channel.running() { - f.render_stateful_widget( - self.spinner, - inner_input_chunks[3], - &mut self.spinner_state, - ); - } - - let result_count = self.channel.result_count(); - let result_count_block = Block::default(); - let result_count_paragraph = Paragraph::new(Span::styled( - format!( - " {} / {} ", - if result_count == 0 { - 0 - } else { - self.results_picker.selected().unwrap_or(0) + 1 - }, - result_count, - ), - Style::default() - .fg(crate::television::DEFAULT_RESULTS_COUNT_FG) - .italic(), - )) - .block(result_count_block) - .alignment(Alignment::Right); - f.render_widget(result_count_paragraph, inner_input_chunks[2]); - - // Make the cursor visible and ask tui-rs to put it at the - // specified coordinates after rendering - f.set_cursor_position(( - // Put cursor past the end of the input text - inner_input_chunks[1].x - + u16::try_from( - self.results_picker.input.visual_cursor().max(scroll) - - scroll, - )?, - // Move one line down, from the border to the input line - inner_input_chunks[1].y, - )); - Ok(()) - } -} - #[cfg(test)] mod tests { const TEXT: &str = "first second, third."; diff --git a/crates/television-utils/src/lib.rs b/crates/television-utils/src/lib.rs index c11c104..f99a6b5 100644 --- a/crates/television-utils/src/lib.rs +++ b/crates/television-utils/src/lib.rs @@ -2,6 +2,8 @@ pub mod cache; pub mod command; pub mod files; pub mod indices; +pub mod input; +pub mod metadata; pub mod stdin; pub mod strings; pub mod syntax; diff --git a/crates/television-utils/src/metadata.rs b/crates/television-utils/src/metadata.rs new file mode 100644 index 0000000..bca00c3 --- /dev/null +++ b/crates/television-utils/src/metadata.rs @@ -0,0 +1,39 @@ +pub struct BuildMetadata { + pub rustc_version: String, + pub build_date: String, + pub target_triple: String, +} + +impl BuildMetadata { + pub fn new( + rustc_version: String, + build_date: String, + target_triple: String, + ) -> Self { + Self { + rustc_version, + build_date, + target_triple, + } + } +} + +pub struct AppMetadata { + pub version: String, + pub build: BuildMetadata, + pub current_directory: String, +} + +impl AppMetadata { + pub fn new( + version: String, + build: BuildMetadata, + current_directory: String, + ) -> Self { + Self { + version, + build, + current_directory, + } + } +} diff --git a/crates/television/app.rs b/crates/television/app.rs index 7a967c2..1ecf15d 100644 --- a/crates/television/app.rs +++ b/crates/television/app.rs @@ -1,13 +1,13 @@ -use std::collections::HashMap; -use std::ops::Deref; use std::sync::Arc; use color_eyre::Result; +use television_screen::mode::Mode; use tokio::sync::{mpsc, Mutex}; use tracing::{debug, info}; -use crate::config::{parse_key, KeyBindings}; -use crate::television::{Mode, Television}; +use crate::config::parse_key; +use crate::keymap::Keymap; +use crate::television::Television; use crate::{ action::Action, config::Config, @@ -17,46 +17,6 @@ use crate::{ use television_channels::channels::TelevisionChannel; use television_channels::entry::Entry; -#[derive(Default, Debug)] -pub struct Keymap(pub HashMap>); - -impl Deref for Keymap { - type Target = HashMap>; - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl From<&KeyBindings> for Keymap { - fn from(keybindings: &KeyBindings) -> Self { - let mut keymap = HashMap::new(); - for (mode, bindings) in keybindings.iter() { - let mut mode_keymap = HashMap::new(); - for (action, key) in bindings { - mode_keymap.insert(*key, action.clone()); - } - keymap.insert(*mode, mode_keymap); - } - Self(keymap) - } -} - -impl Keymap { - pub fn with_mode_mappings( - mut self, - mode: Mode, - mappings: Vec<(Key, Action)>, - ) -> Result { - let mode_keymap = self.0.get_mut(&mode).ok_or_else(|| { - color_eyre::eyre::eyre!("Mode {:?} not found", mode) - })?; - for (key, action) in mappings { - mode_keymap.insert(key, action); - } - Ok(self) - } -} - /// The main application struct that holds the state of the application. pub struct App { keymap: Keymap, diff --git a/crates/television/config.rs b/crates/television/config.rs index 3d892c2..8b19629 100644 --- a/crates/television/config.rs +++ b/crates/television/config.rs @@ -155,7 +155,7 @@ mod tests { use super::*; use crate::action::Action; use crate::config::keybindings::parse_key; - use crate::television::Mode; + use television_screen::mode::Mode; #[test] fn test_config() -> Result<()> { diff --git a/crates/television/config/keybindings.rs b/crates/television/config/keybindings.rs index b3efcce..a1a8022 100644 --- a/crates/television/config/keybindings.rs +++ b/crates/television/config/keybindings.rs @@ -1,10 +1,10 @@ use crate::action::Action; use crate::event::{convert_raw_event_to_key, Key}; -use crate::television::Mode; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use serde::{Deserialize, Deserializer}; use std::collections::HashMap; use std::ops::{Deref, DerefMut}; +use television_screen::mode::Mode; #[derive(Clone, Debug, Default)] pub struct KeyBindings(pub config::Map>); diff --git a/crates/television/config/styles.rs b/crates/television/config/styles.rs index 475b3e6..5cbe001 100644 --- a/crates/television/config/styles.rs +++ b/crates/television/config/styles.rs @@ -1,10 +1,10 @@ -use crate::television::Mode; use ratatui::prelude::{Color, Modifier, Style}; use serde::{Deserialize, Deserializer}; use std::{ collections::HashMap, ops::{Deref, DerefMut}, }; +use television_screen::mode::Mode; #[derive(Clone, Debug, Default)] pub struct Styles(pub HashMap>); diff --git a/crates/television/config/ui.rs b/crates/television/config/ui.rs index 0c56f8a..20d6149 100644 --- a/crates/television/config/ui.rs +++ b/crates/television/config/ui.rs @@ -2,7 +2,7 @@ use config::ValueKind; use serde::Deserialize; use std::collections::HashMap; -use crate::ui::layout::InputPosition; +use television_screen::layout::InputPosition; const DEFAULT_UI_SCALE: u16 = 90; diff --git a/crates/television/input.rs b/crates/television/input.rs new file mode 100644 index 0000000..057055e --- /dev/null +++ b/crates/television/input.rs @@ -0,0 +1,17 @@ +use crate::action::Action; +use television_utils::input::InputRequest; + +pub fn convert_action_to_input_request( + action: &Action, +) -> Option { + match action { + Action::AddInputChar(c) => Some(InputRequest::InsertChar(*c)), + Action::DeletePrevChar => Some(InputRequest::DeletePrevChar), + Action::DeleteNextChar => Some(InputRequest::DeleteNextChar), + Action::GoToPrevChar => Some(InputRequest::GoToPrevChar), + Action::GoToNextChar => Some(InputRequest::GoToNextChar), + Action::GoToInputStart => Some(InputRequest::GoToStart), + Action::GoToInputEnd => Some(InputRequest::GoToEnd), + _ => None, + } +} diff --git a/crates/television/keymap.rs b/crates/television/keymap.rs new file mode 100644 index 0000000..5833f7f --- /dev/null +++ b/crates/television/keymap.rs @@ -0,0 +1,49 @@ +use std::collections::HashMap; +use std::ops::Deref; + +use color_eyre::Result; +use television_screen::mode::Mode; + +use crate::action::Action; +use crate::config::KeyBindings; +use crate::event::Key; + +#[derive(Default, Debug)] +pub struct Keymap(pub HashMap>); + +impl Deref for Keymap { + type Target = HashMap>; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From<&KeyBindings> for Keymap { + fn from(keybindings: &KeyBindings) -> Self { + let mut keymap = HashMap::new(); + for (mode, bindings) in keybindings.iter() { + let mut mode_keymap = HashMap::new(); + for (action, key) in bindings { + mode_keymap.insert(*key, action.clone()); + } + keymap.insert(*mode, mode_keymap); + } + Self(keymap) + } +} + +impl Keymap { + pub fn with_mode_mappings( + mut self, + mode: Mode, + mappings: Vec<(Key, Action)>, + ) -> Result { + let mode_keymap = self.0.get_mut(&mode).ok_or_else(|| { + color_eyre::eyre::eyre!("Mode {:?} not found", mode) + })?; + for (key, action) in mappings { + mode_keymap.insert(key, action); + } + Ok(self) + } +} diff --git a/crates/television/main.rs b/crates/television/main.rs index ad36691..df8f56c 100644 --- a/crates/television/main.rs +++ b/crates/television/main.rs @@ -20,12 +20,13 @@ pub mod cli; pub mod config; pub mod errors; pub mod event; +pub mod input; +pub mod keymap; pub mod logging; pub mod picker; pub mod render; pub mod television; pub mod tui; -pub mod ui; #[tokio::main(flavor = "multi_thread")] async fn main() -> Result<()> { diff --git a/crates/television/picker.rs b/crates/television/picker.rs index 64c2555..7975396 100644 --- a/crates/television/picker.rs +++ b/crates/television/picker.rs @@ -1,6 +1,5 @@ -use crate::ui::input::Input; use ratatui::widgets::ListState; -use television_utils::strings::EMPTY_STRING; +use television_utils::{input::Input, strings::EMPTY_STRING}; #[derive(Debug)] pub struct Picker { diff --git a/crates/television/television.rs b/crates/television/television.rs index 880c775..0b5c028 100644 --- a/crates/television/television.rs +++ b/crates/television/television.rs @@ -1,16 +1,11 @@ +use crate::config::KeyBindings; +use crate::input::convert_action_to_input_request; use crate::picker::Picker; -use crate::ui::{ - cache::RenderedPreviewCache, - input::actions::InputActionHandler, - layout::{Dimensions, InputPosition, Layout}, - spinner::Spinner, -}; -use crate::{action::Action, config::Config, ui::spinner::SpinnerState}; -use crate::{app::Keymap, cable::load_cable_channels}; +use crate::{action::Action, config::Config}; +use crate::{cable::load_cable_channels, keymap::Keymap}; use color_eyre::Result; use copypasta::{ClipboardContext, ClipboardProvider}; use ratatui::{layout::Rect, style::Color, Frame}; -use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::{Arc, Mutex}; use television_channels::channels::{ @@ -19,16 +14,24 @@ use television_channels::channels::{ }; use television_channels::entry::{Entry, ENTRY_PLACEHOLDER}; use television_previewers::previewers::Previewer; +use television_screen::cache::RenderedPreviewCache; +use television_screen::help::draw_help_bar; +use television_screen::input::draw_input_box; +use television_screen::keybindings::{ + build_keybindings_table, DisplayableAction, DisplayableKeybindings, +}; +use television_screen::layout::{Dimensions, InputPosition, Layout}; +use television_screen::mode::Mode; +use television_screen::preview::{ + draw_preview_content_block, draw_preview_title_block, +}; +use television_screen::remote_control::draw_remote_control; +use television_screen::results::draw_results_list; +use television_screen::spinner::{Spinner, SpinnerState}; +use television_utils::metadata::{AppMetadata, BuildMetadata}; use television_utils::strings::EMPTY_STRING; use tokio::sync::mpsc::UnboundedSender; -#[derive(PartialEq, Copy, Clone, Hash, Eq, Debug, Serialize, Deserialize)] -pub enum Mode { - Channel, - RemoteControl, - SendToChannel, -} - pub struct Television { action_tx: Option>, pub config: Config, @@ -48,6 +51,7 @@ pub struct Television { pub rendered_preview_cache: Arc>>, pub(crate) spinner: Spinner, pub(crate) spinner_state: SpinnerState, + pub app_metadata: AppMetadata, } impl Television { @@ -61,6 +65,18 @@ impl Television { let keymap = Keymap::from(&config.keybindings); let builtin_channels = load_builtin_channels(); let cable_channels = load_cable_channels().unwrap_or_default(); + let app_metadata = AppMetadata::new( + env!("CARGO_PKG_VERSION").to_string(), + BuildMetadata::new( + env!("VERGEN_RUSTC_SEMVER").to_string(), + env!("VERGEN_BUILD_DATE").to_string(), + env!("VERGEN_CARGO_TARGET_TRIPLE").to_string(), + ), + std::env::current_dir() + .expect("Could not get current directory") + .to_string_lossy() + .to_string(), + ); channel.find(EMPTY_STRING); let spinner = Spinner::default(); @@ -87,9 +103,18 @@ impl Television { )), spinner, spinner_state: SpinnerState::from(&spinner), + app_metadata, } } + pub fn init_remote_control(&mut self) { + let builtin_channels = load_builtin_channels(); + let cable_channels = load_cable_channels().unwrap_or_default(); + self.remote_control = TelevisionChannel::RemoteControl( + RemoteControl::new(builtin_channels, Some(cable_channels)), + ); + } + pub fn current_channel(&self) -> UnitChannel { UnitChannel::from(&self.channel) } @@ -174,7 +199,7 @@ impl Television { match self.mode { Mode::Channel => self.results_picker.reset_selection(), Mode::RemoteControl | Mode::SendToChannel => { - self.rc_picker.reset_selection() + self.rc_picker.reset_selection(); } } } @@ -183,7 +208,7 @@ impl Television { match self.mode { Mode::Channel => self.results_picker.reset_input(), Mode::RemoteControl | Mode::SendToChannel => { - self.rc_picker.reset_input() + self.rc_picker.reset_input(); } } } @@ -209,11 +234,6 @@ impl Television { } } -// Styles -// input -pub(crate) const DEFAULT_INPUT_FG: Color = Color::LightRed; -pub(crate) const DEFAULT_RESULTS_COUNT_FG: Color = Color::LightRed; - impl Television { /// Register an action handler that can send actions for processing if necessary. /// @@ -253,7 +273,8 @@ impl Television { &mut self.rc_picker.input } }; - input.handle_action(&action); + input + .handle(convert_action_to_input_request(&action).unwrap()); match action { Action::AddInputChar(_) | Action::DeletePrevChar @@ -292,10 +313,12 @@ impl Television { Action::ToggleRemoteControl => match self.mode { Mode::Channel => { self.mode = Mode::RemoteControl; + self.init_remote_control(); } Mode::RemoteControl => { // this resets the RC picker self.reset_picker_input(); + self.init_remote_control(); self.remote_control.find(EMPTY_STRING); self.reset_picker_selection(); self.mode = Mode::Channel; @@ -382,16 +405,54 @@ impl Television { ); // help bar (metadata, keymaps, logo) - self.draw_help_bar(f, &layout.help_bar)?; + draw_help_bar( + f, + &layout.help_bar, + self.current_channel(), + build_keybindings_table( + &self.config.keybindings.to_displayable(), + self.mode, + ), + self.mode, + &self.app_metadata, + ); - self.results_area_height = u32::from(layout.results.height - 2); // 2 for the borders + self.results_area_height = + u32::from(layout.results.height.saturating_sub(2)); // 2 for the borders self.preview_pane_height = layout.preview_window.height; // results list - self.draw_results_list(f, layout.results)?; + let result_count = self.channel.result_count(); + if result_count > 0 && self.results_picker.selected().is_none() { + self.results_picker.select(Some(0)); + self.results_picker.relative_select(Some(0)); + } + let entries = self.channel.results( + self.results_area_height, + u32::try_from(self.results_picker.offset())?, + ); + draw_results_list( + f, + layout.results, + &entries, + &mut self.results_picker.relative_state, + self.config.ui.input_bar_position, + self.config.ui.use_nerd_font_icons, + &mut self.icon_color_cache, + )?; // input box - self.draw_input_box(f, layout.input)?; + draw_input_box( + f, + layout.input, + result_count, + self.channel.total_count(), + &mut self.results_picker.input, + &mut self.results_picker.state, + self.channel.running(), + &self.spinner, + &mut self.spinner_state, + )?; let selected_entry = self .get_selected_entry(Some(Mode::Channel)) @@ -400,20 +461,199 @@ impl Television { // preview title self.current_preview_total_lines = preview.total_lines(); - self.draw_preview_title_block(f, layout.preview_title, &preview)?; + draw_preview_title_block( + f, + layout.preview_title, + &preview, + self.config.ui.use_nerd_font_icons, + )?; // preview content - self.draw_preview_content_block( + // initialize preview scroll + self.maybe_init_preview_scroll( + selected_entry + .line_number + .map(|l| u16::try_from(l).unwrap_or(0)), + layout.preview_window.height, + ); + draw_preview_content_block( f, layout.preview_window, &selected_entry, &preview, + &self.rendered_preview_cache, + self.preview_scroll.unwrap_or(0), ); // remote control if matches!(self.mode, Mode::RemoteControl | Mode::SendToChannel) { - self.draw_remote_control(f, layout.remote_control.unwrap())?; + // NOTE: this should be done in the `update` method + let result_count = self.remote_control.result_count(); + if result_count > 0 && self.rc_picker.selected().is_none() { + self.rc_picker.select(Some(0)); + self.rc_picker.relative_select(Some(0)); + } + let entries = self.remote_control.results( + area.height.saturating_sub(2).into(), + u32::try_from(self.rc_picker.offset())?, + ); + draw_remote_control( + f, + layout.remote_control.unwrap(), + &entries, + self.config.ui.use_nerd_font_icons, + &mut self.rc_picker.state, + &mut self.rc_picker.input, + &mut self.icon_color_cache, + )?; } Ok(()) } + + pub fn maybe_init_preview_scroll( + &mut self, + target_line: Option, + height: u16, + ) { + if self.preview_scroll.is_none() && !self.channel.running() { + self.preview_scroll = + Some(target_line.unwrap_or(0).saturating_sub(height / 3)); + } + } +} + +impl KeyBindings { + pub fn to_displayable(&self) -> HashMap { + // channel mode keybindings + let channel_bindings: HashMap> = + HashMap::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::SelectEntry]), + ), + ( + DisplayableAction::CopyEntryToClipboard, + serialized_keys_for_actions( + self, + &[Action::CopyEntryToClipboard], + ), + ), + ( + DisplayableAction::SendToChannel, + serialized_keys_for_actions( + self, + &[Action::ToggleSendToChannel], + ), + ), + ( + DisplayableAction::ToggleRemoteControl, + serialized_keys_for_actions( + self, + &[Action::ToggleRemoteControl], + ), + ), + ( + DisplayableAction::Quit, + serialized_keys_for_actions(self, &[Action::Quit]), + ), + ]); + + // remote control mode keybindings + let remote_control_bindings: HashMap> = + HashMap::from_iter(vec![ + ( + DisplayableAction::ResultsNavigation, + serialized_keys_for_actions( + self, + &[Action::SelectPrevEntry, Action::SelectNextEntry], + ), + ), + ( + DisplayableAction::SelectEntry, + serialized_keys_for_actions(self, &[Action::SelectEntry]), + ), + ( + DisplayableAction::ToggleRemoteControl, + serialized_keys_for_actions( + self, + &[Action::ToggleRemoteControl], + ), + ), + ]); + + // send to channel mode keybindings + let send_to_channel_bindings: HashMap> = + HashMap::from_iter(vec![ + ( + DisplayableAction::ResultsNavigation, + serialized_keys_for_actions( + self, + &[Action::SelectPrevEntry, Action::SelectNextEntry], + ), + ), + ( + DisplayableAction::SelectEntry, + serialized_keys_for_actions(self, &[Action::SelectEntry]), + ), + ( + DisplayableAction::Cancel, + serialized_keys_for_actions( + self, + &[Action::ToggleSendToChannel], + ), + ), + ]); + + HashMap::from_iter(vec![ + (Mode::Channel, DisplayableKeybindings::new(channel_bindings)), + ( + Mode::RemoteControl, + DisplayableKeybindings::new(remote_control_bindings), + ), + ( + Mode::SendToChannel, + DisplayableKeybindings::new(send_to_channel_bindings), + ), + ]) + } +} + +fn serialized_keys_for_actions( + keybindings: &KeyBindings, + actions: &[Action], +) -> Vec { + actions + .iter() + .map(|a| { + keybindings + .get(&Mode::Channel) + .unwrap() + .get(a) + .unwrap() + .clone() + .to_string() + }) + .collect() } diff --git a/crates/television/ui.rs b/crates/television/ui.rs deleted file mode 100644 index b4a3b87..0000000 --- a/crates/television/ui.rs +++ /dev/null @@ -1,16 +0,0 @@ -use ratatui::style::Color; - -pub mod cache; -pub(crate) mod help; -pub mod input; -pub mod keymap; -pub mod layout; -pub mod logo; -pub mod metadata; -mod mode; -pub mod preview; -mod remote_control; -pub mod results; -pub mod spinner; - -pub const BORDER_COLOR: Color = Color::Blue; diff --git a/crates/television/ui/help.rs b/crates/television/ui/help.rs deleted file mode 100644 index f803821..0000000 --- a/crates/television/ui/help.rs +++ /dev/null @@ -1,68 +0,0 @@ -use super::layout::HelpBarLayout; -use crate::television::Television; -use crate::ui::logo::build_logo_paragraph; -use crate::ui::mode::mode_color; -use crate::ui::BORDER_COLOR; -use ratatui::layout::Rect; -use ratatui::prelude::{Color, Style}; -use ratatui::widgets::{Block, BorderType, Borders, Padding}; -use ratatui::Frame; - -pub fn draw_logo_block(f: &mut Frame, area: Rect, color: Color) { - let logo_block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(BORDER_COLOR)) - .style(Style::default().fg(color)) - .padding(Padding::horizontal(1)); - - let logo_paragraph = build_logo_paragraph().block(logo_block); - - f.render_widget(logo_paragraph, area); -} - -impl Television { - pub(crate) fn draw_help_bar( - &self, - f: &mut Frame, - layout: &Option, - ) -> color_eyre::Result<()> { - if let Some(help_bar) = layout { - self.draw_metadata_block(f, help_bar.left); - self.draw_keymaps_block(f, help_bar.middle)?; - draw_logo_block(f, help_bar.right, mode_color(self.mode)); - } - Ok(()) - } - - fn draw_metadata_block(&self, f: &mut Frame, area: Rect) { - let metadata_block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(BORDER_COLOR)) - .padding(Padding::horizontal(1)) - .style(Style::default()); - - let metadata_table = self.build_metadata_table().block(metadata_block); - - f.render_widget(metadata_table, area); - } - - fn draw_keymaps_block( - &self, - f: &mut Frame, - area: Rect, - ) -> color_eyre::Result<()> { - let keymaps_block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(BORDER_COLOR)) - .style(Style::default()) - .padding(Padding::horizontal(1)); - - let keymaps_table = self.build_keymap_table()?.block(keymaps_block); - - f.render_widget(keymaps_table, area); - Ok(()) - } -} diff --git a/crates/television/ui/input/actions.rs b/crates/television/ui/input/actions.rs deleted file mode 100644 index 6045818..0000000 --- a/crates/television/ui/input/actions.rs +++ /dev/null @@ -1,30 +0,0 @@ -use crate::action::Action; -use crate::ui::input::{Input, InputRequest, StateChanged}; - -/// This makes the `Action` type compatible with the `Input` logic. -pub trait InputActionHandler { - // Handle Key event. - fn handle_action(&mut self, action: &Action) -> Option; -} - -impl InputActionHandler for Input { - /// Handle Key event. - fn handle_action(&mut self, action: &Action) -> Option { - match action { - Action::AddInputChar(c) => { - self.handle(InputRequest::InsertChar(*c)) - } - Action::DeletePrevChar => { - self.handle(InputRequest::DeletePrevChar) - } - Action::DeleteNextChar => { - self.handle(InputRequest::DeleteNextChar) - } - Action::GoToPrevChar => self.handle(InputRequest::GoToPrevChar), - Action::GoToNextChar => self.handle(InputRequest::GoToNextChar), - Action::GoToInputStart => self.handle(InputRequest::GoToStart), - Action::GoToInputEnd => self.handle(InputRequest::GoToEnd), - _ => None, - } - } -} diff --git a/crates/television/ui/input/backend.rs b/crates/television/ui/input/backend.rs deleted file mode 100644 index d17e169..0000000 --- a/crates/television/ui/input/backend.rs +++ /dev/null @@ -1,72 +0,0 @@ -use super::{Input, InputRequest, StateChanged}; -use ratatui::crossterm::event::{ - Event as CrosstermEvent, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, -}; - -/// Converts crossterm event into input requests. -/// TODO: make these keybindings configurable. -pub fn to_input_request(evt: &CrosstermEvent) -> Option { - use InputRequest::*; - use KeyCode::*; - match evt { - CrosstermEvent::Key(KeyEvent { - code, - modifiers, - kind, - state: _, - }) if *kind == KeyEventKind::Press => match (*code, *modifiers) { - (Backspace, KeyModifiers::NONE) => Some(DeletePrevChar), - (Delete, KeyModifiers::NONE) => Some(DeleteNextChar), - (Tab, KeyModifiers::NONE) => None, - (Left, KeyModifiers::NONE) => Some(GoToPrevChar), - (Right, KeyModifiers::NONE) => Some(GoToNextChar), - (Char('w'), KeyModifiers::CONTROL) - | (Backspace, KeyModifiers::ALT) => Some(DeletePrevWord), - (Delete, KeyModifiers::CONTROL) => Some(DeleteNextWord), - (Char('a'), KeyModifiers::CONTROL) - | (Home, KeyModifiers::NONE) => Some(GoToStart), - (Char('e'), KeyModifiers::CONTROL) | (End, KeyModifiers::NONE) => { - Some(GoToEnd) - } - (Char(c), KeyModifiers::NONE) => Some(InsertChar(c)), - (Char(c), KeyModifiers::SHIFT) => Some(InsertChar(c)), - (_, _) => None, - }, - _ => None, - } -} - -#[allow(unused)] -/// Import this trait to implement `Input::handle_event()` for crossterm. -pub trait EventHandler { - /// Handle crossterm event. - fn handle_event(&mut self, evt: &CrosstermEvent) -> Option; -} - -impl EventHandler for Input { - /// Handle crossterm event. - fn handle_event(&mut self, evt: &CrosstermEvent) -> Option { - to_input_request(evt).and_then(|req| self.handle(req)) - } -} - -#[cfg(test)] -mod tests { - use ratatui::crossterm::event::{KeyEventKind, KeyEventState}; - - use super::*; - - #[test] - fn handle_tab() { - let evt = CrosstermEvent::Key(KeyEvent { - code: KeyCode::Tab, - modifiers: KeyModifiers::NONE, - kind: KeyEventKind::Press, - state: KeyEventState::NONE, - }); - - let req = to_input_request(&evt); - - assert!(req.is_none()); - } -} diff --git a/crates/television/ui/keymap.rs b/crates/television/ui/keymap.rs deleted file mode 100644 index 795af30..0000000 --- a/crates/television/ui/keymap.rs +++ /dev/null @@ -1,297 +0,0 @@ -use color_eyre::eyre::{OptionExt, Result}; -use ratatui::{ - layout::Constraint, - style::{Color, Style}, - text::{Line, Span}, - widgets::{Cell, Row, Table}, -}; -use std::collections::HashMap; - -use crate::ui::mode::mode_color; -use crate::{ - action::Action, - event::Key, - television::{Mode, Television}, -}; - -const ACTION_COLOR: Color = Color::DarkGray; - -impl Television { - pub fn build_keymap_table<'a>(&self) -> Result> { - match self.mode { - Mode::Channel => self.build_keymap_table_for_channel(), - Mode::RemoteControl => { - self.build_keymap_table_for_channel_selection() - } - Mode::SendToChannel => { - self.build_keymap_table_for_channel_transitions() - } - } - } - - fn build_keymap_table_for_channel<'a>(&self) -> Result> { - let keymap = self.keymap_for_mode()?; - let key_color = mode_color(self.mode); - - // Results navigation - let prev = keys_for_action(keymap, &Action::SelectPrevEntry); - let next = keys_for_action(keymap, &Action::SelectNextEntry); - let results_row = Row::new(build_cells_for_key_groups( - "Results navigation", - vec![prev, next], - key_color, - )); - - // Preview navigation - let up_keys = - keys_for_action(keymap, &Action::ScrollPreviewHalfPageUp); - let down_keys = - keys_for_action(keymap, &Action::ScrollPreviewHalfPageDown); - let preview_row = Row::new(build_cells_for_key_groups( - "Preview navigation", - vec![up_keys, down_keys], - key_color, - )); - - // Select entry - let select_entry_keys = keys_for_action(keymap, &Action::SelectEntry); - let select_entry_row = Row::new(build_cells_for_key_groups( - "Select entry", - vec![select_entry_keys], - key_color, - )); - - // Copy entry to clipboard - let copy_entry_keys = - keys_for_action(keymap, &Action::CopyEntryToClipboard); - let copy_entry_row = Row::new(build_cells_for_key_groups( - "Copy entry to clipboard", - vec![copy_entry_keys], - key_color, - )); - - // Send to channel - let send_to_channel_keys = - keys_for_action(keymap, &Action::ToggleSendToChannel); - let send_to_channel_row = Row::new(build_cells_for_key_groups( - "Send results to", - vec![send_to_channel_keys], - key_color, - )); - - // Switch channels - let switch_channels_keys = - keys_for_action(keymap, &Action::ToggleRemoteControl); - let switch_channels_row = Row::new(build_cells_for_key_groups( - "Toggle Remote control", - vec![switch_channels_keys], - key_color, - )); - - // MISC line (quit, help, etc.) - // Quit ⏼ - let quit_keys = keys_for_action(keymap, &Action::Quit); - let quit_row = Row::new(build_cells_for_key_groups( - "Quit", - vec![quit_keys], - key_color, - )); - - let widths = vec![Constraint::Fill(1), Constraint::Fill(2)]; - - Ok(Table::new( - vec![ - results_row, - preview_row, - select_entry_row, - copy_entry_row, - send_to_channel_row, - switch_channels_row, - quit_row, - ], - widths, - )) - } - - fn build_keymap_table_for_channel_selection<'a>( - &self, - ) -> Result> { - let keymap = self.keymap_for_mode()?; - let key_color = mode_color(self.mode); - - // Results navigation - let prev = keys_for_action(keymap, &Action::SelectPrevEntry); - let next = keys_for_action(keymap, &Action::SelectNextEntry); - let results_row = Row::new(build_cells_for_key_groups( - "Browse channels", - vec![prev, next], - key_color, - )); - - // Select entry - let select_entry_keys = keys_for_action(keymap, &Action::SelectEntry); - let select_entry_row = Row::new(build_cells_for_key_groups( - "Select channel", - vec![select_entry_keys], - key_color, - )); - - // Remote control - let switch_channels_keys = - keys_for_action(keymap, &Action::ToggleRemoteControl); - let switch_channels_row = Row::new(build_cells_for_key_groups( - "Toggle Remote control", - vec![switch_channels_keys], - key_color, - )); - - // Quit - let quit_keys = keys_for_action(keymap, &Action::Quit); - let quit_row = Row::new(build_cells_for_key_groups( - "Quit", - vec![quit_keys], - key_color, - )); - - Ok(Table::new( - vec![results_row, select_entry_row, switch_channels_row, quit_row], - vec![Constraint::Fill(1), Constraint::Fill(2)], - )) - } - - fn build_keymap_table_for_channel_transitions<'a>( - &self, - ) -> Result> { - let keymap = self.keymap_for_mode()?; - let key_color = mode_color(self.mode); - - // Results navigation - let prev = keys_for_action(keymap, &Action::SelectPrevEntry); - let next = keys_for_action(keymap, &Action::SelectNextEntry); - let results_row = Row::new(build_cells_for_key_groups( - "Browse channels", - vec![prev, next], - key_color, - )); - - // Select entry - let select_entry_keys = keys_for_action(keymap, &Action::SelectEntry); - let select_entry_row = Row::new(build_cells_for_key_groups( - "Send to channel", - vec![select_entry_keys], - key_color, - )); - - // Cancel - let cancel_keys = - keys_for_action(keymap, &Action::ToggleSendToChannel); - let cancel_row = Row::new(build_cells_for_key_groups( - "Cancel", - vec![cancel_keys], - key_color, - )); - - // Quit - let quit_keys = keys_for_action(keymap, &Action::Quit); - let quit_row = Row::new(build_cells_for_key_groups( - "Quit", - vec![quit_keys], - key_color, - )); - - Ok(Table::new( - vec![results_row, select_entry_row, cancel_row, quit_row], - vec![Constraint::Fill(1), Constraint::Fill(2)], - )) - } - - /// Get the keymap for the current mode. - /// - /// # Returns - /// A reference to the keymap for the current mode. - fn keymap_for_mode(&self) -> Result<&HashMap> { - let keymap = self - .keymap - .get(&self.mode) - .ok_or_eyre("No keybindings found for the current Mode")?; - Ok(keymap) - } -} - -/// Build the corresponding spans for a group of keys. -/// -/// # Example -/// ```rust -/// use ratatui::text::Span; -/// use television::ui::help::build_spans_for_key_groups; -/// -/// let key_groups = vec![ -/// // alternate keys for the `SelectNextEntry` action -/// vec!["j".to_string(), "n".to_string()], -/// // alternate keys for the `SelectPrevEntry` action -/// vec!["k".to_string(), "p".to_string()], -/// ]; -/// let spans = build_spans_for_key_groups("↕ Results", key_groups); -/// -/// assert_eq!(spans.len(), 5); -/// ``` -fn build_cells_for_key_groups( - group_name: &str, - key_groups: Vec>, - key_color: Color, -) -> Vec { - if key_groups.is_empty() || key_groups.iter().all(Vec::is_empty) { - return vec![group_name.into(), "No keybindings".into()]; - } - let non_empty_groups = key_groups.iter().filter(|keys| !keys.is_empty()); - let mut cells = vec![Cell::from(Span::styled( - group_name.to_owned() + ": ", - Style::default().fg(ACTION_COLOR), - ))]; - - let mut spans = Vec::new(); - - let key_group_spans: Vec = non_empty_groups - .map(|keys| { - let key_group = keys.join(", "); - Span::styled(key_group, Style::default().fg(key_color)) - }) - .collect(); - key_group_spans.iter().enumerate().for_each(|(i, span)| { - spans.push(span.clone()); - if i < key_group_spans.len() - 1 { - spans.push(Span::styled(" / ", Style::default().fg(key_color))); - } - }); - - cells.push(Cell::from(Line::from(spans))); - - cells -} - -/// Get the keys for a given action. -/// -/// # Example -/// ```rust -/// use std::collections::HashMap; -/// use television::action::Action; -/// use television::ui::help::keys_for_action; -/// -/// let mut keymap = HashMap::new(); -/// keymap.insert('j', Action::SelectNextEntry); -/// keymap.insert('k', Action::SelectPrevEntry); -/// -/// let keys = keys_for_action(&keymap, Action::SelectNextEntry); -/// -/// assert_eq!(keys, vec!["j"]); -/// ``` -fn keys_for_action( - keymap: &HashMap, - action: &Action, -) -> Vec { - keymap - .iter() - .filter(|(_key, act)| *act == action) - .map(|(key, _act)| format!("{key}")) - .collect() -} diff --git a/crates/television/ui/metadata.rs b/crates/television/ui/metadata.rs deleted file mode 100644 index b88c602..0000000 --- a/crates/television/ui/metadata.rs +++ /dev/null @@ -1,123 +0,0 @@ -use std::fmt::Display; - -use ratatui::{ - layout::Constraint, - style::{Color, Style}, - text::Span, - widgets::{Cell, Row, Table}, -}; - -use crate::television::{Mode, Television}; -use crate::ui::mode::mode_color; - -const METADATA_FIELD_NAME_COLOR: Color = Color::DarkGray; -const METADATA_FIELD_VALUE_COLOR: Color = Color::Gray; - -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"), - Mode::SendToChannel => write!(f, "Send to Channel"), - } - } -} - -impl Television { - pub fn build_metadata_table<'a>(&self) -> Table<'a> { - let version_row = Row::new(vec![ - Cell::from(Span::styled( - "version: ", - Style::default().fg(METADATA_FIELD_NAME_COLOR), - )), - Cell::from(Span::styled( - env!("CARGO_PKG_VERSION"), - Style::default().fg(METADATA_FIELD_VALUE_COLOR), - )), - ]); - - let target_triple_row = Row::new(vec![ - Cell::from(Span::styled( - "target triple: ", - Style::default().fg(METADATA_FIELD_NAME_COLOR), - )), - Cell::from(Span::styled( - env!("VERGEN_CARGO_TARGET_TRIPLE"), - Style::default().fg(METADATA_FIELD_VALUE_COLOR), - )), - ]); - - let build_row = Row::new(vec![ - Cell::from(Span::styled( - "build: ", - Style::default().fg(METADATA_FIELD_NAME_COLOR), - )), - Cell::from(Span::styled( - env!("VERGEN_RUSTC_SEMVER"), - Style::default().fg(METADATA_FIELD_VALUE_COLOR), - )), - Cell::from(Span::styled( - " (", - Style::default().fg(METADATA_FIELD_NAME_COLOR), - )), - Cell::from(Span::styled( - env!("VERGEN_BUILD_DATE"), - Style::default().fg(METADATA_FIELD_VALUE_COLOR), - )), - Cell::from(Span::styled( - ")", - Style::default().fg(METADATA_FIELD_NAME_COLOR), - )), - ]); - - let current_dir_row = Row::new(vec![ - Cell::from(Span::styled( - "current directory: ", - Style::default().fg(METADATA_FIELD_NAME_COLOR), - )), - Cell::from(Span::styled( - std::env::current_dir() - .expect("Could not get current directory") - .display() - .to_string(), - Style::default().fg(METADATA_FIELD_VALUE_COLOR), - )), - ]); - - let current_channel_row = Row::new(vec![ - Cell::from(Span::styled( - "current channel: ", - Style::default().fg(METADATA_FIELD_NAME_COLOR), - )), - Cell::from(Span::styled( - self.current_channel().to_string(), - Style::default().fg(METADATA_FIELD_VALUE_COLOR), - )), - ]); - - let current_mode_row = Row::new(vec![ - Cell::from(Span::styled( - "current mode: ", - Style::default().fg(METADATA_FIELD_NAME_COLOR), - )), - Cell::from(Span::styled( - self.mode.to_string(), - Style::default().fg(mode_color(self.mode)), - )), - ]); - - let widths = vec![Constraint::Fill(1), Constraint::Fill(2)]; - - Table::new( - vec![ - version_row, - target_triple_row, - build_row, - current_dir_row, - current_channel_row, - current_mode_row, - ], - widths, - ) - } -} diff --git a/crates/television/ui/mode.rs b/crates/television/ui/mode.rs deleted file mode 100644 index a898b5f..0000000 --- a/crates/television/ui/mode.rs +++ /dev/null @@ -1,14 +0,0 @@ -use crate::television::Mode; -use ratatui::style::Color; - -const CHANNEL_COLOR: Color = Color::Indexed(222); -const REMOTE_CONTROL_COLOR: Color = Color::Indexed(1); -const SEND_TO_CHANNEL_COLOR: Color = Color::Indexed(105); - -pub fn mode_color(mode: Mode) -> Color { - match mode { - Mode::Channel => CHANNEL_COLOR, - Mode::RemoteControl => REMOTE_CONTROL_COLOR, - Mode::SendToChannel => SEND_TO_CHANNEL_COLOR, - } -} diff --git a/crates/television/ui/preview.rs b/crates/television/ui/preview.rs deleted file mode 100644 index 4673812..0000000 --- a/crates/television/ui/preview.rs +++ /dev/null @@ -1,457 +0,0 @@ -use crate::television::Television; -use crate::ui::BORDER_COLOR; -use ansi_to_tui::IntoText; -use color_eyre::eyre::Result; -use ratatui::layout::{Alignment, Rect}; -use ratatui::prelude::{Color, Line, Modifier, Span, Style, Stylize, Text}; -use ratatui::widgets::{Block, BorderType, Borders, Padding, Paragraph, Wrap}; -use ratatui::Frame; -use std::str::FromStr; -use std::sync::Arc; -use syntect::highlighting::Color as SyntectColor; -use television_channels::channels::OnAir; -use television_channels::entry::Entry; -use television_previewers::previewers::{ - Preview, PreviewContent, FILE_TOO_LARGE_MSG, PREVIEW_NOT_SUPPORTED_MSG, -}; -use television_utils::strings::{ - replace_non_printable, shrink_with_ellipsis, ReplaceNonPrintableConfig, - EMPTY_STRING, -}; - -// preview -pub const DEFAULT_PREVIEW_TITLE_FG: Color = Color::Blue; -const DEFAULT_SELECTED_PREVIEW_BG: Color = Color::Rgb(50, 50, 50); -const DEFAULT_PREVIEW_CONTENT_FG: Color = Color::Rgb(150, 150, 180); -const DEFAULT_PREVIEW_GUTTER_FG: Color = Color::Rgb(70, 70, 70); -const DEFAULT_PREVIEW_GUTTER_SELECTED_FG: Color = Color::Rgb(255, 150, 150); - -impl Television { - pub(crate) fn draw_preview_title_block( - &self, - f: &mut Frame, - rect: Rect, - preview: &Arc, - ) -> Result<()> { - let mut preview_title_spans = Vec::new(); - if preview.icon.is_some() && self.config.ui.use_nerd_font_icons { - let icon = preview.icon.as_ref().unwrap(); - preview_title_spans.push(Span::styled( - { - let mut icon_str = String::from(icon.icon); - icon_str.push(' '); - icon_str - }, - Style::default().fg(Color::from_str(icon.color)?), - )); - } - preview_title_spans.push(Span::styled( - shrink_with_ellipsis( - &replace_non_printable( - preview.title.as_bytes(), - &ReplaceNonPrintableConfig::default(), - ) - .0, - rect.width.saturating_sub(4) as usize, - ), - Style::default().fg(DEFAULT_PREVIEW_TITLE_FG).bold(), - )); - let preview_title = Paragraph::new(Line::from(preview_title_spans)) - .block( - Block::default() - .padding(Padding::horizontal(1)) - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(BORDER_COLOR)), - ) - .alignment(Alignment::Left); - f.render_widget(preview_title, rect); - Ok(()) - } - - pub(crate) fn draw_preview_content_block( - &mut self, - f: &mut Frame, - rect: Rect, - entry: &Entry, - preview: &Arc, - ) { - let preview_outer_block = Block::default() - .title_top(Line::from(" Preview ").alignment(Alignment::Center)) - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(BORDER_COLOR)) - .style(Style::default()) - .padding(Padding::right(1)); - - let preview_inner_block = - Block::default().style(Style::default()).padding(Padding { - top: 0, - right: 1, - bottom: 0, - left: 1, - }); - let inner = preview_outer_block.inner(rect); - f.render_widget(preview_outer_block, rect); - - let target_line = - entry.line_number.map(|l| u16::try_from(l).unwrap_or(0)); - let cache_key = compute_cache_key(entry); - - self.maybe_init_preview_scroll(target_line, inner.height); - - // Check if the rendered preview content is already in the cache - if let Some(preview_paragraph) = - self.rendered_preview_cache.lock().unwrap().get(&cache_key) - { - let p = preview_paragraph.as_ref().clone(); - f.render_widget( - p.scroll((self.preview_scroll.unwrap_or(0), 0)), - inner, - ); - return; - } - // If not, render the preview content and cache it if not empty - let rp = Self::build_preview_paragraph( - preview_inner_block, - inner, - preview.content.clone(), - target_line, - self.preview_scroll, - ); - if !preview.stale { - self.rendered_preview_cache - .lock() - .unwrap() - .insert(cache_key, &Arc::new(rp.clone())); - } - f.render_widget( - Arc::new(rp) - .as_ref() - .clone() - .scroll((self.preview_scroll.unwrap_or(0), 0)), - inner, - ); - } - - #[allow(dead_code)] - const FILL_CHAR_SLANTED: char = '╱'; - const FILL_CHAR_EMPTY: char = ' '; - - // FIXME: I broke the previewer (srolling is not working as intended) - // and it looks like the previewer displays the wrong previews - pub fn build_preview_paragraph( - preview_block: Block, - inner: Rect, - preview_content: PreviewContent, - target_line: Option, - preview_scroll: Option, - ) -> Paragraph { - match preview_content { - PreviewContent::AnsiText(text) => Self::build_ansi_text_paragraph( - text, - preview_block, - preview_scroll, - ), - PreviewContent::PlainText(content) => { - Self::build_plain_text_paragraph( - content, - preview_block, - target_line, - preview_scroll, - ) - } - PreviewContent::PlainTextWrapped(content) => { - Self::build_plain_text_wrapped_paragraph( - content, - preview_block, - ) - } - PreviewContent::SyntectHighlightedText(highlighted_lines) => { - Self::build_syntect_highlighted_paragraph( - highlighted_lines, - preview_block, - target_line, - preview_scroll, - ) - } - // meta - PreviewContent::Loading => Self::build_meta_preview_paragraph( - inner, - "Loading...", - Self::FILL_CHAR_EMPTY, - ) - .block(preview_block) - .alignment(Alignment::Left) - .style(Style::default().add_modifier(Modifier::ITALIC)), - PreviewContent::NotSupported => { - Self::build_meta_preview_paragraph( - inner, - PREVIEW_NOT_SUPPORTED_MSG, - Self::FILL_CHAR_EMPTY, - ) - .block(preview_block) - .alignment(Alignment::Left) - .style(Style::default().add_modifier(Modifier::ITALIC)) - } - PreviewContent::FileTooLarge => { - Self::build_meta_preview_paragraph( - inner, - FILE_TOO_LARGE_MSG, - Self::FILL_CHAR_EMPTY, - ) - .block(preview_block) - .alignment(Alignment::Left) - .style(Style::default().add_modifier(Modifier::ITALIC)) - } - PreviewContent::Empty => Paragraph::new(Text::raw(EMPTY_STRING)), - } - } - - fn build_ansi_text_paragraph( - text: String, - preview_block: Block, - preview_scroll: Option, - ) -> Paragraph { - let text = replace_non_printable( - text.as_bytes(), - &ReplaceNonPrintableConfig { - replace_line_feed: false, - replace_control_characters: false, - ..Default::default() - }, - ) - .0 - .into_text() - .unwrap(); - Paragraph::new(text) - .block(preview_block) - .scroll((preview_scroll.unwrap_or(0), 0)) - } - - fn build_plain_text_paragraph( - text: Vec, - preview_block: Block, - target_line: Option, - preview_scroll: Option, - ) -> Paragraph { - let mut lines = Vec::new(); - for (i, line) in text.iter().enumerate() { - lines.push(Line::from(vec![ - build_line_number_span(i + 1).style(Style::default().fg( - if matches!( - target_line, - Some(l) if l == u16::try_from(i).unwrap_or(0) + 1 - ) - { - DEFAULT_PREVIEW_GUTTER_SELECTED_FG - } else { - DEFAULT_PREVIEW_GUTTER_FG - }, - )), - Span::styled(" │ ", - Style::default().fg(DEFAULT_PREVIEW_GUTTER_FG).dim()), - Span::styled( - line.to_string(), - Style::default().fg(DEFAULT_PREVIEW_CONTENT_FG).bg( - if matches!(target_line, Some(l) if l == u16::try_from(i).unwrap() + 1) { - DEFAULT_SELECTED_PREVIEW_BG - } else { - Color::Reset - }, - ), - ), - ])); - } - let text = Text::from(lines); - Paragraph::new(text) - .block(preview_block) - .scroll((preview_scroll.unwrap_or(0), 0)) - } - - fn build_plain_text_wrapped_paragraph( - text: String, - preview_block: Block, - ) -> Paragraph { - let mut lines = Vec::new(); - for line in text.lines() { - lines.push(Line::styled( - line.to_string(), - Style::default().fg(DEFAULT_PREVIEW_CONTENT_FG), - )); - } - let text = Text::from(lines); - Paragraph::new(text) - .block(preview_block) - .wrap(Wrap { trim: true }) - } - - fn build_syntect_highlighted_paragraph( - highlighted_lines: Vec>, - preview_block: Block, - target_line: Option, - preview_scroll: Option, - ) -> Paragraph { - compute_paragraph_from_highlighted_lines( - &highlighted_lines, - target_line.map(|l| l as usize), - ) - .block(preview_block) - .alignment(Alignment::Left) - .scroll((preview_scroll.unwrap_or(0), 0)) - } - - pub fn maybe_init_preview_scroll( - &mut self, - target_line: Option, - height: u16, - ) { - if self.preview_scroll.is_none() && !self.channel.running() { - self.preview_scroll = - Some(target_line.unwrap_or(0).saturating_sub(height / 3)); - } - } - - pub fn build_meta_preview_paragraph<'a>( - inner: Rect, - message: &str, - fill_char: char, - ) -> Paragraph<'a> { - let message_len = message.len(); - if message_len + 8 > inner.width as usize { - return Paragraph::new(Text::from(EMPTY_STRING)); - } - let fill_char_str = fill_char.to_string(); - let fill_line = fill_char_str.repeat(inner.width as usize); - - // Build the paragraph content with slanted lines and center the custom message - let mut lines = Vec::new(); - - // Calculate the vertical center - let vertical_center = inner.height as usize / 2; - let horizontal_padding = (inner.width as usize - message_len) / 2 - 4; - - // Fill the paragraph with slanted lines and insert the centered custom message - for i in 0..inner.height { - if i as usize == vertical_center { - // Center the message horizontally in the middle line - let line = format!( - "{} {} {}", - fill_char_str.repeat(horizontal_padding), - message, - fill_char_str.repeat( - inner.width as usize - - horizontal_padding - - message_len - ) - ); - lines.push(Line::from(line)); - } else if i as usize + 1 == vertical_center - || (i as usize).saturating_sub(1) == vertical_center - { - let line = format!( - "{} {} {}", - fill_char_str.repeat(horizontal_padding), - " ".repeat(message_len), - fill_char_str.repeat( - inner.width as usize - - horizontal_padding - - message_len - ) - ); - lines.push(Line::from(line)); - } else { - lines.push(Line::from(fill_line.clone())); - } - } - - // Create a paragraph with the generated content - Paragraph::new(Text::from(lines)) - } -} - -fn build_line_number_span<'a>(line_number: usize) -> Span<'a> { - Span::from(format!("{line_number:5} ")) -} - -fn compute_paragraph_from_highlighted_lines( - highlighted_lines: &[Vec<(syntect::highlighting::Style, String)>], - line_specifier: Option, -) -> Paragraph<'static> { - let preview_lines: Vec = highlighted_lines - .iter() - .enumerate() - .map(|(i, l)| { - let line_number = - build_line_number_span(i + 1).style(Style::default().fg( - if line_specifier.is_some() - && i == line_specifier.unwrap().saturating_sub(1) - { - DEFAULT_PREVIEW_GUTTER_SELECTED_FG - } else { - DEFAULT_PREVIEW_GUTTER_FG - }, - )); - Line::from_iter( - std::iter::once(line_number) - .chain(std::iter::once(Span::styled( - " │ ", - Style::default().fg(DEFAULT_PREVIEW_GUTTER_FG).dim(), - ))) - .chain(l.iter().cloned().map(|sr| { - convert_syn_region_to_span( - &(sr.0, sr.1), - if line_specifier.is_some() - && i == line_specifier - .unwrap() - .saturating_sub(1) - { - Some(SyntectColor { - r: 50, - g: 50, - b: 50, - a: 255, - }) - } else { - None - }, - ) - })), - ) - }) - .collect(); - - Paragraph::new(preview_lines) -} - -pub fn convert_syn_region_to_span<'a>( - syn_region: &(syntect::highlighting::Style, String), - background: Option, -) -> Span<'a> { - let mut style = Style::default() - .fg(convert_syn_color_to_ratatui_color(syn_region.0.foreground)); - if let Some(background) = background { - style = style.bg(convert_syn_color_to_ratatui_color(background)); - } - style = match syn_region.0.font_style { - syntect::highlighting::FontStyle::BOLD => style.bold(), - syntect::highlighting::FontStyle::ITALIC => style.italic(), - syntect::highlighting::FontStyle::UNDERLINE => style.underlined(), - _ => style, - }; - Span::styled(syn_region.1.clone(), style) -} - -fn convert_syn_color_to_ratatui_color( - color: syntect::highlighting::Color, -) -> Color { - Color::Rgb(color.r, color.g, color.b) -} - -fn compute_cache_key(entry: &Entry) -> String { - let mut cache_key = entry.name.clone(); - if let Some(line_number) = entry.line_number { - cache_key.push_str(&line_number.to_string()); - } - cache_key -} diff --git a/crates/television/ui/remote_control.rs b/crates/television/ui/remote_control.rs deleted file mode 100644 index 0679e70..0000000 --- a/crates/television/ui/remote_control.rs +++ /dev/null @@ -1,153 +0,0 @@ -use crate::television::Television; -use crate::ui::logo::build_remote_logo_paragraph; -use crate::ui::mode::mode_color; -use crate::ui::results::{build_results_list, ResultsListColors}; -use crate::ui::BORDER_COLOR; -use color_eyre::eyre::Result; -use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; -use ratatui::prelude::Style; -use ratatui::style::{Color, Stylize}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{ - Block, BorderType, Borders, ListDirection, Padding, Paragraph, -}; -use ratatui::Frame; -use television_channels::channels::OnAir; - -impl Television { - pub fn draw_remote_control( - &mut self, - f: &mut Frame, - rect: Rect, - ) -> Result<()> { - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints( - [ - Constraint::Min(3), - Constraint::Length(3), - Constraint::Length(20), - ] - .as_ref(), - ) - .split(rect); - self.draw_rc_channels(f, &layout[0])?; - self.draw_rc_input(f, &layout[1])?; - draw_rc_logo(f, layout[2], mode_color(self.mode)); - Ok(()) - } - - fn draw_rc_channels(&mut self, f: &mut Frame, area: &Rect) -> Result<()> { - let rc_block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(BORDER_COLOR)) - .style(Style::default()) - .padding(Padding::right(1)); - - let result_count = self.remote_control.result_count(); - if result_count > 0 && self.rc_picker.selected().is_none() { - self.rc_picker.select(Some(0)); - self.rc_picker.relative_select(Some(0)); - } - - let entries = self.remote_control.results( - area.height.saturating_sub(2).into(), - u32::try_from(self.rc_picker.offset())?, - ); - - let channel_list = build_results_list( - rc_block, - &entries, - ListDirection::TopToBottom, - Some( - ResultsListColors::default() - .result_name_fg(mode_color(self.mode)), - ), - self.config.ui.use_nerd_font_icons, - &mut self.icon_color_cache, - ); - - f.render_stateful_widget( - channel_list, - *area, - &mut self.rc_picker.state, - ); - Ok(()) - } - - fn draw_rc_input(&mut self, f: &mut Frame, area: &Rect) -> Result<()> { - let input_block = Block::default() - .title_top( - Line::from("Remote Control").alignment(Alignment::Center), - ) - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(BORDER_COLOR)) - .style(Style::default()); - - let input_block_inner = input_block.inner(*area); - - f.render_widget(input_block, *area); - - // split input block into 2 parts: prompt symbol, input - let inner_input_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - // prompt symbol - Constraint::Length(2), - // input field - Constraint::Fill(1), - ]) - .split(input_block_inner); - - let prompt_symbol_block = Block::default(); - let arrow = Paragraph::new(Span::styled( - "> ", - Style::default() - .fg(crate::television::DEFAULT_INPUT_FG) - .bold(), - )) - .block(prompt_symbol_block); - f.render_widget(arrow, inner_input_chunks[0]); - - let interactive_input_block = Block::default(); - // keep 2 for borders and 1 for cursor - let width = inner_input_chunks[1].width.max(3) - 3; - let scroll = self.rc_picker.input.visual_scroll(width as usize); - let input = Paragraph::new(self.rc_picker.input.value()) - .scroll((0, u16::try_from(scroll)?)) - .block(interactive_input_block) - .style( - Style::default() - .fg(crate::television::DEFAULT_INPUT_FG) - .bold() - .italic(), - ) - .alignment(Alignment::Left); - f.render_widget(input, inner_input_chunks[1]); - - // Make the cursor visible and ask tui-rs to put it at the - // specified coordinates after rendering - f.set_cursor_position(( - // Put cursor past the end of the input text - inner_input_chunks[1].x - + u16::try_from( - self.rc_picker.input.visual_cursor().max(scroll) - scroll, - )?, - // Move one line down, from the border to the input line - inner_input_chunks[1].y, - )); - Ok(()) - } -} - -fn draw_rc_logo(f: &mut Frame, area: Rect, color: Color) { - let logo_block = Block::default().style(Style::default().fg(color)); - - let logo_paragraph = build_remote_logo_paragraph() - .alignment(Alignment::Center) - .block(logo_block); - - f.render_widget(logo_paragraph, area); -}