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) {