mirror of
https://github.com/alexpasmantier/television.git
synced 2025-06-03 01:50:12 +00:00
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:
parent
1a5fa5dd4c
commit
67c067ff40
@ -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)
|
||||
},
|
||||
|
@ -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),
|
||||
|
@ -95,6 +95,10 @@ impl ChannelPrototype {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn preview_command(&self) -> Option<PreviewCommand> {
|
||||
self.into()
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_PROTOTYPE_NAME: &str = "files";
|
||||
|
@ -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.
|
||||
///
|
||||
|
@ -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;
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -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,
|
||||
))
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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
234
television/previewer/mod.rs
Normal 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, ¬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<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.");
|
||||
}
|
58
television/previewer/state.rs
Normal file
58
television/previewer/state.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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()?;
|
||||
|
@ -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;
|
||||
|
@ -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) {
|
||||
|
Loading…
x
Reference in New Issue
Block a user