From 496d20068244c325f05bd8f653decc82e48f4048 Mon Sep 17 00:00:00 2001 From: Alex Pasmantier Date: Tue, 13 May 2025 18:13:47 +0200 Subject: [PATCH] refactor(previewer): a much more efficient preview system for tv Broke away the previewer logic into its own tokio task communicating with the main thread over two mpsc channels. Most of the previewer code is now much simpler and less verbose. This brings quite a nice bump to performance and overall UI responsiveness and also makes the previewer consume less cpu resources. --- benches/main/ui.rs | 4 +- television/channels/cable.rs | 3 - television/channels/prototypes.rs | 4 + television/draw.rs | 66 ++++----- television/lib.rs | 2 +- television/preview/cache.rs | 68 --------- television/preview/meta.rs | 21 --- television/preview/mod.rs | 140 ------------------ television/preview/previewer.rs | 137 ----------------- television/previewer/mod.rs | 234 ++++++++++++++++++++++++++++++ television/previewer/state.rs | 58 ++++++++ television/render.rs | 24 ++- television/screen/preview.rs | 33 +---- television/television.rs | 141 +++++++++++------- 14 files changed, 441 insertions(+), 494 deletions(-) delete mode 100644 television/preview/cache.rs delete mode 100644 television/preview/meta.rs delete mode 100644 television/preview/mod.rs delete mode 100644 television/preview/previewer.rs create mode 100644 television/previewer/mod.rs create mode 100644 television/previewer/state.rs diff --git a/benches/main/ui.rs b/benches/main/ui.rs index bb0c661..886f42a 100644 --- a/benches/main/ui.rs +++ b/benches/main/ui.rs @@ -491,9 +491,7 @@ pub fn draw(c: &mut Criterion) { std::thread::sleep(std::time::Duration::from_millis(10)); } tv.select_next_entry(10); - let _ = tv.update_preview_state( - &tv.get_selected_entry(None).unwrap(), - ); + let _ = tv.update_preview_state(&tv.get_selected_entry(None)); let _ = tv.update(&Action::Tick); (tv, terminal) }, diff --git a/television/channels/cable.rs b/television/channels/cable.rs index d9810d4..1597eff 100644 --- a/television/channels/cable.rs +++ b/television/channels/cable.rs @@ -14,11 +14,9 @@ use crate::matcher::Matcher; use crate::matcher::{config::Config, injector::Injector}; use crate::utils::command::shell_command; -#[allow(dead_code)] pub struct Channel { pub name: String, matcher: Matcher, - entries_command: String, pub preview_command: Option, selected_entries: FxHashSet, crawl_handle: tokio::task::JoinHandle<()>, @@ -71,7 +69,6 @@ impl Channel { )); Self { matcher, - entries_command: entries_command.to_string(), preview_command, name: name.to_string(), selected_entries: HashSet::with_hasher(FxBuildHasher), diff --git a/television/channels/prototypes.rs b/television/channels/prototypes.rs index 05166fc..57aae72 100644 --- a/television/channels/prototypes.rs +++ b/television/channels/prototypes.rs @@ -95,6 +95,10 @@ impl ChannelPrototype { }, } } + + pub fn preview_command(&self) -> Option { + self.into() + } } const DEFAULT_PROTOTYPE_NAME: &str = "files"; diff --git a/television/draw.rs b/television/draw.rs index 965cf98..56ab151 100644 --- a/television/draw.rs +++ b/television/draw.rs @@ -9,7 +9,7 @@ use crate::{ channels::entry::Entry, config::Config, picker::Picker, - preview::PreviewState, + previewer::state::PreviewState, screen::{ colors::Colorscheme, help::draw_help_bar, input::draw_input_box, keybindings::build_keybindings_table, layout::Layout, @@ -59,7 +59,7 @@ impl Hash for ChannelState { } } -#[derive(Debug, Clone, PartialEq, Hash)] +#[derive(Debug, Clone)] /// The state of the main thread `Television` struct. /// /// This struct is passed along to the UI thread as part of the `Ctx` struct. @@ -131,37 +131,37 @@ impl Ctx { } } -impl PartialEq for Ctx { - fn eq(&self, other: &Self) -> bool { - self.tv_state == other.tv_state - && self.config == other.config - && self.colorscheme == other.colorscheme - && self.app_metadata == other.app_metadata - } -} - -impl Eq for Ctx {} - -impl Hash for Ctx { - fn hash(&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) - } -} +// 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) +// } +// } /// Draw the current UI frame based on the given context. /// diff --git a/television/lib.rs b/television/lib.rs index 4b2e7f9..0f17a01 100644 --- a/television/lib.rs +++ b/television/lib.rs @@ -12,7 +12,7 @@ pub mod keymap; pub mod logging; pub mod matcher; pub mod picker; -pub mod preview; +pub mod previewer; pub mod render; pub mod screen; pub mod television; diff --git a/television/preview/cache.rs b/television/preview/cache.rs deleted file mode 100644 index dd1db23..0000000 --- a/television/preview/cache.rs +++ /dev/null @@ -1,68 +0,0 @@ -use rustc_hash::FxHashMap; -use std::sync::Arc; - -use crate::preview::Preview; -use crate::utils::cache::RingSet; -use tracing::debug; - -/// Default size of the preview cache: 100 entries. -/// -/// This does seem kind of arbitrary for now, will need to play around with it. -/// At the moment, files over 4 MB are not previewed, so the cache size -/// should never exceed 400 MB. -const DEFAULT_PREVIEW_CACHE_SIZE: usize = 100; - -/// A cache for previews. -/// The cache is implemented as an LRU cache with a fixed size. -#[derive(Debug)] -pub struct PreviewCache { - entries: FxHashMap>, - ring_set: RingSet, -} - -impl PreviewCache { - /// Create a new preview cache with the given capacity. - pub fn new(capacity: usize) -> Self { - PreviewCache { - entries: FxHashMap::default(), - ring_set: RingSet::with_capacity(capacity), - } - } - - pub fn get(&self, key: &str) -> Option> { - self.entries.get(key).cloned() - } - - /// Insert a new preview into the cache. - /// If the cache is full, the oldest entry will be removed. - /// If the key is already in the cache, the preview will be updated. - pub fn insert(&mut self, key: String, preview: &Arc) { - debug!("Inserting preview into cache: {}", key); - self.entries.insert(key.clone(), Arc::clone(preview)); - if let Some(oldest_key) = self.ring_set.push(key) { - debug!("Cache full, removing oldest entry: {}", oldest_key); - self.entries.remove(&oldest_key); - } - } - - /// Get the preview for the given key, or insert a new preview if it doesn't exist. - #[allow(dead_code)] - pub fn get_or_insert(&mut self, key: String, f: F) -> Arc - where - F: FnOnce() -> Preview, - { - if let Some(preview) = self.get(&key) { - preview - } else { - let preview = Arc::new(f()); - self.insert(key, &preview); - preview - } - } -} - -impl Default for PreviewCache { - fn default() -> Self { - PreviewCache::new(DEFAULT_PREVIEW_CACHE_SIZE) - } -} diff --git a/television/preview/meta.rs b/television/preview/meta.rs deleted file mode 100644 index a8becab..0000000 --- a/television/preview/meta.rs +++ /dev/null @@ -1,21 +0,0 @@ -use crate::preview::{Preview, PreviewContent}; -use std::sync::Arc; - -#[allow(dead_code)] -pub fn loading(title: &str) -> Arc { - Arc::new(Preview::new( - title.to_string(), - PreviewContent::Loading, - None, - 1, - )) -} - -pub fn timeout(title: &str) -> Arc { - Arc::new(Preview::new( - title.to_string(), - PreviewContent::Timeout, - None, - 1, - )) -} diff --git a/television/preview/mod.rs b/television/preview/mod.rs deleted file mode 100644 index 11076c2..0000000 --- a/television/preview/mod.rs +++ /dev/null @@ -1,140 +0,0 @@ -use std::sync::Arc; - -use devicons::FileIcon; - -pub mod cache; -pub mod meta; -pub mod previewer; - -#[derive(Clone, Debug, PartialEq, Hash)] -pub enum PreviewContent { - Empty, - Loading, - Timeout, - AnsiText(String), -} - -impl PreviewContent { - pub fn total_lines(&self) -> u16 { - match self { - PreviewContent::AnsiText(text) => { - text.lines().count().try_into().unwrap_or(u16::MAX) - } - _ => 0, - } - } -} - -pub const PREVIEW_NOT_SUPPORTED_MSG: &str = - "Preview for this file type is not supported"; -pub const FILE_TOO_LARGE_MSG: &str = "File too large"; -pub const LOADING_MSG: &str = "Loading..."; -pub const TIMEOUT_MSG: &str = "Preview timed out"; - -/// A preview of an entry. -/// -/// # Fields -/// - `title`: The title of the preview. -/// - `content`: The content of the preview. -#[derive(Clone, Debug, PartialEq, Hash)] -pub struct Preview { - pub title: String, - pub content: PreviewContent, - pub icon: Option, - pub total_lines: u16, -} - -impl Default for Preview { - fn default() -> Self { - Preview { - title: String::new(), - content: PreviewContent::Empty, - icon: None, - total_lines: 0, - } - } -} - -impl Preview { - pub fn new( - title: String, - content: PreviewContent, - icon: Option, - total_lines: u16, - ) -> Self { - Preview { - title, - content, - icon, - total_lines, - } - } -} - -#[derive(Debug, Clone, PartialEq, Hash)] -pub struct PreviewState { - pub enabled: bool, - pub preview: Arc, - pub scroll: u16, - pub target_line: Option, -} - -impl Default for PreviewState { - fn default() -> Self { - PreviewState { - enabled: false, - preview: Arc::new(Preview::default()), - scroll: 0, - target_line: None, - } - } -} - -const PREVIEW_MIN_SCROLL_LINES: u16 = 3; - -impl PreviewState { - pub fn new( - enabled: bool, - preview: Arc, - scroll: u16, - target_line: Option, - ) -> Self { - PreviewState { - enabled, - 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; - } - } -} diff --git a/television/preview/previewer.rs b/television/preview/previewer.rs deleted file mode 100644 index 0ba5d0f..0000000 --- a/television/preview/previewer.rs +++ /dev/null @@ -1,137 +0,0 @@ -use crate::{ - channels::{ - entry::Entry, preview::PreviewCommand, prototypes::ChannelPrototype, - }, - preview::{cache::PreviewCache, Preview, PreviewContent}, - utils::command::shell_command, -}; -use parking_lot::Mutex; -use rustc_hash::FxHashSet; -use std::sync::atomic::{AtomicU8, Ordering}; -use std::sync::Arc; -use tracing::debug; - -#[allow(dead_code)] -#[derive(Debug)] -pub struct Previewer { - cache: Arc>, - concurrent_preview_tasks: Arc, - in_flight_previews: Arc>>, - command: PreviewCommand, -} - -impl Previewer { - // 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 request(&mut self, entry: &Entry) -> Option> { - // check if we have a preview in cache for the current request - if let Some(preview) = self.cached(entry) { - return Some(preview); - } - - // start a background task to compute the preview - self.preview(entry); - - None - } -} - -const MAX_CONCURRENT_PREVIEW_TASKS: u8 = 3; - -impl Previewer { - pub fn new(command: PreviewCommand) -> Self { - Previewer { - cache: Arc::new(Mutex::new(PreviewCache::default())), - concurrent_preview_tasks: Arc::new(AtomicU8::new(0)), - in_flight_previews: Arc::new(Mutex::new(FxHashSet::default())), - command, - } - } - - pub fn cached(&self, entry: &Entry) -> Option> { - self.cache.lock().get(&entry.name) - } - - pub fn preview(&mut self, entry: &Entry) { - if self.in_flight_previews.lock().contains(&entry.name) { - debug!("Preview already in flight for {:?}", entry.name); - return; - } - - if self.concurrent_preview_tasks.load(Ordering::Relaxed) - < MAX_CONCURRENT_PREVIEW_TASKS - { - self.in_flight_previews.lock().insert(entry.name.clone()); - self.concurrent_preview_tasks - .fetch_add(1, Ordering::Relaxed); - let cache = self.cache.clone(); - let entry_c = entry.clone(); - let concurrent_tasks = self.concurrent_preview_tasks.clone(); - let command = self.command.clone(); - let in_flight_previews = self.in_flight_previews.clone(); - tokio::spawn(async move { - try_preview( - &command, - &entry_c, - &cache, - &concurrent_tasks, - &in_flight_previews, - ); - }); - } else { - debug!( - "Too many concurrent preview tasks, skipping {:?}", - entry.name - ); - } - } -} - -pub fn try_preview( - command: &PreviewCommand, - entry: &Entry, - cache: &Arc>, - concurrent_tasks: &Arc, - in_flight_previews: &Arc>>, -) { - debug!("Computing preview for {:?}", entry.name); - let command = command.format_with(entry); - debug!("Formatted preview command: {:?}", command); - - let child = shell_command(false) - .arg(&command) - .output() - .expect("failed to execute process"); - - if child.status.success() { - let content = String::from_utf8_lossy(&child.stdout); - let preview = Arc::new(Preview::new( - entry.name.clone(), - PreviewContent::AnsiText(content.to_string()), - None, - u16::try_from(content.lines().count()).unwrap_or(u16::MAX), - )); - - cache.lock().insert(entry.name.clone(), &preview); - } else { - let content = String::from_utf8_lossy(&child.stderr); - let preview = Arc::new(Preview::new( - entry.name.clone(), - PreviewContent::AnsiText(content.to_string()), - None, - u16::try_from(content.lines().count()).unwrap_or(u16::MAX), - )); - cache.lock().insert(entry.name.clone(), &preview); - } - - concurrent_tasks.fetch_sub(1, Ordering::Relaxed); - in_flight_previews.lock().remove(&entry.name); -} - -impl From<&ChannelPrototype> for Option { - fn from(value: &ChannelPrototype) -> Self { - Option::::from(value).map(Previewer::new) - } -} diff --git a/television/previewer/mod.rs b/television/previewer/mod.rs new file mode 100644 index 0000000..a6dfbd2 --- /dev/null +++ b/television/previewer/mod.rs @@ -0,0 +1,234 @@ +use std::{ + cmp::Ordering, + time::{Duration, Instant}, +}; + +use devicons::FileIcon; +use tokio::{ + sync::mpsc::{UnboundedReceiver, UnboundedSender}, + time::timeout, +}; +use tracing::debug; + +use crate::{ + channels::{entry::Entry, preview::PreviewCommand}, + utils::command::shell_command, +}; + +pub mod state; + +pub struct Config { + request_max_age: Duration, + job_timeout: Duration, +} + +pub const DEFAULT_REQUEST_MAX_AGE: Duration = Duration::from_millis(1000); +pub const DEFAULT_JOB_TIMEOUT: Duration = Duration::from_millis(500); + +impl Default for Config { + fn default() -> Self { + Self { + request_max_age: DEFAULT_REQUEST_MAX_AGE, + job_timeout: DEFAULT_JOB_TIMEOUT, + } + } +} + +#[derive(PartialEq, Eq)] +pub enum Request { + Preview(Ticket), + Shutdown, +} + +impl PartialOrd for Request { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Request { + fn cmp(&self, other: &Self) -> Ordering { + match (self, other) { + // Shutdown signals always have priority + (Self::Shutdown, _) => Ordering::Greater, + (_, Self::Shutdown) => Ordering::Less, + // Otherwise fall back to ticket age comparison + (Self::Preview(t1), Self::Preview(t2)) => t1.cmp(t2), + } + } +} + +#[derive(PartialEq, Eq)] +pub struct Ticket { + entry: Entry, + timestamp: Instant, +} + +impl PartialOrd for Ticket { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Ticket { + fn cmp(&self, other: &Self) -> Ordering { + self.age().cmp(&other.age()) + } +} + +impl Ticket { + pub fn new(entry: Entry) -> Self { + Self { + entry, + timestamp: Instant::now(), + } + } + + fn age(&self) -> Duration { + Instant::now().duration_since(self.timestamp) + } +} + +#[derive(Debug, Clone)] +pub struct Preview { + pub title: String, + pub content: String, + pub icon: Option, + pub total_lines: u16, +} + +const DEFAULT_PREVIEW_TITLE: &str = "Select an entry to preview"; + +impl Default for Preview { + fn default() -> Self { + Self { + title: DEFAULT_PREVIEW_TITLE.to_string(), + content: String::new(), + icon: None, + total_lines: 1, + } + } +} + +impl Preview { + fn new( + title: &str, + content: String, + icon: Option, + total_lines: u16, + ) -> Self { + Self { + title: title.to_string(), + content, + icon, + total_lines, + } + } +} + +pub struct Previewer { + config: Config, + requests: UnboundedReceiver, + last_job_entry: Option, + preview_command: PreviewCommand, + previews: UnboundedSender, +} + +impl Previewer { + pub fn new( + preview_command: PreviewCommand, + config: Config, + receiver: UnboundedReceiver, + sender: UnboundedSender, + ) -> Self { + Self { + config, + requests: receiver, + last_job_entry: None, + preview_command, + previews: sender, + } + } + + pub async fn run(mut self) { + let mut buffer = Vec::with_capacity(32); + loop { + let num = self.requests.recv_many(&mut buffer, 32).await; + if num > 0 { + debug!("Previewer received {num} request(s)!"); + // only keep the newest request + match buffer.drain(..).max().unwrap() { + Request::Preview(ticket) => { + if ticket.age() > self.config.request_max_age { + debug!("Preview request is stale, skipping"); + continue; + } + let notify = self.previews.clone(); + let command = + self.preview_command.format_with(&ticket.entry); + self.last_job_entry = Some(ticket.entry.clone()); + // try to execute the preview with a timeout + match timeout( + self.config.job_timeout, + tokio::spawn(async move { + try_preview(&command, &ticket.entry, ¬ify); + }), + ) + .await + { + Ok(_) => { + debug!("Preview job completed successfully"); + } + Err(e) => { + debug!("Preview job timeout: {}", e); + } + } + } + Request::Shutdown => { + debug!("Received shutdown signal, breaking out of the previewer loop."); + break; + } + } + } else { + debug!("Preview request channel closed and no messages left, breaking out of the previewer loop."); + break; + } + } + } +} + +pub fn try_preview( + command: &str, + entry: &Entry, + notify: &UnboundedSender, +) { + debug!("Preview command: {}", command); + + let child = shell_command(false) + .arg(command) + .output() + .expect("failed to execute process"); + + let preview: Preview = { + if child.status.success() { + let content = String::from_utf8_lossy(&child.stdout); + Preview::new( + &entry.name, + content.to_string(), + None, + u16::try_from(content.lines().count()).unwrap_or(u16::MAX), + ) + } else { + let content = String::from_utf8_lossy(&child.stderr); + Preview::new( + &entry.name, + content.to_string(), + None, + u16::try_from(content.lines().count()).unwrap_or(u16::MAX), + ) + } + }; + notify + .send(preview) + .expect("Unable to send preview result to main thread."); +} diff --git a/television/previewer/state.rs b/television/previewer/state.rs new file mode 100644 index 0000000..e632fa4 --- /dev/null +++ b/television/previewer/state.rs @@ -0,0 +1,58 @@ +use crate::previewer::Preview; + +#[derive(Debug, Clone, Default)] +pub struct PreviewState { + pub enabled: bool, + pub preview: Preview, + pub scroll: u16, + pub target_line: Option, +} + +const PREVIEW_MIN_SCROLL_LINES: u16 = 3; + +impl PreviewState { + pub fn new( + enabled: bool, + preview: Preview, + scroll: u16, + target_line: Option, + ) -> Self { + PreviewState { + enabled, + 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 = Preview::default(); + self.scroll = 0; + self.target_line = None; + } + + pub fn update( + &mut self, + preview: Preview, + scroll: u16, + target_line: Option, + ) { + if self.preview.title != preview.title { + self.preview = preview; + self.scroll = scroll; + self.target_line = target_line; + } + } +} diff --git a/television/render.rs b/television/render.rs index c4796c4..c2a7145 100644 --- a/television/render.rs +++ b/television/render.rs @@ -11,7 +11,7 @@ use crate::draw::Ctx; use crate::screen::layout::Layout; use crate::{action::Action, draw::draw, tui::Tui}; -#[derive(Debug, PartialEq, PartialOrd, Ord, Eq, Clone)] +#[derive(Debug, Clone)] pub enum RenderingTask { ClearScreen, Render(Box), @@ -89,15 +89,29 @@ pub async fn render( tui.enter()?; let mut buffer = Vec::with_capacity(256); + let mut num_instructions; let mut frame_start; // Rendering loop 'rendering: while render_rx.recv_many(&mut buffer, 256).await > 0 { frame_start = std::time::Instant::now(); - // deduplicate events - buffer.sort_unstable(); - buffer.dedup(); - for event in buffer.drain(..) { + num_instructions = buffer.len(); + if let Some(last_render) = buffer + .iter() + .rfind(|e| matches!(e, RenderingTask::Render(_))) + { + buffer.push(last_render.clone()); + } + + for event in buffer + .drain(..) + .enumerate() + .filter(|(i, e)| { + !matches!(e, RenderingTask::Render(_)) + || *i == num_instructions + }) + .map(|(_, val)| val) + { match event { RenderingTask::ClearScreen => { tui.terminal.clear()?; diff --git a/television/screen/preview.rs b/television/screen/preview.rs index eb2dff6..23b4ad8 100644 --- a/television/screen/preview.rs +++ b/television/screen/preview.rs @@ -1,5 +1,4 @@ -use crate::preview::PreviewState; -use crate::preview::{PreviewContent, LOADING_MSG, TIMEOUT_MSG}; +use crate::previewer::state::PreviewState; use crate::screen::colors::Colorscheme; use crate::utils::strings::{ replace_non_printable, shrink_with_ellipsis, ReplaceNonPrintableConfig, @@ -12,14 +11,10 @@ use ratatui::widgets::{Block, BorderType, Borders, Padding, Paragraph}; use ratatui::Frame; use ratatui::{ layout::{Alignment, Rect}, - prelude::{Color, Line, Modifier, Span, Style, Stylize, Text}, + prelude::{Color, Line, Span, Style, Stylize, Text}, }; use std::str::FromStr; -#[allow(dead_code)] -const FILL_CHAR_SLANTED: char = '╱'; -const FILL_CHAR_EMPTY: char = ' '; - #[allow(clippy::too_many_arguments)] pub fn draw_preview_content_block( f: &mut Frame, @@ -38,7 +33,6 @@ pub fn draw_preview_content_block( )?; // render the preview content let rp = build_preview_paragraph( - inner, &preview_state.preview.content, preview_state.target_line, preview_state.scroll, @@ -49,8 +43,7 @@ pub fn draw_preview_content_block( } pub fn build_preview_paragraph( - inner: Rect, - preview_content: &PreviewContent, + preview_content: &str, #[allow(unused_variables)] target_line: Option, preview_scroll: u16, ) -> Paragraph<'_> { @@ -62,25 +55,7 @@ pub fn build_preview_paragraph( left: 1, }); - match preview_content { - PreviewContent::AnsiText(text) => { - build_ansi_text_paragraph(text, preview_block, preview_scroll) - } - // meta - PreviewContent::Loading => { - build_meta_preview_paragraph(inner, LOADING_MSG, FILL_CHAR_EMPTY) - .block(preview_block) - .alignment(Alignment::Left) - .style(Style::default().add_modifier(Modifier::ITALIC)) - } - PreviewContent::Timeout => { - build_meta_preview_paragraph(inner, TIMEOUT_MSG, FILL_CHAR_EMPTY) - .block(preview_block) - .alignment(Alignment::Left) - .style(Style::default().add_modifier(Modifier::ITALIC)) - } - PreviewContent::Empty => Paragraph::new(Text::raw(EMPTY_STRING)), - } + build_ansi_text_paragraph(preview_content, preview_block, preview_scroll) } const ANSI_BEFORE_CONTEXT_SIZE: u16 = 10; diff --git a/television/television.rs b/television/television.rs index 1c30cda..9cd9fa1 100644 --- a/television/television.rs +++ b/television/television.rs @@ -10,7 +10,10 @@ use crate::{ draw::{ChannelState, Ctx, TvState}, input::convert_action_to_input_request, picker::Picker, - preview::{previewer::Previewer, Preview, PreviewState}, + previewer::{ + state::PreviewState, Config as PreviewerConfig, Preview, Previewer, + Request as PreviewRequest, Ticket, + }, render::UiState, screen::{ colors::Colorscheme, @@ -25,8 +28,9 @@ use anyhow::Result; use rustc_hash::{FxBuildHasher, FxHashSet}; use serde::{Deserialize, Serialize}; use std::collections::HashSet; -use std::sync::Arc; -use tokio::sync::mpsc::UnboundedSender; +use tokio::sync::mpsc::{ + unbounded_channel, UnboundedReceiver, UnboundedSender, +}; #[derive(PartialEq, Copy, Clone, Hash, Eq, Debug, Serialize, Deserialize)] pub enum Mode { @@ -46,12 +50,14 @@ pub struct Television { pub channel: CableChannel, pub remote_control: Option, pub mode: Mode, + pub currently_selected: Option, pub current_pattern: String, pub matching_mode: MatchingMode, pub results_picker: Picker, pub rc_picker: Picker, - pub previewer: Option, pub preview_state: PreviewState, + pub preview_handles: + Option<(UnboundedSender, UnboundedReceiver)>, pub spinner: Spinner, pub spinner_state: SpinnerState, pub app_metadata: AppMetadata, @@ -79,7 +85,9 @@ impl Television { results_picker = results_picker.inverted(); } - let previewer: Option = (&channel_prototype).into(); + // previewer + let preview_handles = Self::setup_previewer(&channel_prototype); + let mut channel: CableChannel = channel_prototype.into(); let app_metadata = AppMetadata::new( @@ -96,7 +104,7 @@ impl Television { let preview_state = PreviewState::new( channel.supports_preview(), - Arc::new(Preview::default()), + Preview::default(), 0, None, ); @@ -124,12 +132,13 @@ impl Television { channel, remote_control, mode: Mode::Channel, + currently_selected: None, current_pattern: EMPTY_STRING.to_string(), results_picker, matching_mode, rc_picker: Picker::default(), - previewer, preview_state, + preview_handles, spinner, spinner_state: SpinnerState::from(&spinner), app_metadata, @@ -140,6 +149,26 @@ impl Television { } } + fn setup_previewer( + channel_prototype: &ChannelPrototype, + ) -> Option<(UnboundedSender, UnboundedReceiver)> + { + if channel_prototype.preview_command.is_some() { + let (pv_request_tx, pv_request_rx) = unbounded_channel(); + let (pv_preview_tx, pv_preview_rx) = unbounded_channel(); + let previewer = Previewer::new( + channel_prototype.preview_command().unwrap(), + PreviewerConfig::default(), + pv_request_rx, + pv_preview_tx, + ); + tokio::spawn(async move { previewer.run().await }); + Some((pv_request_tx, pv_preview_rx)) + } else { + None + } + } + pub fn update_ui_state(&mut self, ui_state: UiState) { self.ui_state = ui_state; } @@ -153,11 +182,13 @@ impl Television { ); let tv_state = TvState::new( self.mode, - self.get_selected_entry(Some(Mode::Channel)), + self.currently_selected.clone(), self.results_picker.clone(), self.rc_picker.clone(), channel_state, self.spinner, + // PERF: we shouldn't need to clone the whole preview here but only + // what's in range of the preview window self.preview_state.clone(), ); @@ -184,7 +215,12 @@ impl Television { self.reset_picker_input(); self.current_pattern = EMPTY_STRING.to_string(); self.channel.shutdown(); - self.previewer = (&channel_prototype).into(); + if let Some((sender, _)) = &self.preview_handles { + sender + .send(PreviewRequest::Shutdown) + .expect("Failed to send shutdown signal to previewer"); + } + self.preview_handles = Self::setup_previewer(&channel_prototype); self.channel = channel_prototype.into(); } @@ -367,46 +403,45 @@ impl Television { pub fn update_preview_state( &mut self, - selected_entry: &Entry, + selected_entry: &Option, ) -> Result<()> { - if self.config.ui.show_preview_panel - && self.channel.supports_preview() - // FIXME: this is probably redundant with the channel supporting previews - && self.previewer.is_some() - { - // avoid sending unnecessary requests to the previewer - if self.preview_state.preview.title != selected_entry.name { - if let Some(preview) = - self.previewer.as_mut().unwrap().request(selected_entry) - { - self.preview_state.update( - preview, - // scroll to center the selected entry - selected_entry - .line_number - .unwrap_or(0) - .saturating_sub( - (self - .ui_state - .layout - .preview_window - .map_or(0, |w| w.height) - / 2) - .into(), - ) - .try_into() - // if the scroll doesn't fit in a u16, just scroll to the top - // this is a current limitation of ratatui - .unwrap_or(0), - selected_entry - .line_number - .and_then(|l| l.try_into().ok()), - ); - self.action_tx.send(Action::Render)?; - } - } - } else { + if selected_entry.is_none() { self.preview_state.reset(); + return Ok(()); + } + if let Some((sender, receiver)) = &mut self.preview_handles { + // preview requests + if *selected_entry != self.currently_selected { + sender.send(PreviewRequest::Preview(Ticket::new( + selected_entry.as_ref().unwrap().clone(), + )))?; + } + // available previews + let entry = selected_entry.as_ref().unwrap(); + if let Ok(preview) = receiver.try_recv() { + self.preview_state.update( + preview, + // scroll to center the selected entry + entry + .line_number + .unwrap_or(0) + .saturating_sub( + (self + .ui_state + .layout + .preview_window + .map_or(0, |w| w.height) + / 2) + .into(), + ) + .try_into() + // if the scroll doesn't fit in a u16, just scroll to the top + // this is a current limitation of ratatui + .unwrap_or(0), + entry.line_number.and_then(|l| l.try_into().ok()), + ); + self.action_tx.send(Action::Render)?; + } } Ok(()) } @@ -461,7 +496,6 @@ impl Television { self.current_pattern.clone_from(&new_pattern); self.find(&new_pattern); self.reset_picker_selection(); - self.preview_state.reset(); } } _ => {} @@ -488,8 +522,8 @@ impl Television { 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 let Some(entry) = &self.currently_selected { + self.channel.toggle_selection(entry); if matches!(action, Action::ToggleSelectionDown) { self.select_next_entry(1); } else { @@ -625,12 +659,11 @@ impl Television { self.update_rc_picker_state(); } - if let Some(selected_entry) = - self.get_selected_entry(Some(Mode::Channel)) - { + if self.mode == Mode::Channel { + let selected_entry = self.get_selected_entry(None); self.update_preview_state(&selected_entry)?; + self.currently_selected = selected_entry; } - self.ticks += 1; Ok(if self.should_render(action) {