diff --git a/.config/config.toml b/.config/config.toml index bbfd6dc..1b4e76a 100644 --- a/.config/config.toml +++ b/.config/config.toml @@ -202,4 +202,5 @@ toggle_preview = "ctrl-o" "smart_autocomplete" = "ctrl-t" # controls which keybinding should trigger tv # for command history -"command_history" = "ctrl-r" \ No newline at end of file +"command_history" = "ctrl-r" + diff --git a/benches/main.rs b/benches/main.rs index 8f47542..ea4670f 100644 --- a/benches/main.rs +++ b/benches/main.rs @@ -1,7 +1,7 @@ pub mod main { pub mod draw; - pub mod results_list_benchmark; + pub mod draw_results_list; } pub use main::*; -criterion::criterion_main!(results_list_benchmark::benches, draw::benches,); +criterion::criterion_main!(draw_results_list::benches, draw::benches,); diff --git a/benches/main/draw.rs b/benches/main/draw.rs index 238e272..ed7e7ee 100644 --- a/benches/main/draw.rs +++ b/benches/main/draw.rs @@ -3,6 +3,7 @@ use ratatui::backend::TestBackend; use ratatui::layout::Rect; use ratatui::Terminal; use std::path::PathBuf; +use television::action::Action; use television::channels::OnAir; use television::channels::{files::Channel, TelevisionChannel}; use television::config::Config; @@ -22,24 +23,30 @@ fn draw(c: &mut Criterion) { let config = Config::new().unwrap(); let backend = TestBackend::new(width, height); let terminal = Terminal::new(backend).unwrap(); + let (tx, _) = tokio::sync::mpsc::unbounded_channel(); let mut channel = TelevisionChannel::Files(Channel::new(vec![ PathBuf::from("."), ])); channel.find("television"); // Wait for the channel to finish loading + let mut tv = Television::new(tx, channel, config, None); for _ in 0..5 { // tick the matcher - let _ = channel.results(10, 0); + let _ = tv.channel.results(10, 0); std::thread::sleep(std::time::Duration::from_millis(10)); } - let mut tv = Television::new(channel, config, None); tv.select_next_entry(10); + let _ = tv.update_preview_state( + &tv.get_selected_entry(None).unwrap(), + ); + tv.update(&Action::Tick).unwrap(); (tv, terminal) }, // Measurement - |(mut tv, mut terminal)| async move { - tv.draw( + |(tv, mut terminal)| async move { + television::draw::draw( + black_box(&tv.dump_context()), black_box(&mut terminal.get_frame()), black_box(Rect::new(0, 0, width, height)), ) diff --git a/benches/main/results_list_benchmark.rs b/benches/main/draw_results_list.rs similarity index 98% rename from benches/main/results_list_benchmark.rs rename to benches/main/draw_results_list.rs index 88f5cf8..3ae2b72 100644 --- a/benches/main/results_list_benchmark.rs +++ b/benches/main/draw_results_list.rs @@ -4,14 +4,12 @@ use ratatui::layout::Alignment; use ratatui::prelude::{Line, Style}; use ratatui::style::Color; use ratatui::widgets::{Block, BorderType, Borders, ListDirection, Padding}; -use rustc_hash::FxHashMap; use television::channels::entry::merge_ranges; use television::channels::entry::{Entry, PreviewType}; use television::screen::colors::ResultsColorscheme; use television::screen::results::build_results_list; -pub fn results_list_benchmark(c: &mut Criterion) { - let mut icon_color_cache = FxHashMap::default(); +pub fn draw_results_list(c: &mut Criterion) { // FIXME: there's probably a way to have this as a benchmark asset // possible as a JSON file and to load it for the benchmark using Serde // I don't know how exactly right now just having it here instead @@ -656,11 +654,10 @@ pub fn results_list_benchmark(c: &mut Criterion) { None, ListDirection::BottomToTop, false, - &mut icon_color_cache, &colorscheme, ); }); }); } -criterion_group!(benches, results_list_benchmark); +criterion_group!(benches, draw_results_list); diff --git a/television-derive/src/lib.rs b/television-derive/src/lib.rs index 394dcee..4d1b7c3 100644 --- a/television-derive/src/lib.rs +++ b/television-derive/src/lib.rs @@ -323,7 +323,7 @@ fn impl_unit_channel(ast: &syn::DeriveInput) -> TokenStream { // Generate a unit enum from the given enum let unit_enum = quote! { - #[derive(Debug, Clone, Copy, PartialEq, Eq, Display, EnumString)] + #[derive(Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Hash)] #[strum(serialize_all = "kebab_case")] pub enum UnitChannel { #( diff --git a/television/app.rs b/television/app.rs index 8077d21..4cf269d 100644 --- a/television/app.rs +++ b/television/app.rs @@ -1,16 +1,14 @@ use rustc_hash::FxHashSet; -use std::sync::Arc; -use crate::screen::mode::Mode; use anyhow::Result; -use tokio::sync::{mpsc, Mutex}; -use tracing::{debug, info}; +use tokio::sync::mpsc; +use tracing::{debug, info, trace}; use crate::channels::entry::Entry; use crate::channels::TelevisionChannel; use crate::config::{parse_key, Config}; use crate::keymap::Keymap; -use crate::television::Television; +use crate::television::{Mode, Television}; use crate::{ action::Action, event::{Event, EventLoop, Key}, @@ -23,9 +21,8 @@ pub struct App { // maybe move these two into config instead of passing them // via the cli? tick_rate: f64, - frame_rate: f64, /// The television instance that handles channels and entries. - television: Arc>, + television: Television, /// A flag that indicates whether the application should quit during the next frame. should_quit: bool, /// A flag that indicates whether the application should suspend during the next frame. @@ -92,7 +89,6 @@ impl App { let (render_tx, _) = mpsc::unbounded_channel(); let (_, event_rx) = mpsc::unbounded_channel(); let (event_abort_tx, _) = mpsc::unbounded_channel(); - let frame_rate = config.config.frame_rate; let tick_rate = config.config.tick_rate; let keymap = Keymap::from(&config.keybindings).with_mode_mappings( Mode::Channel, @@ -106,12 +102,11 @@ impl App { )?; debug!("{:?}", keymap); let television = - Arc::new(Mutex::new(Television::new(channel, config, input))); + Television::new(action_tx.clone(), channel, config, input); Ok(Self { keymap, tick_rate, - frame_rate, television, should_quit: false, should_suspend: false, @@ -148,19 +143,10 @@ impl App { let (render_tx, render_rx) = mpsc::unbounded_channel(); self.render_tx = render_tx.clone(); let action_tx_r = self.action_tx.clone(); - let television_r = self.television.clone(); - let frame_rate = self.frame_rate; let rendering_task = tokio::spawn(async move { - render( - render_rx, - action_tx_r, - television_r, - frame_rate, - is_output_tty, - ) - .await + render(render_rx, action_tx_r, is_output_tty).await }); - render_tx.send(RenderingTask::Render)?; + self.action_tx.send(Action::Render)?; // event handling loop debug!("Starting event handling loop"); @@ -168,14 +154,11 @@ impl App { loop { // handle event and convert to action if let Some(event) = self.event_rx.recv().await { - let action = self.convert_event_to_action(event).await; + let action = self.convert_event_to_action(event); action_tx.send(action)?; - // it's fine to send a rendering task here, because the rendering loop - // batches and deduplicates rendering tasks - render_tx.send(RenderingTask::Render)?; } - let action_outcome = self.handle_actions().await?; + let action_outcome = self.handle_actions()?; if self.should_quit { // send a termination signal to the event loop @@ -199,7 +182,7 @@ impl App { /// /// # Returns /// The action that corresponds to the given event. - async fn convert_event_to_action(&self, event: Event) -> Action { + fn convert_event_to_action(&self, event: Event) -> Action { match event { Event::Input(keycode) => { info!("{:?}", keycode); @@ -219,7 +202,7 @@ impl App { } // get action based on keybindings self.keymap - .get(&self.television.lock().await.mode) + .get(&self.television.mode) .and_then(|keymap| keymap.get(&keycode).cloned()) .unwrap_or(if let Key::Char(c) = keycode { Action::AddInputChar(c) @@ -246,10 +229,10 @@ impl App { /// /// # Errors /// If an error occurs during the execution of the application. - async fn handle_actions(&mut self) -> Result { + fn handle_actions(&mut self) -> Result { while let Ok(action) = self.action_rx.try_recv() { - if action != Action::Tick && action != Action::Render { - debug!("{action:?}"); + if action != Action::Tick { + trace!("{action:?}"); } match action { Action::Quit => { @@ -269,15 +252,13 @@ impl App { self.render_tx.send(RenderingTask::Quit)?; if let Some(entries) = self .television - .lock() - .await .get_selected_entries(Some(Mode::Channel)) { return Ok(ActionOutcome::Entries(entries)); } return Ok(ActionOutcome::Input( - self.television.lock().await.current_pattern.clone(), + self.television.current_pattern.clone(), )); } Action::SelectPassthrough(passthrough) => { @@ -285,8 +266,6 @@ impl App { self.render_tx.send(RenderingTask::Quit)?; if let Some(entries) = self .television - .lock() - .await .get_selected_entries(Some(Mode::Channel)) { return Ok(ActionOutcome::Passthrough( @@ -303,14 +282,14 @@ impl App { self.render_tx.send(RenderingTask::Resize(w, h))?; } Action::Render => { - self.render_tx.send(RenderingTask::Render)?; + self.render_tx.send(RenderingTask::Render( + self.television.dump_context(), + ))?; } _ => {} } // forward action to the television handler - if let Some(action) = - self.television.lock().await.update(action.clone()).await? - { + if let Some(action) = self.television.update(&action)? { self.action_tx.send(action)?; }; } diff --git a/television/channels/text.rs b/television/channels/text.rs index 303249d..97024df 100644 --- a/television/channels/text.rs +++ b/television/channels/text.rs @@ -264,8 +264,8 @@ const MAX_FILE_SIZE: u64 = 4 * 1024 * 1024; /// This is a soft limit, we might go over it a bit. /// /// A typical line should take somewhere around 100 bytes in memory (for utf8 english text), -/// so this should take around 100 x `5_000_000` = 500MB of memory. -const MAX_LINES_IN_MEM: usize = 5_000_000; +/// so this should take around 100 x `10_000_000` = 1GB of memory. +const MAX_LINES_IN_MEM: usize = 10_000_000; #[allow(clippy::unused_async)] async fn crawl_for_candidates( diff --git a/television/config/keybindings.rs b/television/config/keybindings.rs index 140eb3d..951f067 100644 --- a/television/config/keybindings.rs +++ b/television/config/keybindings.rs @@ -1,13 +1,14 @@ use crate::action::Action; use crate::event::{convert_raw_event_to_key, Key}; -use crate::screen::mode::Mode; +use crate::television::Mode; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use rustc_hash::FxHashMap; use serde::{Deserialize, Deserializer}; use std::fmt::Display; +use std::hash::Hash; use std::ops::{Deref, DerefMut}; -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, PartialEq, Hash)] pub enum Binding { SingleKey(Key), MultipleKeys(Vec), @@ -28,9 +29,16 @@ impl Display for Binding { } } -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug, Default, PartialEq)] pub struct KeyBindings(pub FxHashMap>); +impl Hash for KeyBindings { + fn hash(&self, state: &mut H) { + // we're not actually using this for hashing, so this really only is a placeholder + state.write_u8(0); + } +} + impl Deref for KeyBindings { type Target = FxHashMap>; fn deref(&self) -> &Self::Target { diff --git a/television/config/mod.rs b/television/config/mod.rs index 39d553f..2b71823 100644 --- a/television/config/mod.rs +++ b/television/config/mod.rs @@ -1,5 +1,5 @@ #![allow(clippy::module_name_repetitions, clippy::ref_option)] -use std::{env, path::PathBuf}; +use std::{env, hash::Hash, path::PathBuf}; use anyhow::Result; use directories::ProjectDirs; @@ -11,7 +11,7 @@ use serde::Deserialize; use shell_integration::ShellIntegrationConfig; pub use themes::Theme; use tracing::{debug, warn}; -use ui::UiConfig; +pub use ui::UiConfig; mod keybindings; mod previewers; @@ -22,7 +22,7 @@ mod ui; const DEFAULT_CONFIG: &str = include_str!("../../.config/config.toml"); #[allow(dead_code, clippy::module_name_repetitions)] -#[derive(Clone, Debug, Deserialize, Default)] +#[derive(Clone, Debug, Deserialize, Default, PartialEq)] #[serde(deny_unknown_fields)] pub struct AppConfig { #[serde(default = "get_data_dir")] @@ -35,8 +35,17 @@ pub struct AppConfig { pub tick_rate: f64, } +impl Hash for AppConfig { + fn hash(&self, state: &mut H) { + self.data_dir.hash(state); + self.config_dir.hash(state); + self.frame_rate.to_bits().hash(state); + self.tick_rate.to_bits().hash(state); + } +} + #[allow(dead_code)] -#[derive(Clone, Debug, Deserialize, Default)] +#[derive(Clone, Debug, Deserialize, Default, PartialEq, Hash)] #[serde(deny_unknown_fields)] pub struct Config { /// General application configuration @@ -77,7 +86,6 @@ lazy_static! { const CONFIG_FILE_NAME: &str = "config.toml"; impl Config { - // FIXME: default management is a bit of a mess right now #[allow(clippy::missing_panics_doc, clippy::missing_errors_doc)] pub fn new() -> Result { // Load the default_config values as base defaults diff --git a/television/config/previewers.rs b/television/config/previewers.rs index e4767ba..6bab0e6 100644 --- a/television/config/previewers.rs +++ b/television/config/previewers.rs @@ -1,7 +1,7 @@ use crate::preview::{previewers, PreviewerConfig}; use serde::Deserialize; -#[derive(Clone, Debug, Deserialize, Default)] +#[derive(Clone, Debug, Deserialize, Default, PartialEq, Hash)] pub struct PreviewersConfig { #[serde(default)] pub basic: BasicPreviewerConfig, @@ -17,10 +17,10 @@ impl From for PreviewerConfig { } } -#[derive(Clone, Debug, Deserialize, Default)] +#[derive(Clone, Debug, Deserialize, Default, PartialEq, Hash)] pub struct BasicPreviewerConfig {} -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, PartialEq, Hash)] #[serde(default)] pub struct FilePreviewerConfig { //pub max_file_size: u64, @@ -36,5 +36,5 @@ impl Default for FilePreviewerConfig { } } -#[derive(Clone, Debug, Deserialize, Default)] +#[derive(Clone, Debug, Deserialize, Default, PartialEq, Hash)] pub struct EnvVarPreviewerConfig {} diff --git a/television/config/shell_integration.rs b/television/config/shell_integration.rs index e5d4ad6..0820fca 100644 --- a/television/config/shell_integration.rs +++ b/television/config/shell_integration.rs @@ -1,14 +1,24 @@ +use std::hash::Hash; + use crate::config::parse_key; use crate::event::Key; use rustc_hash::FxHashMap; use serde::Deserialize; -#[derive(Clone, Debug, Deserialize, Default)] +#[derive(Clone, Debug, Deserialize, Default, PartialEq)] #[serde(default)] pub struct ShellIntegrationConfig { pub commands: FxHashMap, pub keybindings: FxHashMap, } + +impl Hash for ShellIntegrationConfig { + fn hash(&self, state: &mut H) { + // we're not actually using this for hashing, so this really only is a placeholder + state.write_u8(0); + } +} + const SMART_AUTOCOMPLETE_CONFIGURATION_KEY: &str = "smart_autocomplete"; const COMMAND_HISTORY_CONFIGURATION_KEY: &str = "command_history"; const DEFAULT_SHELL_AUTOCOMPLETE_KEY: char = 'T'; diff --git a/television/config/ui.rs b/television/config/ui.rs index 75bc3b6..a9e08f2 100644 --- a/television/config/ui.rs +++ b/television/config/ui.rs @@ -6,7 +6,7 @@ use super::themes::DEFAULT_THEME; const DEFAULT_UI_SCALE: u16 = 100; -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, PartialEq, Hash)] #[serde(default)] pub struct UiConfig { pub use_nerd_font_icons: bool, diff --git a/television/draw.rs b/television/draw.rs new file mode 100644 index 0000000..ca5f5bd --- /dev/null +++ b/television/draw.rs @@ -0,0 +1,265 @@ +use std::{hash::Hash, time::Instant}; + +use anyhow::Result; +use ratatui::{layout::Rect, Frame}; +use rustc_hash::FxHashSet; +use tokio::sync::mpsc::Sender; + +use crate::{ + action::Action, + channels::{ + entry::{Entry, PreviewType, ENTRY_PLACEHOLDER}, + UnitChannel, + }, + config::Config, + picker::Picker, + preview::PreviewState, + screen::{ + colors::Colorscheme, help::draw_help_bar, input::draw_input_box, + keybindings::build_keybindings_table, layout::Layout, + preview::draw_preview_content_block, + remote_control::draw_remote_control, results::draw_results_list, + spinner::Spinner, + }, + television::{Message, Mode}, + utils::metadata::AppMetadata, +}; + +#[derive(Debug, Clone, PartialEq)] +pub struct ChannelState { + pub current_channel: UnitChannel, + pub selected_entries: FxHashSet, + pub total_count: u32, + pub running: bool, +} + +impl ChannelState { + pub fn new( + current_channel: UnitChannel, + selected_entries: FxHashSet, + total_count: u32, + running: bool, + ) -> Self { + Self { + current_channel, + selected_entries, + total_count, + running, + } + } +} + +impl Hash for ChannelState { + fn hash(&self, state: &mut H) { + self.current_channel.hash(state); + self.selected_entries + .iter() + .for_each(|entry| entry.hash(state)); + self.total_count.hash(state); + self.running.hash(state); + } +} + +#[derive(Debug, Clone, PartialEq, Hash)] +pub struct TvState { + pub mode: Mode, + pub selected_entry: Option, + pub results_area_height: u16, + pub results_picker: Picker, + pub rc_picker: Picker, + pub channel_state: ChannelState, + pub spinner: Spinner, + pub preview_state: PreviewState, +} + +impl TvState { + #[allow(clippy::too_many_arguments)] + pub fn new( + mode: Mode, + selected_entry: Option, + results_area_height: u16, + results_picker: Picker, + rc_picker: Picker, + channel_state: ChannelState, + spinner: Spinner, + preview_state: PreviewState, + ) -> Self { + Self { + mode, + selected_entry, + results_area_height, + results_picker, + rc_picker, + channel_state, + spinner, + preview_state, + } + } +} + +#[derive(Debug, Clone)] +pub struct Ctx { + pub tv_state: TvState, + pub config: Config, + pub colorscheme: Colorscheme, + pub app_metadata: AppMetadata, + pub tv_tx_handle: Sender, + pub instant: Instant, +} + +impl Ctx { + pub fn new( + tv_state: TvState, + config: Config, + colorscheme: Colorscheme, + app_metadata: AppMetadata, + tv_tx_handle: Sender, + instant: Instant, + ) -> Self { + Self { + tv_state, + config, + colorscheme, + app_metadata, + tv_tx_handle, + instant, + } + } +} + +impl PartialEq for Ctx { + fn eq(&self, other: &Self) -> bool { + self.tv_state == other.tv_state + && self.config == other.config + && self.colorscheme == other.colorscheme + && self.app_metadata == other.app_metadata + } +} + +impl Eq for Ctx {} + +impl Hash for Ctx { + fn hash(&self, state: &mut H) { + self.tv_state.hash(state); + self.config.hash(state); + self.colorscheme.hash(state); + self.app_metadata.hash(state); + } +} + +impl PartialOrd for Ctx { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.instant.cmp(&other.instant)) + } +} + +impl Ord for Ctx { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.instant.cmp(&other.instant) + } +} + +pub fn draw(ctx: &Ctx, f: &mut Frame<'_>, area: Rect) -> Result<()> { + let selected_entry = ctx + .tv_state + .selected_entry + .clone() + .unwrap_or(ENTRY_PLACEHOLDER); + + let show_preview = ctx.config.ui.show_preview_panel + && !matches!(selected_entry.preview_type, PreviewType::None); + let show_remote = !matches!(ctx.tv_state.mode, Mode::Channel); + + let layout = + Layout::build(area, &ctx.config.ui, show_remote, show_preview); + + // help bar (metadata, keymaps, logo) + draw_help_bar( + f, + &layout.help_bar, + ctx.tv_state.channel_state.current_channel, + build_keybindings_table( + &ctx.config.keybindings.to_displayable(), + ctx.tv_state.mode, + &ctx.colorscheme, + ), + ctx.tv_state.mode, + &ctx.app_metadata, + &ctx.colorscheme, + ); + + if layout.results.height.saturating_sub(2) + != ctx.tv_state.results_area_height + { + ctx.tv_tx_handle.try_send(Message::ResultListHeightChanged( + layout.results.height.saturating_sub(2), + ))?; + } + + // results list + draw_results_list( + f, + layout.results, + &ctx.tv_state.results_picker.entries, + &ctx.tv_state.channel_state.selected_entries, + &mut ctx.tv_state.results_picker.relative_state.clone(), + ctx.config.ui.input_bar_position, + ctx.config.ui.use_nerd_font_icons, + &ctx.colorscheme, + &ctx.config + .keybindings + .get(&ctx.tv_state.mode) + .unwrap() + .get(&Action::ToggleHelp) + // just display the first keybinding + .unwrap() + .to_string(), + &ctx.config + .keybindings + .get(&ctx.tv_state.mode) + .unwrap() + .get(&Action::TogglePreview) + // just display the first keybinding + .unwrap() + .to_string(), + )?; + + // input box + draw_input_box( + f, + layout.input, + ctx.tv_state.results_picker.total_items, + ctx.tv_state.channel_state.total_count, + &ctx.tv_state.results_picker.input, + &ctx.tv_state.results_picker.state, + ctx.tv_state.channel_state.running, + &ctx.tv_state.spinner, + &ctx.colorscheme, + )?; + + if show_preview { + draw_preview_content_block( + f, + layout.preview_window.unwrap(), + &ctx.tv_state.preview_state, + ctx.config.ui.use_nerd_font_icons, + &ctx.colorscheme, + )?; + } + + // remote control + if show_remote { + draw_remote_control( + f, + layout.remote_control.unwrap(), + &ctx.tv_state.rc_picker.entries, + ctx.config.ui.use_nerd_font_icons, + &mut ctx.tv_state.rc_picker.state.clone(), + &mut ctx.tv_state.rc_picker.input.clone(), + &ctx.tv_state.mode, + &ctx.colorscheme, + )?; + } + + Ok(()) +} diff --git a/television/event.rs b/television/event.rs index faa2858..91e8abb 100644 --- a/television/event.rs +++ b/television/event.rs @@ -195,7 +195,8 @@ impl EventLoop { tx.send(Event::FocusGained).unwrap_or_else(|_| warn!("Unable to send FocusGained event")); }, Ok(crossterm::event::Event::Resize(x, y)) => { - tx.send(Event::Resize(x, y)).unwrap_or_else(|_| warn!("Unable to send Resize event")); + let (_, (new_x, new_y)) = flush_resize_events((x, y)); + tx.send(Event::Resize(new_x, new_y)).unwrap_or_else(|_| warn!("Unable to send Resize event")); }, _ => {} } @@ -214,6 +215,22 @@ impl EventLoop { } } +// Resize events can occur in batches. +// With a simple loop they can be flushed. +// This function will keep the first and last resize event. +fn flush_resize_events(first_resize: (u16, u16)) -> ((u16, u16), (u16, u16)) { + let mut last_resize = first_resize; + while let Ok(true) = crossterm::event::poll(Duration::from_millis(50)) { + if let Ok(crossterm::event::Event::Resize(x, y)) = + crossterm::event::read() + { + last_resize = (x, y); + } + } + + (first_resize, last_resize) +} + pub fn convert_raw_event_to_key(event: KeyEvent) -> Key { debug!("Raw event: {:?}", event); if event.kind == KeyEventKind::Release { diff --git a/television/keymap.rs b/television/keymap.rs index d660dd1..47125da 100644 --- a/television/keymap.rs +++ b/television/keymap.rs @@ -1,7 +1,7 @@ use rustc_hash::FxHashMap; use std::ops::Deref; -use crate::screen::mode::Mode; +use crate::television::Mode; use anyhow::Result; use crate::action::Action; diff --git a/television/lib.rs b/television/lib.rs index 17f710f..4b2e7f9 100644 --- a/television/lib.rs +++ b/television/lib.rs @@ -4,6 +4,7 @@ pub mod cable; pub mod channels; pub mod cli; pub mod config; +pub mod draw; pub mod errors; pub mod event; pub mod input; diff --git a/television/main.rs b/television/main.rs index d7bc2e0..01ac4ac 100644 --- a/television/main.rs +++ b/television/main.rs @@ -57,8 +57,6 @@ async fn main() -> Result<()> { config.config.tick_rate = args.tick_rate.unwrap_or(config.config.tick_rate); - config.config.frame_rate = - args.frame_rate.unwrap_or(config.config.frame_rate); if args.no_preview { config.ui.show_preview_panel = false; } diff --git a/television/picker.rs b/television/picker.rs index aa89d1b..6e69795 100644 --- a/television/picker.rs +++ b/television/picker.rs @@ -1,12 +1,17 @@ -use crate::utils::{input::Input, strings::EMPTY_STRING}; +use crate::{ + channels::entry::Entry, + utils::{input::Input, strings::EMPTY_STRING}, +}; use ratatui::widgets::ListState; -#[derive(Debug)] +#[derive(Debug, Clone, PartialEq, Hash)] pub struct Picker { pub(crate) state: ListState, pub(crate) relative_state: ListState, inverted: bool, pub(crate) input: Input, + pub entries: Vec, + pub total_items: u32, } impl Default for Picker { @@ -22,6 +27,8 @@ impl Picker { relative_state: ListState::default(), inverted: false, input: Input::new(input.unwrap_or(EMPTY_STRING.to_string())), + entries: Vec::new(), + total_items: 0, } } @@ -99,7 +106,7 @@ impl Picker { let selected = self.selected().unwrap_or(0); let relative_selected = self.relative_selected().unwrap_or(0); self.select(Some(selected.saturating_add(1) % total_items)); - self.relative_select(Some((relative_selected + 1).min(height))); + self.relative_select(Some((relative_selected + 1).min(height - 1))); if self.selected().unwrap() == 0 { self.relative_select(Some(0)); } @@ -111,7 +118,7 @@ impl Picker { self.select(Some((selected + (total_items - 1)) % total_items)); self.relative_select(Some(relative_selected.saturating_sub(1))); if self.selected().unwrap() == total_items - 1 { - self.relative_select(Some(height)); + self.relative_select(Some((height - 1).min(total_items - 1))); } } } @@ -129,7 +136,7 @@ mod tests { let mut picker = Picker::default(); picker.select(Some(0)); picker.relative_select(Some(0)); - picker.select_next(1, 4, 2); + picker.select_next(1, 4, 3); assert_eq!(picker.selected(), Some(1), "selected"); assert_eq!(picker.relative_selected(), Some(1), "relative_selected"); } @@ -143,7 +150,7 @@ mod tests { let mut picker = Picker::default(); picker.select(Some(1)); picker.relative_select(Some(1)); - picker.select_next(1, 4, 2); + picker.select_next(1, 4, 3); assert_eq!(picker.selected(), Some(2), "selected"); assert_eq!(picker.relative_selected(), Some(2), "relative_selected"); } @@ -157,7 +164,7 @@ mod tests { let mut picker = Picker::default(); picker.select(Some(2)); picker.relative_select(Some(2)); - picker.select_next(1, 4, 2); + picker.select_next(1, 4, 3); assert_eq!(picker.selected(), Some(3), "selected"); assert_eq!(picker.relative_selected(), Some(2), "relative_selected"); } @@ -171,7 +178,7 @@ mod tests { let mut picker = Picker::default(); picker.select(Some(3)); picker.relative_select(Some(2)); - picker.select_next(1, 4, 2); + picker.select_next(1, 4, 3); assert_eq!(picker.selected(), Some(0), "selected"); assert_eq!(picker.relative_selected(), Some(0), "relative_selected"); } @@ -185,7 +192,7 @@ mod tests { let mut picker = Picker::default(); picker.select(Some(2)); picker.relative_select(Some(2)); - picker.select_next(1, 3, 2); + picker.select_next(1, 3, 4); assert_eq!(picker.selected(), Some(0), "selected"); assert_eq!(picker.relative_selected(), Some(0), "relative_selected"); } @@ -199,7 +206,7 @@ mod tests { let mut picker = Picker::default(); picker.select(Some(1)); picker.relative_select(Some(1)); - picker.select_prev(1, 4, 2); + picker.select_prev(1, 4, 3); assert_eq!(picker.selected(), Some(0), "selected"); assert_eq!(picker.relative_selected(), Some(0), "relative_selected"); } @@ -213,7 +220,7 @@ mod tests { let mut picker = Picker::default(); picker.select(Some(0)); picker.relative_select(Some(0)); - picker.select_prev(1, 4, 2); + picker.select_prev(1, 4, 3); assert_eq!(picker.selected(), Some(3), "selected"); assert_eq!(picker.relative_selected(), Some(2), "relative_selected"); } @@ -227,7 +234,7 @@ mod tests { let mut picker = Picker::default(); picker.select(Some(3)); picker.relative_select(Some(2)); - picker.select_prev(1, 4, 2); + picker.select_prev(1, 4, 3); assert_eq!(picker.selected(), Some(2), "selected"); assert_eq!(picker.relative_selected(), Some(1), "relative_selected"); } @@ -241,7 +248,7 @@ mod tests { let mut picker = Picker::default(); picker.select(Some(2)); picker.relative_select(Some(2)); - picker.select_prev(1, 4, 2); + picker.select_prev(1, 4, 3); assert_eq!(picker.selected(), Some(1), "selected"); assert_eq!(picker.relative_selected(), Some(1), "relative_selected"); } diff --git a/television/preview/mod.rs b/television/preview/mod.rs index ad9342b..8bca931 100644 --- a/television/preview/mod.rs +++ b/television/preview/mod.rs @@ -19,7 +19,7 @@ pub use previewers::env::EnvVarPreviewerConfig; pub use previewers::files::FilePreviewer; pub use previewers::files::FilePreviewerConfig; -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq, Hash)] pub enum PreviewContent { Empty, FileTooLarge, @@ -60,7 +60,7 @@ pub const TIMEOUT_MSG: &str = "Preview timed out"; /// # Fields /// - `title`: The title of the preview. /// - `content`: The content of the preview. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq, Hash)] pub struct Preview { pub title: String, pub content: PreviewContent, @@ -99,19 +99,58 @@ impl Preview { total_lines, } } +} - pub fn total_lines(&self) -> u16 { - match &self.content { - PreviewContent::SyntectHighlightedText(hl_lines) => { - hl_lines.lines.len().try_into().unwrap_or(u16::MAX) - } - PreviewContent::PlainText(lines) => { - lines.len().try_into().unwrap_or(u16::MAX) - } - PreviewContent::AnsiText(text) => { - text.lines().count().try_into().unwrap_or(u16::MAX) - } - _ => 0, +#[derive(Debug, Default, Clone, PartialEq, Hash)] +pub struct PreviewState { + pub preview: Arc, + pub scroll: u16, + pub target_line: Option, +} + +const PREVIEW_MIN_SCROLL_LINES: u16 = 3; + +impl PreviewState { + pub fn new( + preview: Arc, + scroll: u16, + target_line: Option, + ) -> Self { + PreviewState { + preview, + scroll, + target_line, + } + } + + pub fn scroll_down(&mut self, offset: u16) { + self.scroll = self.scroll.saturating_add(offset).min( + self.preview + .total_lines + .saturating_sub(PREVIEW_MIN_SCROLL_LINES), + ); + } + + pub fn scroll_up(&mut self, offset: u16) { + self.scroll = self.scroll.saturating_sub(offset); + } + + pub fn reset(&mut self) { + self.preview = Arc::new(Preview::default()); + self.scroll = 0; + self.target_line = None; + } + + pub fn update( + &mut self, + preview: Arc, + scroll: u16, + target_line: Option, + ) { + if self.preview.title != preview.title { + self.preview = preview; + self.scroll = scroll; + self.target_line = target_line; } } } @@ -150,7 +189,7 @@ impl PreviewerConfig { } } -const REQUEST_STACK_SIZE: usize = 20; +const REQUEST_STACK_SIZE: usize = 10; impl Previewer { pub fn new(config: Option) -> Self { @@ -174,15 +213,10 @@ impl Previewer { } } - fn cached(&self, entry: &Entry) -> Option> { - match &entry.preview_type { - PreviewType::Files => self.file.cached(entry), - PreviewType::Command(_) => self.command.cached(entry), - PreviewType::Basic | PreviewType::EnvVar => None, - PreviewType::None => Some(Arc::new(Preview::default())), - } - } - + // we could use a target scroll here to make the previewer + // faster, but since it's already running in the background and quite + // fast for most standard file sizes, plus we're caching the previews, + // I'm not sure the extra complexity is worth it. pub fn preview(&mut self, entry: &Entry) -> Option> { // if we haven't acknowledged the request yet, acknowledge it self.requests.push(entry.clone()); @@ -190,9 +224,10 @@ impl Previewer { if let Some(preview) = self.dispatch_request(entry) { return Some(preview); } + // lookup request stack and return the most recent preview available for request in self.requests.back_to_front() { - if let Some(preview) = self.cached(&request) { + if let Some(preview) = self.dispatch_request(&request) { return Some(preview); } } diff --git a/television/preview/previewers/files.rs b/television/preview/previewers/files.rs index fb6a828..32e7c92 100644 --- a/television/preview/previewers/files.rs +++ b/television/preview/previewers/files.rs @@ -12,7 +12,7 @@ use std::sync::{ }; use syntect::{highlighting::Theme, parsing::SyntaxSet}; -use tracing::{debug, warn}; +use tracing::{debug, trace, warn}; use crate::channels::entry; use crate::preview::cache::PreviewCache; @@ -80,7 +80,7 @@ impl FilePreviewer { pub fn preview(&mut self, entry: &entry::Entry) -> Option> { if let Some(preview) = self.cached(entry) { - debug!("Preview cache hit for {:?}", entry.name); + trace!("Preview cache hit for {:?}", entry.name); if preview.partial_offset.is_some() { // preview is partial, spawn a task to compute the next chunk // and return the partial preview @@ -90,7 +90,7 @@ impl FilePreviewer { Some(preview) } else { // preview is not in cache, spawn a task to compute the preview - debug!("Preview cache miss for {:?}", entry.name); + trace!("Preview cache miss for {:?}", entry.name); self.handle_preview_request(entry, None); None } @@ -102,7 +102,7 @@ impl FilePreviewer { partial_preview: Option>, ) { if self.in_flight_previews.lock().contains(&entry.name) { - debug!("Preview already in flight for {:?}", entry.name); + trace!("Preview already in flight for {:?}", entry.name); } if self.concurrent_preview_tasks.load(Ordering::Relaxed) @@ -139,7 +139,7 @@ impl FilePreviewer { /// The size of the buffer used to read the file in bytes. /// This ends up being the max size of partial previews. -const PARTIAL_BUFREAD_SIZE: usize = 64 * 1024; +const PARTIAL_BUFREAD_SIZE: usize = 5 * 1024 * 1024; pub fn try_preview( entry: &entry::Entry, diff --git a/television/render.rs b/television/render.rs index 6d4f448..036175e 100644 --- a/television/render.rs +++ b/television/render.rs @@ -1,20 +1,19 @@ use anyhow::Result; +use crossterm::terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate}; +use crossterm::{execute, queue}; use ratatui::layout::Rect; -use std::{ - io::{stderr, stdout, LineWriter}, - sync::Arc, -}; +use std::io::{stderr, stdout, LineWriter}; use tracing::{debug, warn}; -use tokio::sync::{mpsc, Mutex}; +use tokio::sync::mpsc; -use crate::television::Television; -use crate::{action::Action, tui::Tui}; +use crate::draw::Ctx; +use crate::{action::Action, draw::draw, tui::Tui}; #[derive(Debug, PartialEq, PartialOrd, Ord, Eq, Clone)] pub enum RenderingTask { ClearScreen, - Render, + Render(Ctx), Resize(u16, u16), Resume, Suspend, @@ -39,8 +38,6 @@ impl IoStream { pub async fn render( mut render_rx: mpsc::UnboundedReceiver, action_tx: mpsc::UnboundedSender, - television: Arc>, - frame_rate: f64, is_output_tty: bool, ) -> Result<()> { let stream = if is_output_tty { @@ -50,21 +47,15 @@ pub async fn render( debug!("Rendering to stderr"); IoStream::BufferedStderr.to_stream() }; - let mut tui = Tui::new(stream)?.frame_rate(frame_rate); + let mut tui = Tui::new(stream)?; debug!("Entering tui"); tui.enter()?; - debug!("Registering action handler"); - television - .lock() - .await - .register_action_handler(action_tx.clone())?; - - let mut buffer = Vec::with_capacity(128); + let mut buffer = Vec::with_capacity(256); // Rendering loop - 'rendering: while render_rx.recv_many(&mut buffer, 128).await > 0 { + 'rendering: while render_rx.recv_many(&mut buffer, 256).await > 0 { // deduplicate events buffer.sort_unstable(); buffer.dedup(); @@ -73,17 +64,17 @@ pub async fn render( RenderingTask::ClearScreen => { tui.terminal.clear()?; } - RenderingTask::Render => { + RenderingTask::Render(context) => { if let Ok(size) = tui.size() { // Ratatui uses `u16`s to encode terminal dimensions and its // content for each terminal cell is stored linearly in a // buffer with a `u16` index which means we can't support // terminal areas larger than `u16::MAX`. if size.width.checked_mul(size.height).is_some() { - let mut television = television.lock().await; + queue!(stderr(), BeginSynchronizedUpdate).ok(); tui.terminal.draw(|frame| { if let Err(err) = - television.draw(frame, frame.area()) + draw(&context, frame, frame.area()) { warn!("Failed to draw: {:?}", err); let _ = action_tx.send(Action::Error( @@ -91,6 +82,7 @@ pub async fn render( )); } })?; + execute!(stderr(), EndSynchronizedUpdate).ok(); } else { warn!("Terminal area too large"); } diff --git a/television/screen/colors.rs b/television/screen/colors.rs index 0314368..8ec2ae7 100644 --- a/television/screen/colors.rs +++ b/television/screen/colors.rs @@ -1,6 +1,6 @@ use ratatui::style::Color; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Colorscheme { pub general: GeneralColorscheme, pub help: HelpColorscheme, @@ -10,19 +10,19 @@ pub struct Colorscheme { pub mode: ModeColorscheme, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct GeneralColorscheme { pub border_fg: Color, pub background: Option, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct HelpColorscheme { pub metadata_field_name_fg: Color, pub metadata_field_value_fg: Color, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct ResultsColorscheme { pub result_name_fg: Color, pub result_preview_fg: Color, @@ -32,7 +32,7 @@ pub struct ResultsColorscheme { pub match_foreground_color: Color, } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct PreviewColorscheme { pub title_fg: Color, pub highlight_bg: Color, @@ -41,13 +41,13 @@ pub struct PreviewColorscheme { pub gutter_selected_fg: Color, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct InputColorscheme { pub input_fg: Color, pub results_count_fg: Color, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct ModeColorscheme { pub channel: Color, pub remote_control: Color, diff --git a/television/screen/help.rs b/television/screen/help.rs index cc7b6ab..3216097 100644 --- a/television/screen/help.rs +++ b/television/screen/help.rs @@ -3,7 +3,8 @@ use crate::channels::UnitChannel; use crate::screen::colors::{Colorscheme, GeneralColorscheme}; use crate::screen::logo::build_logo_paragraph; use crate::screen::metadata::build_metadata_table; -use crate::screen::mode::{mode_color, Mode}; +use crate::screen::mode::mode_color; +use crate::television::Mode; use crate::utils::metadata::AppMetadata; use ratatui::layout::Rect; use ratatui::prelude::{Color, Style}; diff --git a/television/screen/input.rs b/television/screen/input.rs index b7db944..5c10f99 100644 --- a/television/screen/input.rs +++ b/television/screen/input.rs @@ -10,10 +10,7 @@ use ratatui::{ Frame, }; -use crate::screen::{ - colors::Colorscheme, - spinner::{Spinner, SpinnerState}, -}; +use crate::screen::{colors::Colorscheme, spinner::Spinner}; // TODO: refactor arguments (e.g. use a struct for the spinner+state, same #[allow(clippy::too_many_arguments)] @@ -22,11 +19,10 @@ pub fn draw_input_box( rect: Rect, results_count: u32, total_count: u32, - input_state: &mut Input, - results_picker_state: &mut ListState, + input_state: &Input, + results_picker_state: &ListState, matcher_running: bool, spinner: &Spinner, - spinner_state: &mut SpinnerState, colorscheme: &Colorscheme, ) -> Result<()> { let input_block = Block::default() @@ -89,11 +85,7 @@ pub fn draw_input_box( f.render_widget(input, inner_input_chunks[1]); if matcher_running { - f.render_stateful_widget( - spinner, - inner_input_chunks[3], - spinner_state, - ); + f.render_widget(spinner, inner_input_chunks[3]); } let result_count_block = Block::default(); @@ -119,8 +111,9 @@ pub fn draw_input_box( // 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)?, + inner_input_chunks[1].x.saturating_add(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, )); diff --git a/television/screen/keybindings.rs b/television/screen/keybindings.rs index 6ece418..eb76b9c 100644 --- a/television/screen/keybindings.rs +++ b/television/screen/keybindings.rs @@ -1,7 +1,8 @@ use rustc_hash::FxHashMap; use std::fmt::Display; -use crate::screen::{colors::Colorscheme, mode::Mode}; +use crate::screen::colors::Colorscheme; +use crate::television::Mode; use ratatui::{ layout::Constraint, style::{Color, Style}, diff --git a/television/screen/layout.rs b/television/screen/layout.rs index 769ffd0..3cabc6d 100644 --- a/television/screen/layout.rs +++ b/television/screen/layout.rs @@ -4,6 +4,8 @@ use ratatui::layout; use ratatui::layout::{Constraint, Direction, Rect}; use serde::Deserialize; +use crate::config::UiConfig; + pub struct Dimensions { pub x: u16, pub y: u16, @@ -44,7 +46,7 @@ impl HelpBarLayout { } } -#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq)] +#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Hash)] pub enum InputPosition { #[serde(rename = "top")] Top, @@ -62,7 +64,7 @@ impl Display for InputPosition { } } -#[derive(Debug, Clone, Copy, Deserialize, Default)] +#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Hash)] pub enum PreviewTitlePosition { #[serde(rename = "top")] #[default] @@ -107,19 +109,20 @@ impl Layout { } pub fn build( - dimensions: &Dimensions, area: Rect, - with_remote: bool, - with_help_bar: bool, - with_preview: bool, - input_position: InputPosition, + ui_config: &UiConfig, + show_remote: bool, + show_preview: bool, + // ) -> Self { + let show_preview = show_preview && ui_config.show_preview_panel; + let dimensions = Dimensions::from(ui_config.ui_scale); let main_block = centered_rect(dimensions.x, dimensions.y, area); // split the main block into two vertical chunks (help bar + rest) let main_rect: Rect; let help_bar_layout: Option; - if with_help_bar { + if ui_config.show_help_bar { let hz_chunks = layout::Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Max(9), Constraint::Fill(1)]) @@ -152,10 +155,10 @@ impl Layout { // split the main block into 1, 2, or 3 vertical chunks // (results + preview + remote) let mut constraints = vec![Constraint::Fill(1)]; - if with_preview { + if show_preview { constraints.push(Constraint::Fill(1)); } - if with_remote { + if show_remote { // in order to fit with the help bar logo constraints.push(Constraint::Length(24)); } @@ -170,28 +173,28 @@ impl Layout { let left_chunks = layout::Layout::default() .direction(Direction::Vertical) - .constraints(match input_position { + .constraints(match ui_config.input_bar_position { InputPosition::Top => { results_constraints.into_iter().rev().collect() } InputPosition::Bottom => results_constraints, }) .split(vt_chunks[0]); - let (input, results) = match input_position { + let (input, results) = match ui_config.input_bar_position { InputPosition::Bottom => (left_chunks[1], left_chunks[0]), InputPosition::Top => (left_chunks[0], left_chunks[1]), }; // right block: preview title + preview let mut remote_idx = 1; - let preview_window = if with_preview { + let preview_window = if show_preview { remote_idx += 1; Some(vt_chunks[1]) } else { None }; - let remote_control = if with_remote { + let remote_control = if show_remote { Some(vt_chunks[remote_idx]) } else { None diff --git a/television/screen/metadata.rs b/television/screen/metadata.rs index 97b157f..8426df5 100644 --- a/television/screen/metadata.rs +++ b/television/screen/metadata.rs @@ -1,10 +1,8 @@ use std::fmt::Display; use crate::channels::UnitChannel; -use crate::screen::{ - colors::Colorscheme, - mode::{mode_color, Mode}, -}; +use crate::screen::{colors::Colorscheme, mode::mode_color}; +use crate::television::Mode; use crate::utils::metadata::AppMetadata; use ratatui::{ layout::Constraint, diff --git a/television/screen/mode.rs b/television/screen/mode.rs index 2b80d45..439b287 100644 --- a/television/screen/mode.rs +++ b/television/screen/mode.rs @@ -1,7 +1,6 @@ use ratatui::style::Color; -use serde::{Deserialize, Serialize}; -use crate::screen::colors::ModeColorscheme; +use crate::{screen::colors::ModeColorscheme, television::Mode}; pub fn mode_color(mode: Mode, colorscheme: &ModeColorscheme) -> Color { match mode { @@ -10,11 +9,3 @@ pub fn mode_color(mode: Mode, colorscheme: &ModeColorscheme) -> Color { Mode::SendToChannel => colorscheme.send_to_channel, } } - -// 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/television/screen/preview.rs b/television/screen/preview.rs index 8005337..0525d86 100644 --- a/television/screen/preview.rs +++ b/television/screen/preview.rs @@ -1,12 +1,9 @@ -use crate::channels::entry::Entry; +use crate::preview::PreviewState; use crate::preview::{ - ansi::IntoText, Preview, PreviewContent, FILE_TOO_LARGE_MSG, LOADING_MSG, + ansi::IntoText, PreviewContent, FILE_TOO_LARGE_MSG, LOADING_MSG, PREVIEW_NOT_SUPPORTED_MSG, TIMEOUT_MSG, }; -use crate::screen::{ - cache::RenderedPreviewCache, - colors::{Colorscheme, PreviewColorscheme}, -}; +use crate::screen::colors::{Colorscheme, PreviewColorscheme}; use crate::utils::strings::{ replace_non_printable, shrink_with_ellipsis, ReplaceNonPrintableConfig, EMPTY_STRING, @@ -20,19 +17,46 @@ use ratatui::{ prelude::{Color, Line, Modifier, Span, Style, Stylize, Text}, }; use std::str::FromStr; -use std::sync::{Arc, Mutex}; #[allow(dead_code)] const FILL_CHAR_SLANTED: char = '╱'; const FILL_CHAR_EMPTY: char = ' '; -#[allow(clippy::needless_pass_by_value)] +#[allow(clippy::too_many_arguments)] +pub fn draw_preview_content_block( + f: &mut Frame, + rect: Rect, + preview_state: &PreviewState, + use_nerd_font_icons: bool, + colorscheme: &Colorscheme, +) -> Result<()> { + let inner = draw_content_outer_block( + f, + rect, + colorscheme, + preview_state.preview.icon, + &preview_state.preview.title, + use_nerd_font_icons, + )?; + + // render the preview content + let rp = build_preview_paragraph( + inner, + &preview_state.preview.content, + preview_state.target_line, + preview_state.scroll, + colorscheme, + ); + f.render_widget(rp, inner); + Ok(()) +} + pub fn build_preview_paragraph<'a>( inner: Rect, - preview_content: PreviewContent, + preview_content: &'a PreviewContent, target_line: Option, preview_scroll: u16, - colorscheme: Colorscheme, + colorscheme: &'a Colorscheme, ) -> Paragraph<'a> { let preview_block = Block::default().style(Style::default()).padding(Padding { @@ -58,14 +82,16 @@ pub fn build_preview_paragraph<'a>( preview_block, colorscheme.preview, ) + .scroll((preview_scroll, 0)) } PreviewContent::SyntectHighlightedText(highlighted_lines) => { build_syntect_highlighted_paragraph( - highlighted_lines.lines, + &highlighted_lines.lines, preview_block, target_line, preview_scroll, colorscheme.preview, + inner.height, ) } // meta @@ -104,12 +130,11 @@ pub fn build_preview_paragraph<'a>( const ANSI_BEFORE_CONTEXT_SIZE: u16 = 10; const ANSI_CONTEXT_SIZE: usize = 150; -#[allow(clippy::needless_pass_by_value)] -fn build_ansi_text_paragraph( - text: String, - preview_block: Block, +fn build_ansi_text_paragraph<'a>( + text: &'a str, + preview_block: Block<'a>, preview_scroll: u16, -) -> Paragraph { +) -> Paragraph<'a> { let lines = text.lines(); let skip = preview_scroll.saturating_sub(ANSI_BEFORE_CONTEXT_SIZE) as usize; @@ -137,14 +162,13 @@ fn build_ansi_text_paragraph( .scroll((preview_scroll, 0)) } -#[allow(clippy::needless_pass_by_value)] -fn build_plain_text_paragraph( - text: Vec, - preview_block: Block<'_>, +fn build_plain_text_paragraph<'a>( + text: &'a [String], + preview_block: Block<'a>, target_line: Option, preview_scroll: u16, colorscheme: PreviewColorscheme, -) -> Paragraph<'_> { +) -> Paragraph<'a> { let mut lines = Vec::new(); for (i, line) in text.iter().enumerate() { lines.push(Line::from(vec![ @@ -179,12 +203,11 @@ fn build_plain_text_paragraph( .scroll((preview_scroll, 0)) } -#[allow(clippy::needless_pass_by_value)] -fn build_plain_text_wrapped_paragraph( - text: String, - preview_block: Block<'_>, +fn build_plain_text_wrapped_paragraph<'a>( + text: &'a str, + preview_block: Block<'a>, colorscheme: PreviewColorscheme, -) -> Paragraph<'_> { +) -> Paragraph<'a> { let mut lines = Vec::new(); for line in text.lines() { lines.push(Line::styled( @@ -198,22 +221,24 @@ fn build_plain_text_wrapped_paragraph( .wrap(Wrap { trim: true }) } -#[allow(clippy::needless_pass_by_value)] -fn build_syntect_highlighted_paragraph( - highlighted_lines: Vec>, - preview_block: Block, +fn build_syntect_highlighted_paragraph<'a>( + highlighted_lines: &'a [Vec<(syntect::highlighting::Style, String)>], + preview_block: Block<'a>, target_line: Option, preview_scroll: u16, colorscheme: PreviewColorscheme, -) -> Paragraph { + height: u16, +) -> Paragraph<'a> { compute_paragraph_from_highlighted_lines( - &highlighted_lines, + highlighted_lines, target_line.map(|l| l as usize), + preview_scroll, colorscheme, + height, ) .block(preview_block) .alignment(Alignment::Left) - .scroll((preview_scroll, 0)) + //.scroll((preview_scroll, 0)) } pub fn build_meta_preview_paragraph<'a>( @@ -325,109 +350,6 @@ fn draw_content_outer_block( Ok(inner) } -#[allow(clippy::too_many_arguments)] -pub fn draw_preview_content_block( - f: &mut Frame, - rect: Rect, - entry: &Entry, - preview: &Option>, - rendered_preview_cache: &Arc>>, - preview_scroll: u16, - use_nerd_font_icons: bool, - colorscheme: &Colorscheme, -) -> Result<()> { - if let Some(preview) = preview { - let inner = draw_content_outer_block( - f, - rect, - colorscheme, - preview.icon, - &preview.title, - use_nerd_font_icons, - )?; - - // check if the rendered preview content is already in the cache - let cache_key = compute_cache_key(entry); - if let Some(rp) = - rendered_preview_cache.lock().unwrap().get(&cache_key) - { - // we got a hit, render the cached preview content - let p = rp.paragraph.as_ref().clone(); - f.render_widget(p.scroll((preview_scroll, 0)), inner); - return Ok(()); - } - // render the preview content and cache it - let rp = build_preview_paragraph( - //preview_inner_block, - inner, - preview.content.clone(), - entry.line_number.map(|l| u16::try_from(l).unwrap_or(0)), - preview_scroll, - colorscheme.clone(), - ); - // only cache the preview content if it's not a partial preview - // and the preview title matches the entry name - if preview.partial_offset.is_none() && preview.title == entry.name { - rendered_preview_cache.lock().unwrap().insert( - cache_key, - preview.icon, - &preview.title, - &Arc::new(rp.clone()), - ); - } - f.render_widget(rp.scroll((preview_scroll, 0)), inner); - return Ok(()); - } - // else if last_preview exists - if let Some(last_preview) = - &rendered_preview_cache.lock().unwrap().last_preview - { - let inner = draw_content_outer_block( - f, - rect, - colorscheme, - last_preview.icon, - &last_preview.title, - use_nerd_font_icons, - )?; - - f.render_widget( - last_preview - .paragraph - .as_ref() - .clone() - .scroll((preview_scroll, 0)), - inner, - ); - return Ok(()); - } - // otherwise render empty preview - let inner = draw_content_outer_block( - f, - rect, - colorscheme, - None, - "", - use_nerd_font_icons, - )?; - let preview_outer_block = Block::default() - .title_top(Line::from(Span::styled( - " Preview ", - Style::default().fg(colorscheme.preview.title_fg), - ))) - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(colorscheme.general.border_fg)) - .style( - Style::default() - .bg(colorscheme.general.background.unwrap_or_default()), - ) - .padding(Padding::new(0, 1, 1, 0)); - f.render_widget(preview_outer_block, inner); - - Ok(()) -} - fn build_line_number_span<'a>(line_number: usize) -> Span<'a> { Span::from(format!("{line_number:5} ")) } @@ -435,11 +357,15 @@ fn build_line_number_span<'a>(line_number: usize) -> Span<'a> { fn compute_paragraph_from_highlighted_lines( highlighted_lines: &[Vec<(syntect::highlighting::Style, String)>], line_specifier: Option, + preview_scroll: u16, colorscheme: PreviewColorscheme, + height: u16, ) -> Paragraph<'static> { let preview_lines: Vec = highlighted_lines .iter() .enumerate() + .skip(preview_scroll.saturating_sub(1).into()) + .take(height.into()) .map(|(i, l)| { let line_number = build_line_number_span(i + 1).style(Style::default().fg( @@ -501,11 +427,3 @@ fn convert_syn_color_to_ratatui_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/television/screen/remote_control.rs b/television/screen/remote_control.rs index 905ddaf..0cdc91b 100644 --- a/television/screen/remote_control.rs +++ b/television/screen/remote_control.rs @@ -1,10 +1,9 @@ -use rustc_hash::FxHashMap; - use crate::channels::entry::Entry; use crate::screen::colors::{Colorscheme, GeneralColorscheme}; use crate::screen::logo::build_remote_logo_paragraph; -use crate::screen::mode::{mode_color, Mode}; +use crate::screen::mode::mode_color; use crate::screen::results::build_results_list; +use crate::television::Mode; use crate::utils::input::Input; use anyhow::Result; @@ -25,7 +24,6 @@ pub fn draw_remote_control( use_nerd_font_icons: bool, picker_state: &mut ListState, input_state: &mut Input, - icon_color_cache: &mut FxHashMap, mode: &Mode, colorscheme: &Colorscheme, ) -> Result<()> { @@ -46,7 +44,6 @@ pub fn draw_remote_control( entries, use_nerd_font_icons, picker_state, - icon_color_cache, colorscheme, ); draw_rc_input(f, layout[1], input_state, colorscheme)?; @@ -65,7 +62,6 @@ fn draw_rc_channels( entries: &[Entry], use_nerd_font_icons: bool, picker_state: &mut ListState, - icon_color_cache: &mut FxHashMap, colorscheme: &Colorscheme, ) { let rc_block = Block::default() @@ -84,7 +80,6 @@ fn draw_rc_channels( None, ListDirection::TopToBottom, use_nerd_font_icons, - icon_color_cache, &colorscheme.results, ); diff --git a/television/screen/results.rs b/television/screen/results.rs index f20b26e..bd368b0 100644 --- a/television/screen/results.rs +++ b/television/screen/results.rs @@ -13,7 +13,7 @@ use ratatui::widgets::{ Block, BorderType, Borders, List, ListDirection, ListState, Padding, }; use ratatui::Frame; -use rustc_hash::{FxHashMap, FxHashSet}; +use rustc_hash::FxHashSet; use std::str::FromStr; const POINTER_SYMBOL: &str = "> "; @@ -26,7 +26,6 @@ pub fn build_results_list<'a, 'b>( selected_entries: Option<&FxHashSet>, list_direction: ListDirection, use_icons: bool, - icon_color_cache: &mut FxHashMap, colorscheme: &ResultsColorscheme, ) -> List<'a> where @@ -50,20 +49,10 @@ where // optional icon if let Some(icon) = entry.icon.as_ref() { if use_icons { - if let Some(icon_color) = icon_color_cache.get(icon.color) { - spans.push(Span::styled( - icon.to_string(), - Style::default().fg(*icon_color), - )); - } else { - let icon_color = Color::from_str(icon.color).unwrap(); - icon_color_cache - .insert(icon.color.to_string(), icon_color); - spans.push(Span::styled( - icon.to_string(), - Style::default().fg(icon_color), - )); - } + spans.push(Span::styled( + icon.to_string(), + Style::default().fg(Color::from_str(icon.color).unwrap()), + )); spans.push(Span::raw(" ")); } @@ -160,7 +149,6 @@ pub fn draw_results_list( relative_picker_state: &mut ListState, input_bar_position: InputPosition, use_nerd_font_icons: bool, - icon_color_cache: &mut FxHashMap, colorscheme: &Colorscheme, help_keybinding: &str, preview_keybinding: &str, @@ -191,7 +179,6 @@ pub fn draw_results_list( InputPosition::Top => ListDirection::TopToBottom, }, use_nerd_font_icons, - icon_color_cache, &colorscheme.results, ); diff --git a/television/screen/spinner.rs b/television/screen/spinner.rs index c863d00..eb8eb03 100644 --- a/television/screen/spinner.rs +++ b/television/screen/spinner.rs @@ -1,23 +1,29 @@ -use ratatui::{ - buffer::Buffer, layout::Rect, style::Style, widgets::StatefulWidget, -}; +use ratatui::{buffer::Buffer, layout::Rect, style::Style, widgets::Widget}; const FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; /// A spinner widget. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Hash)] pub struct Spinner { frames: &'static [&'static str], + state: SpinnerState, } impl Spinner { pub fn new(frames: &'static [&str]) -> Spinner { - Spinner { frames } + Spinner { + frames, + state: SpinnerState::new(frames.len()), + } } pub fn frame(&self, index: usize) -> &str { self.frames[index] } + + pub fn tick(&mut self) { + self.state.tick(); + } } impl Default for Spinner { @@ -26,7 +32,7 @@ impl Default for Spinner { } } -#[derive(Debug)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] pub struct SpinnerState { pub current_frame: usize, total_frames: usize, @@ -51,31 +57,24 @@ impl From<&Spinner> for SpinnerState { } } -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) { +impl Widget for Spinner { + fn render(self, area: Rect, buf: &mut Buffer) { buf.set_string( area.left(), area.top(), - self.frame(state.current_frame), + self.frame(self.state.current_frame), Style::default(), ); - 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) { +impl Widget for &Spinner { + fn render(self, area: Rect, buf: &mut Buffer) { buf.set_string( area.left(), area.top(), - self.frame(state.current_frame), + self.frame(self.state.current_frame), Style::default(), ); - state.tick(); } } diff --git a/television/television.rs b/television/television.rs index 61d91b6..eb6eabb 100644 --- a/television/television.rs +++ b/television/television.rs @@ -1,63 +1,68 @@ use crate::action::Action; +use crate::cable::load_cable_channels; use crate::channels::entry::{Entry, PreviewType, ENTRY_PLACEHOLDER}; use crate::channels::{ remote_control::{load_builtin_channels, RemoteControl}, OnAir, TelevisionChannel, UnitChannel, }; use crate::config::{Config, KeyBindings, Theme}; +use crate::draw::{ChannelState, Ctx, TvState}; use crate::input::convert_action_to_input_request; use crate::picker::Picker; -use crate::preview::Previewer; -use crate::screen::cache::RenderedPreviewCache; +use crate::preview::{PreviewState, Previewer}; use crate::screen::colors::Colorscheme; -use crate::screen::help::draw_help_bar; -use crate::screen::input::draw_input_box; -use crate::screen::keybindings::{ - build_keybindings_table, DisplayableAction, DisplayableKeybindings, -}; -use crate::screen::layout::{Dimensions, InputPosition, Layout}; -use crate::screen::mode::Mode; -use crate::screen::preview::draw_preview_content_block; -use crate::screen::remote_control::draw_remote_control; -use crate::screen::results::draw_results_list; +use crate::screen::keybindings::{DisplayableAction, DisplayableKeybindings}; +use crate::screen::layout::InputPosition; use crate::screen::spinner::{Spinner, SpinnerState}; use crate::utils::metadata::AppMetadata; use crate::utils::strings::EMPTY_STRING; -use crate::{cable::load_cable_channels, keymap::Keymap}; use anyhow::Result; use copypasta::{ClipboardContext, ClipboardProvider}; -use ratatui::{layout::Rect, style::Color, Frame}; use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet}; +use serde::{Deserialize, Serialize}; use std::collections::HashSet; -use std::sync::{Arc, Mutex}; -use tokio::sync::mpsc::UnboundedSender; +use tokio::sync::mpsc::{Receiver, Sender, UnboundedSender}; + +#[derive(PartialEq, Copy, Clone, Hash, Eq, Debug, Serialize, Deserialize)] +pub enum Mode { + Channel, + RemoteControl, + SendToChannel, +} pub struct Television { - action_tx: Option>, + action_tx: UnboundedSender, pub config: Config, - pub keymap: Keymap, - pub(crate) channel: TelevisionChannel, - pub(crate) remote_control: TelevisionChannel, + pub channel: TelevisionChannel, + pub remote_control: TelevisionChannel, pub mode: Mode, pub current_pattern: String, - pub(crate) results_picker: Picker, - pub(crate) rc_picker: Picker, - results_area_height: u32, + pub results_picker: Picker, + pub rc_picker: Picker, + results_area_height: u16, pub previewer: Previewer, - pub preview_scroll: Option, - pub preview_pane_height: u16, - current_preview_total_lines: u16, - pub icon_color_cache: FxHashMap, - pub rendered_preview_cache: Arc>>, - pub(crate) spinner: Spinner, - pub(crate) spinner_state: SpinnerState, + pub preview_state: PreviewState, + pub spinner: Spinner, + pub spinner_state: SpinnerState, pub app_metadata: AppMetadata, pub colorscheme: Colorscheme, + pub ticks: u64, + // these are really here as a means to communicate between the render thread + // and the main thread to update `Television`'s state without needing to pass + // a mutable reference to `draw` + pub inner_rx: Receiver, + pub inner_tx: Sender, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum Message { + ResultListHeightChanged(u16), } impl Television { #[must_use] pub fn new( + action_tx: UnboundedSender, mut channel: TelevisionChannel, config: Config, input: Option, @@ -67,7 +72,6 @@ impl Television { results_picker = results_picker.inverted(); } let previewer = Previewer::new(Some(config.previewers.clone().into())); - let keymap = Keymap::from(&config.keybindings); let cable_channels = load_cable_channels().unwrap_or_default(); let builtin_channels = load_builtin_channels(Some( &cable_channels.keys().collect::>(), @@ -84,10 +88,12 @@ impl Television { channel.find(&input.unwrap_or(EMPTY_STRING.to_string())); let spinner = Spinner::default(); + // capacity is quite arbitrary here, we can adjust it later + let (inner_tx, inner_rx) = tokio::sync::mpsc::channel(10); + Self { - action_tx: None, + action_tx, config, - keymap, channel, remote_control: TelevisionChannel::RemoteControl( RemoteControl::new(builtin_channels, Some(cable_channels)), @@ -98,17 +104,14 @@ impl Television { rc_picker: Picker::default(), results_area_height: 0, previewer, - preview_scroll: None, - preview_pane_height: 0, - current_preview_total_lines: 0, - icon_color_cache: FxHashMap::default(), - rendered_preview_cache: Arc::new(Mutex::new( - RenderedPreviewCache::default(), - )), + preview_state: PreviewState::default(), spinner, spinner_state: SpinnerState::from(&spinner), app_metadata, colorscheme, + ticks: 0, + inner_rx, + inner_tx, } } @@ -122,12 +125,41 @@ impl Television { ); } + pub fn dump_context(&self) -> Ctx { + let channel_state = ChannelState::new( + self.current_channel(), + self.channel.selected_entries().clone(), + self.channel.total_count(), + self.channel.running(), + ); + let tv_state = TvState::new( + self.mode, + self.get_selected_entry(Some(Mode::Channel)), + self.results_area_height, + self.results_picker.clone(), + self.rc_picker.clone(), + channel_state, + self.spinner, + self.preview_state.clone(), + ); + + Ctx::new( + tv_state, + self.config.clone(), + self.colorscheme.clone(), + self.app_metadata.clone(), + self.inner_tx.clone(), + // now timestamp + std::time::Instant::now(), + ) + } + pub fn current_channel(&self) -> UnitChannel { UnitChannel::from(&self.channel) } pub fn change_channel(&mut self, channel: TelevisionChannel) { - self.reset_preview_scroll(); + self.preview_state.reset(); self.reset_picker_selection(); self.reset_picker_input(); self.current_pattern = EMPTY_STRING.to_string(); @@ -147,7 +179,7 @@ impl Television { } #[must_use] - pub fn get_selected_entry(&mut self, mode: Option) -> Option { + pub fn get_selected_entry(&self, mode: Option) -> Option { match mode.unwrap_or(self.mode) { Mode::Channel => { if let Some(i) = self.results_picker.selected() { @@ -168,7 +200,7 @@ impl Television { #[must_use] pub fn get_selected_entries( - &mut self, + &self, mode: Option, ) -> Option> { if self.channel.selected_entries().is_empty() @@ -221,10 +253,6 @@ impl Television { ); } - 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(), @@ -242,85 +270,232 @@ impl Television { } } } - - pub fn scroll_preview_down(&mut self, offset: u16) { - if self.preview_scroll.is_none() { - self.preview_scroll = Some(0); - } - if let Some(scroll) = self.preview_scroll { - self.preview_scroll = Some( - (scroll + offset).min( - self.current_preview_total_lines - .saturating_sub(2 * self.preview_pane_height / 3), - ), - ); - } - } - - pub fn scroll_preview_up(&mut self, offset: u16) { - if let Some(scroll) = self.preview_scroll { - self.preview_scroll = Some(scroll.saturating_sub(offset)); - } - } } +const RENDER_EVERY_N_TICKS: u64 = 10; + impl Television { - /// Register an action handler that can send actions for processing if necessary. - /// - /// # Arguments - /// * `tx` - An unbounded sender that can send actions. - /// - /// # Returns - /// * `Result<()>` - An Ok result or an error. - pub fn register_action_handler( + fn should_render(&self, action: &Action) -> bool { + self.ticks == RENDER_EVERY_N_TICKS + || matches!( + action, + Action::AddInputChar(_) + | Action::DeletePrevChar + | Action::DeletePrevWord + | Action::DeleteNextChar + | Action::GoToPrevChar + | Action::GoToNextChar + | Action::GoToInputStart + | Action::GoToInputEnd + | Action::ToggleSelectionDown + | Action::ToggleSelectionUp + | Action::ConfirmSelection + | Action::SelectNextEntry + | Action::SelectPrevEntry + | Action::SelectNextPage + | Action::SelectPrevPage + | Action::ScrollPreviewDown + | Action::ScrollPreviewUp + | Action::ScrollPreviewHalfPageDown + | Action::ScrollPreviewHalfPageUp + | Action::ToggleRemoteControl + | Action::ToggleSendToChannel + | Action::ToggleHelp + | Action::TogglePreview + | Action::CopyEntryToClipboard + ) + || self.channel.running() + } + + pub fn update_preview_state( &mut self, - tx: UnboundedSender, + selected_entry: &Entry, ) -> Result<()> { - self.action_tx = Some(tx); + if self.config.ui.show_preview_panel + && !matches!(selected_entry.preview_type, PreviewType::None) + { + // preview content + if let Some(preview) = self.previewer.preview(selected_entry) { + if self.preview_state.preview.title != preview.title { + self.preview_state.update( + preview, + // scroll to center the selected entry + selected_entry + .line_number + .unwrap_or(0) + .saturating_sub( + (self.results_area_height / 2).into(), + ) + .try_into()?, + selected_entry + .line_number + .and_then(|l| l.try_into().ok()), + ); + self.action_tx.send(Action::Render)?; + } + } + } else { + self.preview_state.reset(); + } Ok(()) } - fn should_render(&self, action: &Action) -> bool { - matches!( - action, - Action::AddInputChar(_) - | Action::DeletePrevChar - | Action::DeletePrevWord - | Action::DeleteNextChar - | Action::GoToPrevChar - | Action::GoToNextChar - | Action::GoToInputStart - | Action::GoToInputEnd - | Action::ToggleSelectionDown - | Action::ToggleSelectionUp - | Action::ConfirmSelection - | Action::SelectNextEntry - | Action::SelectPrevEntry - | Action::SelectNextPage - | Action::SelectPrevPage - | Action::ScrollPreviewDown - | Action::ScrollPreviewUp - | Action::ScrollPreviewHalfPageDown - | Action::ScrollPreviewHalfPageUp - | Action::ToggleRemoteControl - | Action::ToggleSendToChannel - | Action::ToggleHelp - | Action::TogglePreview - | Action::CopyEntryToClipboard - ) || self.channel.running() + pub fn update_results_picker_state(&mut self) { + if self.results_picker.selected().is_none() + && self.channel.result_count() > 0 + { + self.results_picker.select(Some(0)); + self.results_picker.relative_select(Some(0)); + } + + self.results_picker.entries = self.channel.results( + self.results_area_height.into(), + u32::try_from(self.results_picker.offset()).unwrap(), + ); + self.results_picker.total_items = self.channel.result_count(); } - #[allow(clippy::unused_async)] - /// Update the state of the component based on a received action. - /// - /// # Arguments - /// * `action` - An action that may modify the state of the television. - /// - /// # Returns - /// * `Result>` - An action to be processed or none. - pub async fn update(&mut self, action: Action) -> Result> { + pub fn update_rc_picker_state(&mut self) { + if self.rc_picker.selected().is_none() + && self.remote_control.result_count() > 0 + { + self.rc_picker.select(Some(0)); + self.rc_picker.relative_select(Some(0)); + } + + self.rc_picker.entries = self.remote_control.results( + // this'll be more than the actual rc height but it's fine + self.results_area_height.into(), + u32::try_from(self.rc_picker.offset()).unwrap(), + ); + self.rc_picker.total_items = self.remote_control.total_count(); + } + + pub fn handle_input_action(&mut self, 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(convert_action_to_input_request(action).unwrap()); + match action { + Action::AddInputChar(_) + | Action::DeletePrevChar + | Action::DeletePrevWord + | Action::DeleteNextChar => { + 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_picker_selection(); + self.preview_state.reset(); + } + } + _ => {} + } + } + + pub fn handle_toggle_rc(&mut self) { + 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; + } + Mode::SendToChannel => {} + } + } + + pub fn handle_toggle_send_to_channel(&mut self) { + match self.mode { + Mode::Channel | Mode::RemoteControl => { + self.mode = Mode::SendToChannel; + self.remote_control = TelevisionChannel::RemoteControl( + RemoteControl::with_transitions_from(&self.channel), + ); + } + Mode::SendToChannel => { + self.reset_picker_input(); + self.remote_control.find(EMPTY_STRING); + self.reset_picker_selection(); + self.mode = Mode::Channel; + } + } + } + + pub fn handle_toggle_selection(&mut self, action: &Action) { + if matches!(self.mode, Mode::Channel) { + if let Some(entry) = self.get_selected_entry(None) { + self.channel.toggle_selection(&entry); + if matches!(action, Action::ToggleSelectionDown) { + self.select_next_entry(1); + } else { + self.select_prev_entry(1); + } + } + } + } + + pub fn handle_confirm_selection(&mut self) -> Result<()> { + match self.mode { + Mode::Channel => { + self.action_tx.send(Action::SelectAndExit)?; + } + Mode::RemoteControl => { + if let Some(entry) = self.get_selected_entry(None) { + let new_channel = + self.remote_control.zap(entry.name.as_str())?; + // 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); + } + } + Mode::SendToChannel => { + if let Some(entry) = self.get_selected_entry(None) { + let new_channel = self + .channel + .transition_to(entry.name.as_str().try_into()?); + self.reset_picker_selection(); + self.reset_picker_input(); + self.remote_control.find(EMPTY_STRING); + self.mode = Mode::Channel; + self.change_channel(new_channel); + } + } + } + Ok(()) + } + + pub fn handle_copy_entry_to_clipboard(&mut self) { + if self.mode == Mode::Channel { + if let Some(entries) = self.get_selected_entries(None) { + let mut ctx = ClipboardContext::new().unwrap(); + ctx.set_contents( + entries + .iter() + .map(|e| e.name.clone()) + .collect::>() + .join(" "), + ) + .unwrap(); + } + } + } + + pub fn handle_action(&mut self, action: &Action) -> Result<()> { + // handle actions match action { - // handle input actions Action::AddInputChar(_) | Action::DeletePrevChar | Action::DeletePrevWord @@ -329,145 +504,47 @@ impl Television { | Action::GoToInputStart | Action::GoToNextChar | Action::GoToPrevChar => { - let input = match self.mode { - Mode::Channel => &mut self.results_picker.input, - Mode::RemoteControl | Mode::SendToChannel => { - &mut self.rc_picker.input - } - }; - input - .handle(convert_action_to_input_request(&action).unwrap()); - match action { - Action::AddInputChar(_) - | Action::DeletePrevChar - | Action::DeletePrevWord - | Action::DeleteNextChar => { - 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_picker_selection(); - self.reset_preview_scroll(); - } - } - _ => {} - } + self.handle_input_action(action); } Action::SelectNextEntry => { - self.reset_preview_scroll(); + self.preview_state.reset(); self.select_next_entry(1); } Action::SelectPrevEntry => { - self.reset_preview_scroll(); + self.preview_state.reset(); self.select_prev_entry(1); } Action::SelectNextPage => { - self.reset_preview_scroll(); - self.select_next_entry(self.results_area_height); + self.preview_state.reset(); + self.select_next_entry(self.results_area_height.into()); } Action::SelectPrevPage => { - self.reset_preview_scroll(); - self.select_prev_entry(self.results_area_height); + self.preview_state.reset(); + self.select_prev_entry(self.results_area_height.into()); + } + Action::ScrollPreviewDown => self.preview_state.scroll_down(1), + Action::ScrollPreviewUp => self.preview_state.scroll_up(1), + Action::ScrollPreviewHalfPageDown => { + self.preview_state.scroll_down(20); + } + Action::ScrollPreviewHalfPageUp => { + self.preview_state.scroll_up(20); + } + Action::ToggleRemoteControl => { + self.handle_toggle_rc(); } - Action::ScrollPreviewDown => self.scroll_preview_down(1), - Action::ScrollPreviewUp => self.scroll_preview_up(1), - Action::ScrollPreviewHalfPageDown => self.scroll_preview_down(20), - Action::ScrollPreviewHalfPageUp => self.scroll_preview_up(20), - 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; - } - Mode::SendToChannel => {} - }, Action::ToggleSelectionDown | Action::ToggleSelectionUp => { - if matches!(self.mode, Mode::Channel) { - if let Some(entry) = self.get_selected_entry(None) { - self.channel.toggle_selection(&entry); - if matches!(action, Action::ToggleSelectionDown) { - self.select_next_entry(1); - } else { - self.select_prev_entry(1); - } - } - } + self.handle_toggle_selection(action); } Action::ConfirmSelection => { - match self.mode { - Mode::Channel => { - self.action_tx - .as_ref() - .unwrap() - .send(Action::SelectAndExit)?; - } - Mode::RemoteControl => { - if let Some(entry) = - self.get_selected_entry(Some(Mode::RemoteControl)) - { - let new_channel = self - .remote_control - .zap(entry.name.as_str())?; - // 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); - } - } - Mode::SendToChannel => { - if let Some(entry) = - self.get_selected_entry(Some(Mode::RemoteControl)) - { - let new_channel = self.channel.transition_to( - entry.name.as_str().try_into().unwrap(), - ); - self.reset_picker_selection(); - self.reset_picker_input(); - self.remote_control.find(EMPTY_STRING); - self.mode = Mode::Channel; - self.change_channel(new_channel); - } - } - } + self.handle_confirm_selection()?; } Action::CopyEntryToClipboard => { - if self.mode == Mode::Channel { - if let Some(entries) = self.get_selected_entries(None) { - let mut ctx = ClipboardContext::new().unwrap(); - ctx.set_contents( - entries - .iter() - .map(|e| e.name.clone()) - .collect::>() - .join(" "), - ) - .unwrap(); - } - } + self.handle_copy_entry_to_clipboard(); + } + Action::ToggleSendToChannel => { + self.handle_toggle_send_to_channel(); } - Action::ToggleSendToChannel => match self.mode { - Mode::Channel | Mode::RemoteControl => { - self.mode = Mode::SendToChannel; - self.remote_control = TelevisionChannel::RemoteControl( - RemoteControl::with_transitions_from(&self.channel), - ); - } - Mode::SendToChannel => { - self.reset_picker_input(); - self.remote_control.find(EMPTY_STRING); - self.reset_picker_selection(); - self.mode = Mode::Channel; - } - }, Action::ToggleHelp => { self.config.ui.show_help_bar = !self.config.ui.show_help_bar; } @@ -477,180 +554,45 @@ impl Television { } _ => {} } - - Ok(if self.should_render(&action) { - Some(Action::Render) - } else { - None - }) + Ok(()) } - /// Render the television on the screen. + #[allow(clippy::unused_async)] + /// Update the television state based on the action provided. /// - /// # Arguments - /// * `f` - A frame used for rendering. - /// * `area` - The area in which the television should be drawn. - /// - /// # Returns - /// * `Result<()>` - An Ok result or an error. - pub fn draw(&mut self, f: &mut Frame<'_>, area: Rect) -> Result<()> { + /// This function may return an Action that'll be processed by the parent `App`. + pub fn update(&mut self, action: &Action) -> Result> { + if let Ok(Message::ResultListHeightChanged(height)) = + self.inner_rx.try_recv() + { + self.results_area_height = height; + self.action_tx.send(Action::Render)?; + } + let selected_entry = self .get_selected_entry(Some(Mode::Channel)) .unwrap_or(ENTRY_PLACEHOLDER); - let layout = Layout::build( - &Dimensions::from(self.config.ui.ui_scale), - area, - !matches!(self.mode, Mode::Channel), - self.config.ui.show_help_bar, - self.config.ui.show_preview_panel - && !matches!(selected_entry.preview_type, PreviewType::None), - self.config.ui.input_bar_position, - ); + self.update_preview_state(&selected_entry)?; - // help bar (metadata, keymaps, logo) - draw_help_bar( - f, - &layout.help_bar, - self.current_channel(), - build_keybindings_table( - &self.config.keybindings.to_displayable(), - self.mode, - &self.colorscheme, - ), - self.mode, - &self.app_metadata, - &self.colorscheme, - ); + self.update_results_picker_state(); - self.results_area_height = - u32::from(layout.results.height.saturating_sub(2)); // 2 for the borders - self.preview_pane_height = match layout.preview_window { - Some(preview) => preview.height, - None => 0, - }; + self.update_rc_picker_state(); - // results list - 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, - self.channel.selected_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, - &self.colorscheme, - &self - .config - .keybindings - .get(&self.mode) - .unwrap() - .get(&Action::ToggleHelp) - // just display the first keybinding - .unwrap() - .to_string(), - &self - .config - .keybindings - .get(&self.mode) - .unwrap() - .get(&Action::TogglePreview) - // just display the first keybinding - .unwrap() - .to_string(), - )?; + self.handle_action(action)?; - // input box - 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, - &self.colorscheme, - )?; + self.ticks += 1; - if self.config.ui.show_preview_panel - && !matches!(selected_entry.preview_type, PreviewType::None) - { - // preview content - let maybe_preview = self.previewer.preview(&selected_entry); - - let _ = self.previewer.preview(&selected_entry); - - if let Some(preview) = &maybe_preview { - self.current_preview_total_lines = preview.total_lines; - // initialize preview scroll - self.maybe_init_preview_scroll( - selected_entry - .line_number - .map(|l| u16::try_from(l).unwrap_or(0)), - layout.preview_window.unwrap().height, - ); + Ok(if self.should_render(action) { + if self.channel.running() { + self.spinner.tick(); } + self.ticks = 0; - draw_preview_content_block( - f, - layout.preview_window.unwrap(), - &selected_entry, - &maybe_preview, - &self.rendered_preview_cache, - self.preview_scroll.unwrap_or(0), - self.config.ui.use_nerd_font_icons, - &self.colorscheme, - )?; - } - - // remote control - if matches!(self.mode, Mode::RemoteControl | Mode::SendToChannel) { - // 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, - &self.mode, - &self.colorscheme, - )?; - } - 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)); - } + Some(Action::Render) + } else { + None + }) } } diff --git a/television/tui.rs b/television/tui.rs index 2281781..cc70bb9 100644 --- a/television/tui.rs +++ b/television/tui.rs @@ -5,14 +5,15 @@ use std::{ use anyhow::Result; use crossterm::{ - cursor, execute, + cursor, + event::DisableMouseCapture, + execute, terminal::{ disable_raw_mode, enable_raw_mode, is_raw_mode_enabled, EnterAlternateScreen, LeaveAlternateScreen, }, }; use ratatui::{backend::CrosstermBackend, layout::Size}; -use tokio::task::JoinHandle; use tracing::debug; #[allow(dead_code)] @@ -20,8 +21,6 @@ pub struct Tui where W: Write, { - pub task: JoinHandle<()>, - pub frame_rate: f64, pub terminal: ratatui::Terminal>, } @@ -32,17 +31,10 @@ where { pub fn new(writer: W) -> Result { Ok(Self { - task: tokio::spawn(async {}), - frame_rate: 60.0, terminal: ratatui::Terminal::new(CrosstermBackend::new(writer))?, }) } - pub fn frame_rate(mut self, frame_rate: f64) -> Self { - self.frame_rate = frame_rate; - self - } - pub fn size(&self) -> Result { Ok(self.terminal.size()?) } @@ -52,7 +44,7 @@ where let mut buffered_stderr = LineWriter::new(stderr()); execute!(buffered_stderr, EnterAlternateScreen)?; self.terminal.clear()?; - execute!(buffered_stderr, cursor::Hide)?; + execute!(buffered_stderr, DisableMouseCapture)?; Ok(()) } diff --git a/television/utils/cache.rs b/television/utils/cache.rs index 93a49e3..5d2eae6 100644 --- a/television/utils/cache.rs +++ b/television/utils/cache.rs @@ -1,6 +1,6 @@ use rustc_hash::{FxBuildHasher, FxHashSet}; use std::collections::{HashSet, VecDeque}; -use tracing::debug; +use tracing::{debug, trace}; /// A ring buffer that also keeps track of the keys it contains to avoid duplicates. /// @@ -81,7 +81,7 @@ where pub fn push(&mut self, item: T) -> Option { // If the key is already in the buffer, do nothing if self.contains(&item) { - debug!("Key already in ring buffer: {:?}", item); + trace!("Key already in ring buffer: {:?}", item); return None; } let mut popped_key = None; diff --git a/television/utils/input.rs b/television/utils/input.rs index 6261ca7..5f96b4b 100644 --- a/television/utils/input.rs +++ b/television/utils/input.rs @@ -30,7 +30,7 @@ pub struct StateChanged { pub type InputResponse = Option; /// An input buffer with cursor support. -#[derive(Default, Debug, Clone)] +#[derive(Default, Debug, Clone, PartialEq, Hash)] pub struct Input { value: String, cursor: usize, diff --git a/television/utils/metadata.rs b/television/utils/metadata.rs index a729b2f..3afef5d 100644 --- a/television/utils/metadata.rs +++ b/television/utils/metadata.rs @@ -1,3 +1,4 @@ +#[derive(Debug, Clone, PartialEq, Hash, Eq)] pub struct AppMetadata { pub version: String, pub current_directory: String, diff --git a/television/utils/syntax.rs b/television/utils/syntax.rs index 98016fa..5ca69f5 100644 --- a/television/utils/syntax.rs +++ b/television/utils/syntax.rs @@ -118,7 +118,7 @@ fn set_syntax_set<'a>( }) } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Hash)] pub struct HighlightedLines { pub lines: Vec>, //pub state: Option,