From 76d32a01c2ef0d397f1004c212b4a9f16770cdd6 Mon Sep 17 00:00:00 2001 From: alexpasmantier Date: Sun, 27 Oct 2024 00:37:13 +0200 Subject: [PATCH] great progress --- .config/config.toml | 6 +- Cargo.lock | 14 +- Cargo.toml | 22 +- crates/television/action.rs | 2 +- crates/television/channels.rs | 25 +- crates/television/channels/alias.rs | 6 +- crates/television/channels/env.rs | 16 +- crates/television/channels/files.rs | 2 +- crates/television/channels/git_repos.rs | 7 +- .../{tv_guide.rs => remote_control.rs} | 26 +- crates/television/channels/stdin.rs | 4 +- crates/television/channels/text.rs | 20 +- crates/television/config.rs | 1 - crates/television/event.rs | 2 +- crates/television/main.rs | 5 +- crates/television/picker.rs | 114 ++++ crates/television/previewers/directory.rs | 2 +- crates/television/previewers/files.rs | 9 +- crates/television/television.rs | 518 +++++------------- crates/television/ui.rs | 7 +- crates/television/ui/help.rs | 273 ++------- crates/television/ui/input.rs | 126 ++++- crates/television/ui/keymap.rs | 241 ++++++++ crates/television/ui/layout.rs | 101 ++-- crates/television/ui/logo.rs | 30 + crates/television/ui/preview.rs | 107 +++- crates/television/ui/remote_control.rs | 147 +++++ crates/television/ui/results.rs | 54 +- crates/television_derive/src/lib.rs | 59 +- 29 files changed, 1192 insertions(+), 754 deletions(-) rename crates/television/channels/{tv_guide.rs => remote_control.rs} (96%) create mode 100644 crates/television/picker.rs create mode 100644 crates/television/ui/keymap.rs create mode 100644 crates/television/ui/remote_control.rs diff --git a/.config/config.toml b/.config/config.toml index dd0d7dc..c49e675 100644 --- a/.config/config.toml +++ b/.config/config.toml @@ -8,13 +8,13 @@ ctrl-d = "ScrollPreviewHalfPageDown" ctrl-u = "ScrollPreviewHalfPageUp" enter = "SelectEntry" ctrl-enter = "SendToChannel" -ctrl-s = "ToggleChannelSelection" +ctrl-s = "ToggleRemoteControl" -[keybindings.Guide] +[keybindings.RemoteControl] esc = "Quit" down = "SelectNextEntry" up = "SelectPrevEntry" ctrl-n = "SelectNextEntry" ctrl-p = "SelectPrevEntry" enter = "SelectEntry" -ctrl-s = "ToggleChannelSelection" +ctrl-s = "ToggleRemoteControl" diff --git a/Cargo.lock b/Cargo.lock index 64c7318..e2776f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1541,6 +1541,12 @@ dependencies = [ "hashbrown 0.15.0", ] +[[package]] +name = "indoc" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" + [[package]] name = "infer" version = "0.16.0" @@ -2038,24 +2044,24 @@ dependencies = [ [[package]] name = "ratatui" -version = "0.28.1" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ "bitflags 2.6.0", "cassowary", "compact_str", "crossterm", + "indoc", "instability", "itertools", "lru", "paste", "serde", "strum", - "strum_macros", "unicode-segmentation", "unicode-truncate", - "unicode-width 0.1.14", + "unicode-width 0.2.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index a0997e0..ecabf21 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,10 +9,10 @@ build = "build.rs" repository = "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", ] @@ -28,12 +28,12 @@ members = ["crates/television_derive"] television-derive = { version = "0.1.0", path = "crates/television_derive" } better-panic = "0.3.0" clap = { version = "4.4.5", features = [ - "derive", - "cargo", - "wrap_help", - "unicode", - "string", - "unstable-styles", + "derive", + "cargo", + "wrap_help", + "unicode", + "string", + "unstable-styles", ] } color-eyre = "0.6.3" config = "0.14.0" @@ -50,7 +50,7 @@ libc = "0.2.158" nucleo = "0.5.0" nucleo-matcher = "0.3.1" parking_lot = "0.12.3" -ratatui = { version = "0.28.1", features = ["serde", "macros"] } +ratatui = { version = "0.29.0", features = ["serde", "macros"] } regex = "1.10.6" serde = { version = "1.0.208", features = ["derive"] } serde_json = "1.0.125" diff --git a/crates/television/action.rs b/crates/television/action.rs index c6e2af9..e9de727 100644 --- a/crates/television/action.rs +++ b/crates/television/action.rs @@ -42,6 +42,6 @@ pub enum Action { Error(String), NoOp, // channel actions - ToggleChannelSelection, + ToggleRemoteControl, SendToChannel, } diff --git a/crates/television/channels.rs b/crates/television/channels.rs index e647bdf..a263319 100644 --- a/crates/television/channels.rs +++ b/crates/television/channels.rs @@ -6,9 +6,9 @@ mod alias; mod env; mod files; mod git_repos; +pub mod remote_control; pub mod stdin; mod text; -pub mod tv_guide; /// The interface that all television channels must implement. /// @@ -94,13 +94,34 @@ pub trait OnAir: Send { #[allow(dead_code, clippy::module_name_repetitions)] #[derive(UnitChannel, CliChannel, Broadcast)] pub enum TelevisionChannel { + /// The environment variables channel. + /// + /// This channel allows to search through environment variables. Env(env::Channel), + /// The files channel. + /// + /// This channel allows to search through files. Files(files::Channel), + /// The git repositories channel. + /// + /// This channel allows to search through git repositories. GitRepos(git_repos::Channel), + /// The text channel. + /// + /// This channel allows to search through the contents of text files. Text(text::Channel), + /// The standard input channel. + /// + /// This channel allows to search through whatever is passed through stdin. Stdin(stdin::Channel), + /// The alias channel. + /// + /// This channel allows to search through aliases. Alias(alias::Channel), - TvGuide(tv_guide::TvGuide), + /// The remote control channel. + /// + /// This channel allows to switch between different channels. + RemoteControl(remote_control::RemoteControl), } /// NOTE: this could be generated by a derive macro diff --git a/crates/television/channels/alias.rs b/crates/television/channels/alias.rs index da407d6..c63538e 100644 --- a/crates/television/channels/alias.rs +++ b/crates/television/channels/alias.rs @@ -52,7 +52,7 @@ fn get_raw_aliases(shell: &str) -> Vec { let aliases = String::from_utf8(output.stdout).unwrap(); aliases .lines() - .map(std::string::ToString::to_string) + .map(ToString::to_string) .collect() } "zsh" => { @@ -65,7 +65,7 @@ fn get_raw_aliases(shell: &str) -> Vec { let aliases = String::from_utf8(output.stdout).unwrap(); aliases .lines() - .map(std::string::ToString::to_string) + .map(ToString::to_string) .collect() } // TODO: add more shells @@ -179,7 +179,7 @@ impl OnAir for Channel { .collect() } - fn get_result(&self, index: u32) -> Option { + fn get_result(&self, index: u32) -> Option { let snapshot = self.matcher.snapshot(); snapshot.get_matched_item(index).map(|item| { Entry::new(item.data.name.clone(), PreviewType::EnvVar) diff --git a/crates/television/channels/env.rs b/crates/television/channels/env.rs index d38eebd..7b74aa9 100644 --- a/crates/television/channels/env.rs +++ b/crates/television/channels/env.rs @@ -76,14 +76,6 @@ impl OnAir for Channel { } } - fn result_count(&self) -> u32 { - self.result_count - } - - fn total_count(&self) -> u32 { - self.total_count - } - fn results(&mut self, num_entries: u32, offset: u32) -> Vec { let status = self.matcher.tick(Self::MATCHER_TICK_TIMEOUT); let snapshot = self.matcher.snapshot(); @@ -157,6 +149,14 @@ impl OnAir for Channel { }) } + fn result_count(&self) -> u32 { + self.result_count + } + + fn total_count(&self) -> u32 { + self.total_count + } + fn running(&self) -> bool { self.running } diff --git a/crates/television/channels/files.rs b/crates/television/channels/files.rs index af86e1e..6bad96b 100644 --- a/crates/television/channels/files.rs +++ b/crates/television/channels/files.rs @@ -101,7 +101,7 @@ impl OnAir for Channel { .matched_items( offset ..(num_entries + offset) - .min(snapshot.matched_item_count()), + .min(snapshot.matched_item_count()), ) .map(move |item| { snapshot.pattern().column_pattern(0).indices( diff --git a/crates/television/channels/git_repos.rs b/crates/television/channels/git_repos.rs index c00186c..988c83c 100644 --- a/crates/television/channels/git_repos.rs +++ b/crates/television/channels/git_repos.rs @@ -1,4 +1,3 @@ -use color_eyre::owo_colors::OwoColorize; use devicons::FileIcon; use ignore::{overrides::OverrideBuilder, DirEntry}; use nucleo::{ @@ -105,7 +104,7 @@ impl OnAir for Channel { .matched_items( offset ..(num_entries + offset) - .min(snapshot.matched_item_count()), + .min(snapshot.matched_item_count()), ) .map(move |item| { snapshot.pattern().column_pattern(0).indices( @@ -201,7 +200,7 @@ fn get_ignored_paths() -> Vec { } #[allow(clippy::unused_async)] async fn crawl_for_repos( - starting_point: std::path::PathBuf, + starting_point: PathBuf, injector: nucleo::Injector, entry_cache: Arc>>, cache_valid: Arc>, @@ -214,7 +213,7 @@ async fn crawl_for_repos( Some(walker_overrides_builder.build().unwrap()), Some(get_ignored_paths()), ) - .build_parallel(); + .build_parallel(); walker.run(|| { let injector = injector.clone(); diff --git a/crates/television/channels/tv_guide.rs b/crates/television/channels/remote_control.rs similarity index 96% rename from crates/television/channels/tv_guide.rs rename to crates/television/channels/remote_control.rs index 19dc125..9a1d3aa 100644 --- a/crates/television/channels/tv_guide.rs +++ b/crates/television/channels/remote_control.rs @@ -14,7 +14,7 @@ use crate::{ previewers::PreviewType, }; -pub struct TvGuide { +pub struct RemoteControl { matcher: Nucleo, last_pattern: String, result_count: u32, @@ -24,7 +24,7 @@ pub struct TvGuide { const NUM_THREADS: usize = 1; -impl TvGuide { +impl RemoteControl { pub fn new() -> Self { let matcher = Nucleo::new( Config::DEFAULT, @@ -38,7 +38,7 @@ impl TvGuide { cols[0] = (*e).to_string().into(); }); } - TvGuide { + RemoteControl { matcher, last_pattern: String::new(), result_count: 0, @@ -50,7 +50,7 @@ impl TvGuide { const MATCHER_TICK_TIMEOUT: u64 = 2; } -impl Default for TvGuide { +impl Default for RemoteControl { fn default() -> Self { Self::new() } @@ -61,7 +61,7 @@ const TV_ICON: FileIcon = FileIcon { color: "#ffffff", }; -impl OnAir for TvGuide { +impl OnAir for RemoteControl { fn find(&mut self, pattern: &str) { if pattern != self.last_pattern { self.matcher.pattern.reparse( @@ -75,14 +75,6 @@ impl OnAir for TvGuide { } } - fn result_count(&self) -> u32 { - self.result_count - } - - fn total_count(&self) -> u32 { - self.total_count - } - fn results(&mut self, num_entries: u32, offset: u32) -> Vec { let status = self.matcher.tick(Self::MATCHER_TICK_TIMEOUT); let snapshot = self.matcher.snapshot(); @@ -130,6 +122,14 @@ impl OnAir for TvGuide { }) } + fn result_count(&self) -> u32 { + self.result_count + } + + fn total_count(&self) -> u32 { + self.total_count + } + fn running(&self) -> bool { self.running } diff --git a/crates/television/channels/stdin.rs b/crates/television/channels/stdin.rs index e7a6b9a..168c910 100644 --- a/crates/television/channels/stdin.rs +++ b/crates/television/channels/stdin.rs @@ -90,7 +90,7 @@ impl OnAir for Channel { .matched_items( offset ..(num_entries + offset) - .min(snapshot.matched_item_count()), + .min(snapshot.matched_item_count()), ) .map(move |item| { snapshot.pattern().column_pattern(0).indices( @@ -118,7 +118,7 @@ impl OnAir for Channel { .collect() } - fn get_result(&self, index: u32) -> Option { + fn get_result(&self, index: u32) -> Option { let snapshot = self.matcher.snapshot(); snapshot.get_matched_item(index).map(|item| { let content = item.matcher_columns[0].to_string(); diff --git a/crates/television/channels/text.rs b/crates/television/channels/text.rs index d03ce85..7996047 100644 --- a/crates/television/channels/text.rs +++ b/crates/television/channels/text.rs @@ -91,14 +91,6 @@ impl OnAir for Channel { } } - fn result_count(&self) -> u32 { - self.result_count - } - - fn total_count(&self) -> u32 { - self.total_count - } - fn results(&mut self, num_entries: u32, offset: u32) -> Vec { let status = self.matcher.tick(Self::MATCHER_TICK_TIMEOUT); let snapshot = self.matcher.snapshot(); @@ -157,6 +149,14 @@ impl OnAir for Channel { }) } + fn result_count(&self) -> u32 { + self.result_count + } + + fn total_count(&self) -> u32 { + self.total_count + } + fn running(&self) -> bool { self.running } @@ -219,9 +219,9 @@ async fn load_candidates(path: PathBuf, injector: Injector) { Ok(bytes_read) => { if (bytes_read == 0) || is_not_text(&buffer) - .unwrap_or(false) + .unwrap_or(false) || proportion_of_printable_ascii_characters(&buffer) - < PRINTABLE_ASCII_THRESHOLD + < PRINTABLE_ASCII_THRESHOLD { return ignore::WalkState::Continue; } diff --git a/crates/television/config.rs b/crates/television/config.rs index 03a81ed..fb86aad 100644 --- a/crates/television/config.rs +++ b/crates/television/config.rs @@ -52,7 +52,6 @@ lazy_static! { impl Config { pub fn new() -> Result { - //let default_config: Config = json5::from_str(CONFIG).unwrap(); let default_config: Config = toml::from_str(CONFIG).unwrap(); let data_dir = get_data_dir(); let config_dir = get_config_dir(); diff --git a/crates/television/event.rs b/crates/television/event.rs index b3f8c51..f4ee38b 100644 --- a/crates/television/event.rs +++ b/crates/television/event.rs @@ -154,7 +154,7 @@ impl EventLoop { let (tx, rx) = mpsc::unbounded_channel(); let tx_c = tx.clone(); let tick_interval = - tokio::time::Duration::from_secs_f64(1.0 / tick_rate); + Duration::from_secs_f64(1.0 / tick_rate); let (abort, mut abort_recv) = mpsc::unbounded_channel(); diff --git a/crates/television/main.rs b/crates/television/main.rs index e724bf6..7368398 100644 --- a/crates/television/main.rs +++ b/crates/television/main.rs @@ -19,6 +19,7 @@ mod errors; mod event; mod fuzzy; mod logging; +mod picker; mod previewers; mod render; mod television; @@ -28,8 +29,8 @@ mod utils; #[tokio::main(flavor = "multi_thread")] async fn main() -> Result<()> { - crate::errors::init()?; - crate::logging::init()?; + errors::init()?; + logging::init()?; let args = Cli::parse(); diff --git a/crates/television/picker.rs b/crates/television/picker.rs new file mode 100644 index 0000000..3ccd89f --- /dev/null +++ b/crates/television/picker.rs @@ -0,0 +1,114 @@ +use crate::ui::input::Input; +use crate::utils::strings::EMPTY_STRING; +use ratatui::widgets::ListState; + +#[derive(Debug)] +pub struct Picker { + pub(crate) state: ListState, + pub(crate) relative_state: ListState, + pub(crate) view_offset: usize, + _inverted: bool, + pub(crate) input: Input, +} + +impl Default for Picker { + fn default() -> Self { + Self::new() + } +} + +impl Picker { + fn new() -> Self { + Self { + state: ListState::default(), + relative_state: ListState::default(), + view_offset: 0, + _inverted: false, + input: Input::new(EMPTY_STRING.to_string()), + } + } + + pub(crate) fn inverted(mut self) -> Self { + self._inverted = !self._inverted; + self + } + + pub(crate) fn reset_selection(&mut self) { + self.state.select(Some(0)); + self.relative_state.select(Some(0)); + self.view_offset = 0; + } + + pub(crate) fn reset_input(&mut self) { + self.input.reset(); + } + + pub(crate) fn selected(&self) -> Option { + self.state.selected() + } + + pub(crate) fn select(&mut self, index: Option) { + self.state.select(index); + } + + fn relative_selected(&self) -> Option { + self.relative_state.selected() + } + + pub(crate) fn relative_select(&mut self, index: Option) { + self.relative_state.select(index); + } + + pub(crate) fn select_next(&mut self, total_items: usize, height: usize) { + if self._inverted { + self._select_prev(total_items, height); + } else { + self._select_next(total_items, height); + } + } + + pub(crate) fn select_prev(&mut self, total_items: usize, height: usize) { + if self._inverted { + self._select_next(total_items, height); + } else { + self._select_prev(total_items, height); + } + } + + fn _select_next(&mut self, total_items: usize, height: usize) { + let selected = self.selected().unwrap_or(0); + let relative_selected = self.relative_selected().unwrap_or(0); + if selected > 0 { + self.select(Some(selected - 1)); + self.relative_select(Some(relative_selected.saturating_sub(1))); + if relative_selected == 0 { + self.view_offset = self.view_offset.saturating_sub(1); + } + } else { + self.view_offset = total_items.saturating_sub(height - 2); + self.select(Some((total_items).saturating_sub(1))); + self.relative_select(Some(height - 3)); + } + } + + fn _select_prev(&mut self, total_items: usize, height: usize) { + let new_index = (self.selected().unwrap_or(0) + 1) % total_items; + self.select(Some(new_index)); + if new_index == 0 { + self.view_offset = 0; + self.relative_select(Some(0)); + return; + } + if self.relative_selected().unwrap_or(0) == height - 3 { + self.view_offset += 1; + self.relative_select(Some( + self.selected().unwrap_or(0).min(height - 3), + )); + } else { + self.relative_select(Some( + (self.relative_selected().unwrap_or(0) + 1) + .min(self.selected().unwrap_or(0)), + )); + } + } +} diff --git a/crates/television/previewers/directory.rs b/crates/television/previewers/directory.rs index 95216b0..bbc000d 100644 --- a/crates/television/previewers/directory.rs +++ b/crates/television/previewers/directory.rs @@ -85,7 +85,7 @@ fn tree>( .build(); let mut level_entry_count: u8 = 0; - for path in w.skip(1).filter_map(std::result::Result::ok) { + for path in w.skip(1).filter_map(Result::ok) { let m = path.metadata().unwrap(); if m.is_dir() && max_depth > 1 { root.push(tree( diff --git a/crates/television/previewers/files.rs b/crates/television/previewers/files.rs index 193edab..4e161aa 100644 --- a/crates/television/previewers/files.rs +++ b/crates/television/previewers/files.rs @@ -5,11 +5,10 @@ use std::fs::File; use std::io::{BufRead, BufReader, Read, Seek}; use std::path::{Path, PathBuf}; use std::sync::Arc; -use syntect::easy::HighlightLines; use tokio::sync::Mutex; use syntect::{ - highlighting::{Style, Theme, ThemeSet}, + highlighting::{Theme, ThemeSet}, parsing::SyntaxSet, }; use tracing::{debug, warn}; @@ -82,7 +81,7 @@ impl FilePreviewer { entry.name.clone(), preview.clone(), ) - .await; + .await; // compute the highlighted version in the background let mut reader = @@ -226,8 +225,8 @@ impl FilePreviewer { if let Ok(bytes_read) = f.read(&mut buffer) { if bytes_read > 0 && proportion_of_printable_ascii_characters( - &buffer[..bytes_read], - ) > PRINTABLE_ASCII_THRESHOLD + &buffer[..bytes_read], + ) > PRINTABLE_ASCII_THRESHOLD { file_type = FileType::Text; } diff --git a/crates/television/television.rs b/crates/television/television.rs index 978ff19..c3faed9 100644 --- a/crates/television/television.rs +++ b/crates/television/television.rs @@ -1,62 +1,44 @@ -use color_eyre::Result; -use futures::executor::block_on; -use ratatui::{ - layout::{ - Alignment, Constraint, Direction, Layout as RatatuiLayout, Rect, - }, - style::{Color, Style, Stylize}, - text::{Line, Span}, - widgets::{ - block::{Position, Title}, - Block, BorderType, Borders, ListState, Padding, Paragraph, Wrap, - }, - Frame, -}; -use serde::{Deserialize, Serialize}; -use std::{collections::HashMap, str::FromStr}; -use strum::Display; -use tokio::sync::mpsc::UnboundedSender; - -use crate::ui::preview::DEFAULT_PREVIEW_TITLE_FG; -use crate::ui::results::build_results_list; -use crate::ui::{ - layout::{Dimensions, Layout}, - logo::build_logo_paragraph, -}; +use crate::channels::remote_control::RemoteControl; +use crate::channels::OnAir; +use crate::channels::UnitChannel; +use crate::picker::Picker; +use crate::ui::layout::{Dimensions, Layout}; use crate::utils::strings::EMPTY_STRING; use crate::{action::Action, config::Config}; -use crate::{channels::tv_guide::TvGuide, ui::get_border_style}; -use crate::{channels::OnAir, utils::strings::shrink_with_ellipsis}; use crate::{ channels::TelevisionChannel, ui::input::actions::InputActionHandler, }; -use crate::{channels::UnitChannel, ui::input::Input}; use crate::{ entry::{Entry, ENTRY_PLACEHOLDER}, ui::spinner::Spinner, }; use crate::{previewers::Previewer, ui::spinner::SpinnerState}; +use color_eyre::Result; +use futures::executor::block_on; +use ratatui::{layout::Rect, style::Color, widgets::Paragraph, Frame}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use strum::Display; +use tokio::sync::mpsc::UnboundedSender; #[derive( PartialEq, Copy, Clone, Hash, Eq, Debug, Serialize, Deserialize, Display, )] pub enum Mode { Channel, - Guide, + RemoteControl, SendToChannel, } pub struct Television { action_tx: Option>, pub config: Config, - channel: TelevisionChannel, - guide: TelevisionChannel, - current_pattern: String, + pub(crate) channel: TelevisionChannel, + pub(crate) remote_control: TelevisionChannel, pub mode: Mode, - input: Input, - picker_state: ListState, - relative_picker_state: ListState, - picker_view_offset: usize, + current_pattern: String, + pub(crate) results_picker: Picker, + pub(crate) rc_picker: Picker, results_area_height: u32, pub previewer: Previewer, pub preview_scroll: Option, @@ -69,30 +51,26 @@ pub struct Television { /// are rendered correctly even when resizing the terminal while still /// benefiting from a cache mechanism. pub meta_paragraph_cache: HashMap<(String, u16, u16), Paragraph<'static>>, - spinner: Spinner, - spinner_state: SpinnerState, + pub(crate) spinner: Spinner, + pub(crate) spinner_state: SpinnerState, } impl Television { #[must_use] pub fn new(mut channel: TelevisionChannel) -> Self { channel.find(EMPTY_STRING); - let guide = TelevisionChannel::TvGuide(TvGuide::new()); - let spinner = Spinner::default(); - let spinner_state = SpinnerState::from(&spinner); - Self { action_tx: None, config: Config::default(), channel, - guide, - current_pattern: EMPTY_STRING.to_string(), + remote_control: TelevisionChannel::RemoteControl( + RemoteControl::new(), + ), mode: Mode::Channel, - input: Input::new(EMPTY_STRING.to_string()), - picker_state: ListState::default(), - relative_picker_state: ListState::default(), - picker_view_offset: 0, + current_pattern: EMPTY_STRING.to_string(), + results_picker: Picker::default(), + rc_picker: Picker::default().inverted(), results_area_height: 0, previewer: Previewer::new(), preview_scroll: None, @@ -100,7 +78,7 @@ impl Television { current_preview_total_lines: 0, meta_paragraph_cache: HashMap::new(), spinner, - spinner_state, + spinner_state: SpinnerState::from(&spinner), } } @@ -111,9 +89,9 @@ impl Television { /// FIXME: this needs rework pub fn change_channel(&mut self, channel: TelevisionChannel) { self.reset_preview_scroll(); - self.reset_results_selection(); + self.reset_picker_selection(); + self.reset_picker_input(); self.current_pattern = EMPTY_STRING.to_string(); - self.input.reset(); self.channel.shutdown(); self.channel = channel; } @@ -123,92 +101,84 @@ impl Television { Mode::Channel => { self.channel.find(pattern); } - Mode::Guide | Mode::SendToChannel => { - self.guide.find(pattern); + Mode::RemoteControl | Mode::SendToChannel => { + self.remote_control.find(pattern); } } } #[must_use] pub fn get_selected_entry(&mut self) -> Option { - self.picker_state.selected().and_then(|i| match self.mode { - Mode::Channel => { + match self.mode { + Mode::Channel => self.results_picker.selected().and_then(|i| { self.channel.get_result(u32::try_from(i).unwrap()) + }), + Mode::RemoteControl | Mode::SendToChannel => { + self.rc_picker.selected().and_then(|i| { + self.remote_control.get_result(u32::try_from(i).unwrap()) + }) } - Mode::Guide | Mode::SendToChannel => { - self.guide.get_result(u32::try_from(i).unwrap()) - } - }) + } } pub fn select_prev_entry(&mut self) { - let result_count = match self.mode { - Mode::Channel => self.channel.result_count(), - Mode::Guide | Mode::SendToChannel => self.guide.total_count(), + let (result_count, picker) = match self.mode { + Mode::Channel => { + (self.channel.result_count(), &mut self.results_picker) + } + Mode::RemoteControl | Mode::SendToChannel => { + (self.remote_control.total_count(), &mut self.rc_picker) + } }; if result_count == 0 { return; } - let new_index = (self.picker_state.selected().unwrap_or(0) + 1) - % result_count as usize; - self.picker_state.select(Some(new_index)); - if new_index == 0 { - self.picker_view_offset = 0; - self.relative_picker_state.select(Some(0)); - return; - } - if self.relative_picker_state.selected().unwrap_or(0) - == self.results_area_height as usize - 3 - { - self.picker_view_offset += 1; - self.relative_picker_state.select(Some( - self.picker_state - .selected() - .unwrap_or(0) - .min(self.results_area_height as usize - 3), - )); - } else { - self.relative_picker_state.select(Some( - (self.relative_picker_state.selected().unwrap_or(0) + 1) - .min(self.picker_state.selected().unwrap_or(0)), - )); - } + picker.select_prev( + result_count as usize, + self.results_area_height as usize, + ); } pub fn select_next_entry(&mut self) { - let result_count = match self.mode { - Mode::Channel => self.channel.result_count(), - Mode::Guide | Mode::SendToChannel => self.guide.total_count(), + let (result_count, picker) = match self.mode { + Mode::Channel => { + (self.channel.result_count(), &mut self.results_picker) + } + Mode::RemoteControl | Mode::SendToChannel => { + (self.remote_control.total_count(), &mut self.rc_picker) + } }; if result_count == 0 { return; } - let selected = self.picker_state.selected().unwrap_or(0); - let relative_selected = - self.relative_picker_state.selected().unwrap_or(0); - if selected > 0 { - self.picker_state.select(Some(selected - 1)); - self.relative_picker_state - .select(Some(relative_selected.saturating_sub(1))); - if relative_selected == 0 { - self.picker_view_offset = - self.picker_view_offset.saturating_sub(1); - } - } else { - self.picker_view_offset = result_count - .saturating_sub(self.results_area_height - 2) - as usize; - self.picker_state - .select(Some((result_count as usize).saturating_sub(1))); - self.relative_picker_state - .select(Some(self.results_area_height as usize - 3)); - } + picker.select_next( + result_count as usize, + self.results_area_height as usize, + ); } fn reset_preview_scroll(&mut self) { self.preview_scroll = None; } + fn reset_picker_selection(&mut self) { + match self.mode { + Mode::Channel => self.results_picker.reset_selection(), + Mode::RemoteControl | Mode::SendToChannel => { + self.rc_picker.reset_selection() + } + } + } + + fn reset_picker_input(&mut self) { + match self.mode { + Mode::Channel => self.results_picker.reset_input(), + Mode::RemoteControl | Mode::SendToChannel => { + self.rc_picker.reset_input() + } + } + } + pub fn scroll_preview_down(&mut self, offset: u16) { if self.preview_scroll.is_none() { self.preview_scroll = Some(0); @@ -228,18 +198,12 @@ impl Television { self.preview_scroll = Some(scroll.saturating_sub(offset)); } } - - fn reset_results_selection(&mut self) { - self.picker_state.select(Some(0)); - self.relative_picker_state.select(Some(0)); - self.picker_view_offset = 0; - } } // Styles // input -const DEFAULT_INPUT_FG: Color = Color::LightRed; -const DEFAULT_RESULTS_COUNT_FG: Color = Color::LightRed; +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. @@ -286,17 +250,23 @@ impl Television { | Action::GoToInputStart | Action::GoToNextChar | Action::GoToPrevChar => { - self.input.handle_action(&action); + let input = match self.mode { + Mode::Channel => &mut self.results_picker.input, + Mode::RemoteControl | Mode::SendToChannel => { + &mut self.rc_picker.input + } + }; + input.handle_action(&action); match action { Action::AddInputChar(_) | Action::DeletePrevChar | Action::DeleteNextChar => { - let new_pattern = self.input.value().to_string(); + let new_pattern = input.value().to_string(); if new_pattern != self.current_pattern { self.current_pattern.clone_from(&new_pattern); self.find(&new_pattern); self.reset_preview_scroll(); - self.reset_results_selection(); + self.reset_picker_selection(); } } _ => {} @@ -314,13 +284,15 @@ impl Television { Action::ScrollPreviewUp => self.scroll_preview_up(1), Action::ScrollPreviewHalfPageDown => self.scroll_preview_down(20), Action::ScrollPreviewHalfPageUp => self.scroll_preview_up(20), - Action::ToggleChannelSelection => match self.mode { + Action::ToggleRemoteControl => match self.mode { Mode::Channel => { - self.reset_screen(); - self.mode = Mode::Guide; + self.mode = Mode::RemoteControl; } - Mode::Guide => { - self.reset_screen(); + Mode::RemoteControl => { + // this resets the RC picker + self.reset_picker_input(); + self.remote_control.find(EMPTY_STRING); + self.reset_picker_selection(); self.mode = Mode::Channel; } Mode::SendToChannel => {} @@ -333,10 +305,14 @@ impl Television { .as_ref() .unwrap() .send(Action::SelectAndExit)?, - Mode::Guide => { + Mode::RemoteControl => { if let Ok(new_channel) = TelevisionChannel::try_from(&entry) { + // this resets the RC picker + self.reset_picker_selection(); + self.reset_picker_input(); + self.remote_control.find(EMPTY_STRING); self.mode = Mode::Channel; self.change_channel(new_channel); } @@ -356,7 +332,8 @@ impl Television { Action::SendToChannel => { self.mode = Mode::SendToChannel; // TODO: build new guide from current channel based on which are pipeable into - self.guide = TelevisionChannel::TvGuide(TvGuide::new()); + self.remote_control = + TelevisionChannel::RemoteControl(RemoteControl::new()); self.reset_screen(); } _ => {} @@ -366,11 +343,11 @@ impl Television { fn reset_screen(&mut self) { self.reset_preview_scroll(); - self.reset_results_selection(); + self.reset_picker_selection(); + self.reset_picker_input(); self.current_pattern = EMPTY_STRING.to_string(); - self.input.reset(); self.channel.find(EMPTY_STRING); - self.guide.find(EMPTY_STRING); + self.remote_control.find(EMPTY_STRING); } /// Render the television on the screen. @@ -382,278 +359,43 @@ impl Television { /// # Returns /// * `Result<()>` - An Ok result or an error. pub fn draw(&mut self, f: &mut Frame, area: Rect) -> Result<()> { - let dimensions = match self.mode { - Mode::Channel => &Dimensions::default(), - Mode::Guide | Mode::SendToChannel => &Dimensions::new(30, 70), - }; let layout = Layout::build( - dimensions, + &Dimensions::default(), area, - match self.mode { - Mode::Channel => true, - Mode::Guide | Mode::SendToChannel => false, - }, + !matches!(self.mode, Mode::Channel), ); - 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 = self.build_metadata_table().block(metadata_block); - - f.render_widget(metadata_table, layout.help_bar_left); - - 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 = self.build_help_table()?.block(keymaps_block); - - f.render_widget(keymaps_table, layout.help_bar_middle); - - let logo_block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(Color::Blue)) - .style(Style::default().fg(Color::Yellow)) - .padding(Padding::horizontal(1)); - - let logo_paragraph = build_logo_paragraph().block(logo_block); - - f.render_widget(logo_paragraph, layout.help_bar_right); + // help bar (metadata, keymaps, logo) + self.draw_help_bar(f, &layout)?; self.results_area_height = u32::from(layout.results.height); - if let Some(preview_window) = layout.preview_window { - self.preview_pane_height = preview_window.height; - } + self.preview_pane_height = layout.preview_window.height; // top left block: results - let results_block = Block::default() - .title( - Title::from(" Results ") - .position(Position::Top) - .alignment(Alignment::Center), - ) - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(get_border_style(false)) - .style(Style::default()) - .padding(Padding::right(1)); - - let result_count = match self.mode { - Mode::Channel => self.channel.result_count(), - Mode::Guide | Mode::SendToChannel => self.guide.total_count(), - }; - if result_count > 0 && self.picker_state.selected().is_none() { - self.picker_state.select(Some(0)); - self.relative_picker_state.select(Some(0)); - } - - let entries = match self.mode { - Mode::Channel => self.channel.results( - layout.results.height.saturating_sub(2).into(), - u32::try_from(self.picker_view_offset)?, - ), - Mode::Guide | Mode::SendToChannel => self.guide.results( - layout.results.height.saturating_sub(2).into(), - u32::try_from(self.picker_view_offset)?, - ), - }; - - let results_list = build_results_list(results_block, &entries); - - f.render_stateful_widget( - results_list, - layout.results, - &mut self.relative_picker_state, - ); + self.draw_results_list(f, &layout)?; // bottom left block: input - let input_block = Block::default() - .title( - Title::from(" Pattern ") - .position(Position::Top) - .alignment(Alignment::Center), - ) - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(get_border_style(false)) - .style(Style::default()); + self.draw_input_box(f, &layout)?; - let input_block_inner = input_block.inner(layout.input); + let selected_entry = + self.get_selected_entry().unwrap_or(ENTRY_PLACEHOLDER); + let preview = block_on(self.previewer.preview(&selected_entry)); - f.render_widget(input_block, layout.input); + // top right block: preview title + self.current_preview_total_lines = preview.total_lines(); + self.draw_preview_title_block(f, &layout, &selected_entry, &preview)?; - // split input block into 3 parts: prompt symbol, input, result count - let total_count = match self.mode { - Mode::Channel => self.channel.total_count(), - Mode::Guide | Mode::SendToChannel => self.guide.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); + // bottom right block: preview content + self.draw_preview_content_block( + f, + &layout, + &selected_entry, + &preview, + )?; - 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 = self.input.visual_scroll(width as usize); - let input = Paragraph::new(self.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, inner_input_chunks[1]); - - if match self.mode { - Mode::Channel => self.channel.running(), - Mode::Guide | Mode::SendToChannel => self.guide.running(), - } { - f.render_stateful_widget( - self.spinner, - inner_input_chunks[3], - &mut self.spinner_state, - ); - } - - let result_count_block = Block::default(); - let result_count_paragraph = Paragraph::new(Span::styled( - format!( - " {} / {} ", - if result_count == 0 { - 0 - } else { - self.picker_state.selected().unwrap_or(0) + 1 - }, - result_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( - self.input.visual_cursor().max(scroll) - scroll, - )?, - // Move one line down, from the border to the input line - inner_input_chunks[1].y, - )); - - if layout.preview_title.is_some() || layout.preview_window.is_some() { - let selected_entry = - self.get_selected_entry().unwrap_or(ENTRY_PLACEHOLDER); - let preview = block_on(self.previewer.preview(&selected_entry)); - - if let Some(preview_title_area) = layout.preview_title { - // top right block: preview title - self.current_preview_total_lines = preview.total_lines(); - - let mut preview_title_spans = Vec::new(); - if let Some(icon) = &selected_entry.icon { - preview_title_spans.push(Span::styled( - { - let mut icon_str = String::from(" "); - icon_str.push(icon.icon); - icon_str.push(' '); - icon_str - }, - Style::default().fg(Color::from_str(icon.color)?), - )); - } - preview_title_spans.push(Span::styled( - shrink_with_ellipsis( - &preview.title, - preview_title_area.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() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(get_border_style(false)), - ) - .alignment(Alignment::Left); - f.render_widget(preview_title, preview_title_area); - } - - if let Some(preview_area) = layout.preview_window { - // file preview - let preview_outer_block = Block::default() - .title( - Title::from(" Preview ") - .position(Position::Top) - .alignment(Alignment::Center), - ) - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(get_border_style(false)) - .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(preview_area); - f.render_widget(preview_outer_block, preview_area); - - //if let PreviewContent::Image(img) = &preview.content { - // let image_component = StatefulImage::new(None); - // frame.render_stateful_widget( - // image_component, - // inner, - // &mut img.clone(), - // ); - //} else { - let preview_block = self.build_preview_paragraph( - preview_inner_block, - inner, - &preview, - selected_entry - .line_number - .map(|l| u16::try_from(l).unwrap_or(0)), - ); - f.render_widget(preview_block, inner); - //} - } + // remote control + if matches!(self.mode, Mode::RemoteControl) { + self.draw_remote_control(f, &layout.remote_control.unwrap())?; } Ok(()) } diff --git a/crates/television/ui.rs b/crates/television/ui.rs index 8dde02f..6746a1a 100644 --- a/crates/television/ui.rs +++ b/crates/television/ui.rs @@ -1,14 +1,15 @@ -use ratatui::style::{Color, Style, Stylize}; +use ratatui::style::{Color, Style}; -pub mod help; +pub(crate) mod help; pub mod input; +pub mod keymap; pub mod layout; pub mod logo; pub mod metadata; pub mod preview; +mod remote_control; pub mod results; pub mod spinner; - // input //const DEFAULT_INPUT_FG: Color = Color::Rgb(200, 200, 200); //const DEFAULT_RESULTS_COUNT_FG: Color = Color::Rgb(150, 150, 150); diff --git a/crates/television/ui/help.rs b/crates/television/ui/help.rs index 418fe90..5e95c1d 100644 --- a/crates/television/ui/help.rs +++ b/crates/television/ui/help.rs @@ -1,237 +1,64 @@ -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::television::Television; +use crate::ui::layout::Layout; +use crate::ui::logo::build_logo_paragraph; +use ratatui::layout::Rect; +use ratatui::prelude::{Color, Style}; +use ratatui::widgets::{Block, BorderType, Borders, Padding}; +use ratatui::Frame; -use crate::{ - action::Action, - event::Key, - television::{Mode, Television}, -}; +pub fn draw_logo_block(f: &mut Frame, area: Rect) { + let logo_block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(Color::Blue)) + .style(Style::default().fg(Color::Yellow)) + .padding(Padding::horizontal(1)); -const ACTION_COLOR: Color = Color::DarkGray; -const KEY_COLOR: Color = Color::LightYellow; + let logo_paragraph = build_logo_paragraph().block(logo_block); + + f.render_widget(logo_paragraph, area); +} impl Television { - pub fn build_help_table<'a>(&self) -> Result> { - match self.mode { - Mode::Channel => self.build_help_table_for_channel(), - Mode::Guide => self.build_help_table_for_channel_selection(), - Mode::SendToChannel => self.build_help_table_for_channel(), - } + pub(crate) fn draw_help_bar( + &self, + f: &mut Frame, + layout: &Layout, + ) -> color_eyre::Result<()> { + self.draw_metadata_block(f, layout.help_bar_left); + self.draw_keymaps_block(f, layout.help_bar_middle)?; + draw_logo_block(f, layout.help_bar_right); + Ok(()) } - fn build_help_table_for_channel<'a>(&self) -> Result> { - let keymap = self.keymap_for_mode()?; + 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(Color::Blue)) + .padding(Padding::horizontal(1)) + .style(Style::default()); - // 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], - )); + let metadata_table = self.build_metadata_table().block(metadata_block); - // 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], - )); - - // 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], - )); - - // Send to channel - let send_to_channel_keys = - keys_for_action(keymap, &Action::SendToChannel); - let send_to_channel_row = Row::new(build_cells_for_key_groups( - "⇉ Send results to", - vec![send_to_channel_keys], - )); - - // Switch channels - let switch_channels_keys = - keys_for_action(keymap, &Action::ToggleChannelSelection); - let switch_channels_row = Row::new(build_cells_for_key_groups( - "⨀ Switch channels", - vec![switch_channels_keys], - )); - - // 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])); - - let widths = vec![Constraint::Fill(1), Constraint::Fill(2)]; - - Ok(Table::new( - vec![ - results_row, - preview_row, - select_entry_row, - send_to_channel_row, - switch_channels_row, - quit_row, - ], - widths, - )) + f.render_widget(metadata_table, area); } - fn build_help_table_for_channel_selection<'a>(&self) -> Result> { - let keymap = self.keymap_for_mode()?; + 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(Color::Blue)) + .style(Style::default()) + .padding(Padding::horizontal(1)); - // 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", - vec![prev, next], - )); + let keymaps_table = self.build_keymap_table()?.block(keymaps_block); - // 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], - )); - - // Switch channels - let switch_channels_keys = - keys_for_action(keymap, &Action::ToggleChannelSelection); - let switch_channels_row = Row::new(build_cells_for_key_groups( - "Switch channels", - vec![switch_channels_keys], - )); - - // Quit - let quit_keys = keys_for_action(keymap, &Action::Quit); - let quit_row = - Row::new(build_cells_for_key_groups("Quit", vec![quit_keys])); - - Ok(Table::new( - vec![results_row, select_entry_row, switch_channels_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 - .config - .keybindings - .get(&self.mode) - .ok_or_eyre("No keybindings found for the current Mode")?; - Ok(keymap) + f.render_widget(keymaps_table, area); + Ok(()) } } - -/// Build the corresponding spans for a group of keys. -/// -/// # Arguments -/// - `group_name`: The name of the group. -/// - `key_groups`: A vector of vectors of strings representing the keys for each group. -/// Each vector of strings represents a group of alternate keys for a given `Action`. -/// -/// # Returns -/// A vector of `Span`s representing the key groups. -/// -/// # 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>, -) -> Vec { - if key_groups.is_empty() || key_groups.iter().all(std::vec::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(); - //spans.push(Span::styled("[", Style::default().fg(KEY_COLOR))); - - 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))); - } - }); - - //spans.push(Span::styled("]", Style::default().fg(KEY_COLOR))); - cells.push(Cell::from(Line::from(spans))); - - cells -} - -/// Get the keys for a given action. -/// -/// # Arguments -/// - `keymap`: A hashmap of keybindings. -/// - `action`: The action to get the keys for. -/// -/// # Returns -/// A vector of strings representing the keys for the 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/input.rs b/crates/television/ui/input.rs index a4322a2..f42a611 100644 --- a/crates/television/ui/input.rs +++ b/crates/television/ui/input.rs @@ -1,3 +1,17 @@ +use crate::channels::OnAir; +use crate::television::Television; +use crate::ui::get_border_style; +use crate::ui::layout::Layout; +use color_eyre::eyre::Result; +use ratatui::layout::{ + Alignment, Constraint, Direction, Layout as RatatuiLayout, +}; +use ratatui::prelude::{Span, Style}; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::{Block, BorderType, Borders, Paragraph}; +use ratatui::Frame; + pub mod actions; pub mod backend; @@ -332,12 +346,12 @@ impl Input { self.value.as_str() } - /// Get the currect cursor placement. + /// Get the correct cursor placement. pub fn cursor(&self) -> usize { self.cursor } - /// Get the current cursor position with account for multispace characters. + /// Get the current cursor position with account for multi space characters. pub fn visual_cursor(&self) -> usize { if self.cursor == 0 { return 0; @@ -355,7 +369,7 @@ impl Input { }) } - /// Get the scroll position with account for multispace characters. + /// Get the scroll position with account for multi space characters. pub fn visual_scroll(&self, width: usize) -> usize { let scroll = self.visual_cursor().max(width) - width; let mut uscroll = 0; @@ -398,9 +412,113 @@ impl std::fmt::Display for Input { } } +impl Television { + pub(crate) fn draw_input_box( + &mut self, + f: &mut Frame, + layout: &Layout, + ) -> Result<()> { + let input_block = Block::default() + .title_top(Line::from(" Pattern ").alignment(Alignment::Center)) + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(get_border_style(false)) + .style(Style::default()); + + let input_block_inner = input_block.inner(layout.input); + + f.render_widget(input_block, layout.input); + + // 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."; use super::*; diff --git a/crates/television/ui/keymap.rs b/crates/television/ui/keymap.rs new file mode 100644 index 0000000..d3df4ea --- /dev/null +++ b/crates/television/ui/keymap.rs @@ -0,0 +1,241 @@ +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::{ + action::Action, + event::Key, + television::{Mode, Television}, +}; + +const ACTION_COLOR: Color = Color::DarkGray; +const KEY_COLOR: Color = Color::LightYellow; + +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(), + } + } + + fn build_keymap_table_for_channel<'a>(&self) -> Result> { + let keymap = self.keymap_for_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], + )); + + // 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], + )); + + // 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], + )); + + // Send to channel + let send_to_channel_keys = + keys_for_action(keymap, &Action::SendToChannel); + let send_to_channel_row = Row::new(build_cells_for_key_groups( + "⇉ Send results to", + vec![send_to_channel_keys], + )); + + // Switch channels + let switch_channels_keys = + keys_for_action(keymap, &Action::ToggleRemoteControl); + let switch_channels_row = Row::new(build_cells_for_key_groups( + "⨀ Remote control", + vec![switch_channels_keys], + )); + + // 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])); + + let widths = vec![Constraint::Fill(1), Constraint::Fill(2)]; + + Ok(Table::new( + vec![ + results_row, + preview_row, + select_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()?; + + // 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", + vec![prev, next], + )); + + // 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], + )); + + // Switch channels + let switch_channels_keys = + keys_for_action(keymap, &Action::ToggleRemoteControl); + let switch_channels_row = Row::new(build_cells_for_key_groups( + "Switch channels", + vec![switch_channels_keys], + )); + + // Quit + let quit_keys = keys_for_action(keymap, &Action::Quit); + let quit_row = + Row::new(build_cells_for_key_groups("Quit", vec![quit_keys])); + + Ok(Table::new( + vec![results_row, select_entry_row, switch_channels_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 + .config + .keybindings + .get(&self.mode) + .ok_or_eyre("No keybindings found for the current Mode")?; + Ok(keymap) + } +} + +/// Build the corresponding spans for a group of keys. +/// +/// # Arguments +/// - `group_name`: The name of the group. +/// - `key_groups`: A vector of vectors of strings representing the keys for each group. +/// Each vector of strings represents a group of alternate keys for a given `Action`. +/// +/// # Returns +/// A vector of `Span`s representing the key groups. +/// +/// # 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>, +) -> 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(); + //spans.push(Span::styled("[", Style::default().fg(KEY_COLOR))); + + 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))); + } + }); + + //spans.push(Span::styled("]", Style::default().fg(KEY_COLOR))); + cells.push(Cell::from(Line::from(spans))); + + cells +} + +/// Get the keys for a given action. +/// +/// # Arguments +/// - `keymap`: A hashmap of keybindings. +/// - `action`: The action to get the keys for. +/// +/// # Returns +/// A vector of strings representing the keys for the 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/layout.rs b/crates/television/ui/layout.rs index 904aeb7..30d7870 100644 --- a/crates/television/ui/layout.rs +++ b/crates/television/ui/layout.rs @@ -24,8 +24,9 @@ pub struct Layout { pub help_bar_right: Rect, pub results: Rect, pub input: Rect, - pub preview_title: Option, - pub preview_window: Option, + pub preview_title: Rect, + pub preview_window: Rect, + pub remote_control: Option, } impl Layout { @@ -35,8 +36,9 @@ impl Layout { help_bar_right: Rect, results: Rect, input: Rect, - preview_title: Option, - preview_window: Option, + preview_title: Rect, + preview_window: Rect, + remote_control: Option, ) -> Self { Self { help_bar_left, @@ -46,13 +48,14 @@ impl Layout { input, preview_title, preview_window, + remote_control, } } pub fn build( dimensions: &Dimensions, area: Rect, - with_preview: bool, + with_remote: bool, ) -> Self { let main_block = centered_rect(dimensions.x, dimensions.y, area); // split the main block into two vertical chunks (help bar + rest) @@ -65,60 +68,56 @@ impl Layout { let help_bar_chunks = layout::Layout::default() .direction(Direction::Horizontal) .constraints([ + // metadata Constraint::Fill(1), + // keymaps Constraint::Fill(1), + // logo Constraint::Length(24), ]) .split(hz_chunks[0]); - if with_preview { - // split the main block into two vertical chunks - let vt_chunks = layout::Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(50), - Constraint::Percentage(50), - ]) - .split(hz_chunks[1]); - - // left block: results + input field - let left_chunks = layout::Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(10), Constraint::Length(3)]) - .split(vt_chunks[0]); - - // right block: preview title + preview - let right_chunks = layout::Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(3), Constraint::Min(10)]) - .split(vt_chunks[1]); - - Self::new( - help_bar_chunks[0], - help_bar_chunks[1], - help_bar_chunks[2], - left_chunks[0], - left_chunks[1], - Some(right_chunks[0]), - Some(right_chunks[1]), - ) + // split the main block into two vertical chunks + let constraints = if with_remote { + vec![ + Constraint::Fill(1), + Constraint::Fill(1), + Constraint::Length(24), + ] } else { - // split the main block into two vertical chunks - let chunks = layout::Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(10), Constraint::Length(3)]) - .split(hz_chunks[1]); + vec![Constraint::Percentage(50), Constraint::Percentage(50)] + }; + let vt_chunks = layout::Layout::default() + .direction(Direction::Horizontal) + .constraints(constraints) + .split(hz_chunks[1]); - Self::new( - help_bar_chunks[0], - help_bar_chunks[1], - help_bar_chunks[2], - chunks[0], - chunks[1], - None, - None, - ) - } + // left block: results + input field + let left_chunks = layout::Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(10), Constraint::Length(3)]) + .split(vt_chunks[0]); + + // right block: preview title + preview + let right_chunks = layout::Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(10)]) + .split(vt_chunks[1]); + + Self::new( + help_bar_chunks[0], + help_bar_chunks[1], + help_bar_chunks[2], + left_chunks[0], + left_chunks[1], + right_chunks[0], + right_chunks[1], + if with_remote { + Some(vt_chunks[2]) + } else { + None + }, + ) } } diff --git a/crates/television/ui/logo.rs b/crates/television/ui/logo.rs index 414306f..2538de3 100644 --- a/crates/television/ui/logo.rs +++ b/crates/television/ui/logo.rs @@ -24,3 +24,33 @@ pub fn build_logo_paragraph<'a>() -> Paragraph<'a> { let logo_paragraph = Paragraph::new(lines); logo_paragraph } + +const REMOTE_LOGO: &str = r" + _____________ +/ \ +| (*) (#) | +| | +| (1) (2) (3) | +| (4) (5) (6) | +| (7) (8) (9) | +| | +| _ | +| | | | +| (_¯(0)¯_) | +| | | | +| ¯ | +| | +| | +| === === === | +| | +| T.V | +`-------------´"; + +pub fn build_remote_logo_paragraph<'a>() -> Paragraph<'a> { + let lines = REMOTE_LOGO + .lines() + .map(std::convert::Into::into) + .collect::>(); + let logo_paragraph = Paragraph::new(lines); + logo_paragraph +} diff --git a/crates/television/ui/preview.rs b/crates/television/ui/preview.rs index 5ced656..13fc409 100644 --- a/crates/television/ui/preview.rs +++ b/crates/television/ui/preview.rs @@ -1,11 +1,17 @@ +use crate::entry::Entry; use crate::previewers::{ Preview, PreviewContent, FILE_TOO_LARGE_MSG, PREVIEW_NOT_SUPPORTED_MSG, }; use crate::television::Television; -use crate::utils::strings::EMPTY_STRING; +use crate::ui::get_border_style; +use crate::ui::layout::Layout; +use crate::utils::strings::{shrink_with_ellipsis, EMPTY_STRING}; +use color_eyre::eyre::Result; use ratatui::layout::{Alignment, Rect}; use ratatui::prelude::{Color, Line, Modifier, Span, Style, Stylize, Text}; -use ratatui::widgets::{Block, Paragraph, Wrap}; +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; @@ -17,6 +23,91 @@ 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, + layout: &Layout, + selected_entry: &Entry, + preview: &Arc, + ) -> Result<()> { + let mut preview_title_spans = Vec::new(); + if let Some(icon) = &selected_entry.icon { + preview_title_spans.push(Span::styled( + { + // FIXME: this should be done using padding on the parent block + let mut icon_str = String::from(" "); + icon_str.push(icon.icon); + icon_str.push(' '); + icon_str + }, + Style::default().fg(Color::from_str(icon.color)?), + )); + } + preview_title_spans.push(Span::styled( + shrink_with_ellipsis( + &preview.title, + layout.preview_window.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() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(get_border_style(false)), + ) + .alignment(Alignment::Left); + f.render_widget(preview_title, layout.preview_title); + Ok(()) + } + + pub(crate) fn draw_preview_content_block( + &mut self, + f: &mut Frame, + layout: &Layout, + selected_entry: &Entry, + preview: &Arc, + ) -> Result<()> { + let preview_outer_block = Block::default() + .title_top(Line::from(" Preview ").alignment(Alignment::Center)) + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(get_border_style(false)) + .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(layout.preview_window); + f.render_widget(preview_outer_block, layout.preview_window); + + //if let PreviewContent::Image(img) = &preview.content { + // let image_component = StatefulImage::new(None); + // frame.render_stateful_widget( + // image_component, + // inner, + // &mut img.clone(), + // ); + //} else { + let preview_block = self.build_preview_paragraph( + preview_inner_block, + inner, + &preview, + selected_entry + .line_number + .map(|l| u16::try_from(l).unwrap_or(0)), + ); + f.render_widget(preview_block, inner); + //} + Ok(()) + } + const FILL_CHAR_SLANTED: char = '╱'; const FILL_CHAR_EMPTY: char = ' '; @@ -45,7 +136,7 @@ impl Television { }, )), Span::styled(" │ ", - Style::default().fg(DEFAULT_PREVIEW_GUTTER_FG).dim()), + Style::default().fg(DEFAULT_PREVIEW_GUTTER_FG).dim()), Span::styled( line.to_string(), Style::default().fg(DEFAULT_PREVIEW_CONTENT_FG).bg( @@ -83,9 +174,9 @@ impl Television { self.preview_scroll.unwrap_or(0), self.preview_pane_height, ) - .block(preview_block) - .alignment(Alignment::Left) - .scroll((self.preview_scroll.unwrap_or(0), 0)) + .block(preview_block) + .alignment(Alignment::Left) + .scroll((self.preview_scroll.unwrap_or(0), 0)) } // meta PreviewContent::Loading => self @@ -277,6 +368,6 @@ pub fn convert_syn_region_to_span<'a>( fn convert_syn_color_to_ratatui_color( color: syntect::highlighting::Color, -) -> ratatui::style::Color { - ratatui::style::Color::Rgb(color.r, color.g, color.b) +) -> Color { + Color::Rgb(color.r, color.g, color.b) } diff --git a/crates/television/ui/remote_control.rs b/crates/television/ui/remote_control.rs new file mode 100644 index 0000000..c82e61c --- /dev/null +++ b/crates/television/ui/remote_control.rs @@ -0,0 +1,147 @@ +use crate::channels::OnAir; +use crate::television::Television; +use crate::ui::get_border_style; +use crate::ui::logo::build_remote_logo_paragraph; +use crate::ui::results::build_results_list; +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; + +impl Television { + pub fn draw_remote_control( + &mut self, + f: &mut Frame, + area: &Rect, + ) -> Result<()> { + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Fill(1), + Constraint::Length(3), + Constraint::Length(20), + ] + .as_ref(), + ) + .split(*area); + self.draw_rc_channels(f, &layout[0])?; + self.draw_rc_input(f, &layout[1])?; + draw_rc_logo(f, layout[2]); + 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(get_border_style(false)) + .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.view_offset)?, + ); + + let channel_list = + build_results_list(rc_block, &entries, ListDirection::TopToBottom); + + 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(get_border_style(false)) + .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) { + let logo_block = Block::default() + // .borders(Borders::ALL) + // .border_type(BorderType::Rounded) + // .border_style(Style::default().fg(Color::Blue)) + .style(Style::default().fg(Color::Yellow)); + + 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/ui/results.rs index b19fbf7..26e6b15 100644 --- a/crates/television/ui/results.rs +++ b/crates/television/ui/results.rs @@ -1,7 +1,16 @@ +use crate::channels::OnAir; use crate::entry::Entry; +use crate::television::Television; +use crate::ui::get_border_style; +use crate::ui::layout::Layout; use crate::utils::strings::{next_char_boundary, slice_at_char_boundaries}; +use color_eyre::eyre::Result; +use ratatui::layout::Alignment; use ratatui::prelude::{Color, Line, Span, Style, Stylize}; -use ratatui::widgets::{Block, List, ListDirection}; +use ratatui::widgets::{ + Block, BorderType, Borders, List, ListDirection, Padding, +}; +use ratatui::Frame; use std::str::FromStr; // Styles @@ -13,6 +22,7 @@ const DEFAULT_RESULT_SELECTED_BG: Color = Color::Rgb(50, 50, 50); pub fn build_results_list<'a, 'b>( results_block: Block<'b>, entries: &'a [Entry], + list_direction: ListDirection, ) -> List<'a> where 'b: 'a, @@ -107,8 +117,48 @@ where } Line::from(spans) })) - .direction(ListDirection::BottomToTop) + .direction(list_direction) .highlight_style(Style::default().bg(DEFAULT_RESULT_SELECTED_BG)) .highlight_symbol("> ") .block(results_block) } + +impl Television { + pub(crate) fn draw_results_list( + &mut self, + f: &mut Frame, + layout: &Layout, + ) -> Result<()> { + let results_block = Block::default() + .title_top(Line::from(" Results ").alignment(Alignment::Center)) + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(get_border_style(false)) + .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 entries = self.channel.results( + layout.results.height.saturating_sub(2).into(), + u32::try_from(self.results_picker.view_offset)?, + ); + + let results_list = build_results_list( + results_block, + &entries, + ListDirection::BottomToTop, + ); + + f.render_stateful_widget( + results_list, + layout.results, + &mut self.results_picker.relative_state, + ); + Ok(()) + } +} diff --git a/crates/television_derive/src/lib.rs b/crates/television_derive/src/lib.rs index 0b27594..1f893ef 100644 --- a/crates/television_derive/src/lib.rs +++ b/crates/television_derive/src/lib.rs @@ -1,6 +1,27 @@ use proc_macro::TokenStream; use quote::quote; +/// This macro generates a `CliChannel` enum and the necessary glue code +/// to convert into a `TelevisionChannel` member: +/// +/// ```rust +/// use crate::channels::{TelevisionChannel, OnAir}; +/// use television_derive::CliChannel; +/// use crate::channels::{files, text}; +/// +/// #[derive(CliChannel)] +/// enum TelevisionChannel { +/// Files(files::Channel), +/// Text(text::Channel), +/// // ... +/// } +/// +/// let television_channel: TelevisionChannel = CliTvChannel::Files.to_channel(); +/// +/// assert!(matches!(television_channel, TelevisionChannel::Files(_))); +/// ``` +/// +/// The `CliChannel` enum is used to select channels from the command line. #[proc_macro_derive(CliChannel)] pub fn cli_channel_derive(input: TokenStream) -> TokenStream { // Construct a representation of Rust code as a syntax tree @@ -11,7 +32,8 @@ pub fn cli_channel_derive(input: TokenStream) -> TokenStream { impl_cli_channel(&ast) } -const VARIANT_BLACKLIST: [&str; 2] = ["Stdin", "TvGuide"]; +/// List of variant names that should be ignored when generating the CliTvChannel enum. +const VARIANT_BLACKLIST: [&str; 2] = ["Stdin", "RemoteControl"]; fn impl_cli_channel(ast: &syn::DeriveInput) -> TokenStream { // check that the struct is an enum @@ -88,8 +110,34 @@ fn impl_cli_channel(ast: &syn::DeriveInput) -> TokenStream { gen.into() } -/// This macro generates the `OnAir` trait implementation for the -/// given enum. +/// This macro generates the `OnAir` trait implementation for the given enum. +/// +/// The `OnAir` trait is used to interact with the different television channels +/// and forwards the method calls to the corresponding channel variants. +/// +/// Example: +/// ```rust +/// use television_derive::Broadcast; +/// use crate::channels::{TelevisionChannel, OnAir}; +/// use crate::channels::{files, text}; +/// +/// #[derive(Broadcast)] +/// enum TelevisionChannel { +/// Files(files::Channel), +/// Text(text::Channel), +/// } +/// +/// let mut channel = TelevisionChannel::Files(files::Channel::default()); +/// +/// // Use the `OnAir` trait methods directly on TelevisionChannel +/// channel.find("pattern"); +/// let results = channel.results(10, 0); +/// let result = channel.get_result(0); +/// let result_count = channel.result_count(); +/// let total_count = channel.total_count(); +/// let running = channel.running(); +/// channel.shutdown(); +/// ``` #[proc_macro_derive(Broadcast)] pub fn tv_channel_derive(input: TokenStream) -> TokenStream { // Construct a representation of Rust code as a syntax tree @@ -196,6 +244,11 @@ fn impl_tv_channel(ast: &syn::DeriveInput) -> TokenStream { trait_impl.into() } +/// This macro generates a `UnitChannel` enum and the necessary glue code +/// to convert from and to a `TelevisionChannel` member. +/// +/// The `UnitChannel` enum is used as a unit variant of the `TelevisionChannel` +/// enum. #[proc_macro_derive(UnitChannel)] pub fn unit_channel_derive(input: TokenStream) -> TokenStream { // Construct a representation of Rust code as a syntax tree