refactor(previewer): a much more efficient preview system for tv (#506)

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.
This commit is contained in:
Alexandre Pasmantier 2025-05-14 20:22:53 +02:00 committed by GitHub
parent 1a5fa5dd4c
commit 67c067ff40
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 441 additions and 494 deletions

View File

@ -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)
},

View File

@ -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<String>,
entries_command: String,
pub preview_command: Option<PreviewCommand>,
selected_entries: FxHashSet<Entry>,
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),

View File

@ -95,6 +95,10 @@ impl ChannelPrototype {
},
}
}
pub fn preview_command(&self) -> Option<PreviewCommand> {
self.into()
}
}
const DEFAULT_PROTOTYPE_NAME: &str = "files";

View File

@ -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<H: std::hash::Hasher>(&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<std::cmp::Ordering> {
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<H: std::hash::Hasher>(&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<std::cmp::Ordering> {
// 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.
///

View File

@ -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;

View File

@ -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<String, Arc<Preview>>,
ring_set: RingSet<String>,
}
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<Arc<Preview>> {
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<Preview>) {
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<F>(&mut self, key: String, f: F) -> Arc<Preview>
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)
}
}

View File

@ -1,21 +0,0 @@
use crate::preview::{Preview, PreviewContent};
use std::sync::Arc;
#[allow(dead_code)]
pub fn loading(title: &str) -> Arc<Preview> {
Arc::new(Preview::new(
title.to_string(),
PreviewContent::Loading,
None,
1,
))
}
pub fn timeout(title: &str) -> Arc<Preview> {
Arc::new(Preview::new(
title.to_string(),
PreviewContent::Timeout,
None,
1,
))
}

View File

@ -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<FileIcon>,
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<FileIcon>,
total_lines: u16,
) -> Self {
Preview {
title,
content,
icon,
total_lines,
}
}
}
#[derive(Debug, Clone, PartialEq, Hash)]
pub struct PreviewState {
pub enabled: bool,
pub preview: Arc<Preview>,
pub scroll: u16,
pub target_line: Option<u16>,
}
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<Preview>,
scroll: u16,
target_line: Option<u16>,
) -> 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<Preview>,
scroll: u16,
target_line: Option<u16>,
) {
if self.preview.title != preview.title {
self.preview = preview;
self.scroll = scroll;
self.target_line = target_line;
}
}
}

View File

@ -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<Mutex<PreviewCache>>,
concurrent_preview_tasks: Arc<AtomicU8>,
in_flight_previews: Arc<Mutex<FxHashSet<String>>>,
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<Arc<Preview>> {
// 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<Arc<Preview>> {
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<Mutex<PreviewCache>>,
concurrent_tasks: &Arc<AtomicU8>,
in_flight_previews: &Arc<Mutex<FxHashSet<String>>>,
) {
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<Previewer> {
fn from(value: &ChannelPrototype) -> Self {
Option::<PreviewCommand>::from(value).map(Previewer::new)
}
}

234
television/previewer/mod.rs Normal file
View File

@ -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<Ordering> {
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<Ordering> {
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<FileIcon>,
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<FileIcon>,
total_lines: u16,
) -> Self {
Self {
title: title.to_string(),
content,
icon,
total_lines,
}
}
}
pub struct Previewer {
config: Config,
requests: UnboundedReceiver<Request>,
last_job_entry: Option<Entry>,
preview_command: PreviewCommand,
previews: UnboundedSender<Preview>,
}
impl Previewer {
pub fn new(
preview_command: PreviewCommand,
config: Config,
receiver: UnboundedReceiver<Request>,
sender: UnboundedSender<Preview>,
) -> 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, &notify);
}),
)
.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<Preview>,
) {
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.");
}

View File

@ -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<u16>,
}
const PREVIEW_MIN_SCROLL_LINES: u16 = 3;
impl PreviewState {
pub fn new(
enabled: bool,
preview: Preview,
scroll: u16,
target_line: Option<u16>,
) -> 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<u16>,
) {
if self.preview.title != preview.title {
self.preview = preview;
self.scroll = scroll;
self.target_line = target_line;
}
}
}

View File

@ -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<Ctx>),
@ -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()?;

View File

@ -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<u16>,
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;

View File

@ -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<RemoteControl>,
pub mode: Mode,
pub currently_selected: Option<Entry>,
pub current_pattern: String,
pub matching_mode: MatchingMode,
pub results_picker: Picker,
pub rc_picker: Picker,
pub previewer: Option<Previewer>,
pub preview_state: PreviewState,
pub preview_handles:
Option<(UnboundedSender<PreviewRequest>, UnboundedReceiver<Preview>)>,
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<Previewer> = (&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<PreviewRequest>, UnboundedReceiver<Preview>)>
{
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<Entry>,
) -> 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) {