refactor(draw): isolating the draw method, avoiding mutexes, and perf improvements

This commit is contained in:
Alexandre Pasmantier 2025-01-26 21:18:41 +01:00
parent eaab4e966b
commit 2172d20b7b
38 changed files with 936 additions and 790 deletions

View File

@ -203,3 +203,4 @@ toggle_preview = "ctrl-o"
# controls which keybinding should trigger tv
# for command history
"command_history" = "ctrl-r"

View File

@ -1,7 +1,7 @@
pub mod main {
pub mod draw;
pub mod results_list_benchmark;
pub mod draw_results_list;
}
pub use main::*;
criterion::criterion_main!(results_list_benchmark::benches, draw::benches,);
criterion::criterion_main!(draw_results_list::benches, draw::benches,);

View File

@ -3,6 +3,7 @@ use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
use ratatui::Terminal;
use std::path::PathBuf;
use television::action::Action;
use television::channels::OnAir;
use television::channels::{files::Channel, TelevisionChannel};
use television::config::Config;
@ -22,24 +23,30 @@ fn draw(c: &mut Criterion) {
let config = Config::new().unwrap();
let backend = TestBackend::new(width, height);
let terminal = Terminal::new(backend).unwrap();
let (tx, _) = tokio::sync::mpsc::unbounded_channel();
let mut channel =
TelevisionChannel::Files(Channel::new(vec![
PathBuf::from("."),
]));
channel.find("television");
// Wait for the channel to finish loading
let mut tv = Television::new(tx, channel, config, None);
for _ in 0..5 {
// tick the matcher
let _ = channel.results(10, 0);
let _ = tv.channel.results(10, 0);
std::thread::sleep(std::time::Duration::from_millis(10));
}
let mut tv = Television::new(channel, config, None);
tv.select_next_entry(10);
let _ = tv.update_preview_state(
&tv.get_selected_entry(None).unwrap(),
);
tv.update(&Action::Tick).unwrap();
(tv, terminal)
},
// Measurement
|(mut tv, mut terminal)| async move {
tv.draw(
|(tv, mut terminal)| async move {
television::draw::draw(
black_box(&tv.dump_context()),
black_box(&mut terminal.get_frame()),
black_box(Rect::new(0, 0, width, height)),
)

View File

@ -4,14 +4,12 @@ use ratatui::layout::Alignment;
use ratatui::prelude::{Line, Style};
use ratatui::style::Color;
use ratatui::widgets::{Block, BorderType, Borders, ListDirection, Padding};
use rustc_hash::FxHashMap;
use television::channels::entry::merge_ranges;
use television::channels::entry::{Entry, PreviewType};
use television::screen::colors::ResultsColorscheme;
use television::screen::results::build_results_list;
pub fn results_list_benchmark(c: &mut Criterion) {
let mut icon_color_cache = FxHashMap::default();
pub fn draw_results_list(c: &mut Criterion) {
// FIXME: there's probably a way to have this as a benchmark asset
// possible as a JSON file and to load it for the benchmark using Serde
// I don't know how exactly right now just having it here instead
@ -656,11 +654,10 @@ pub fn results_list_benchmark(c: &mut Criterion) {
None,
ListDirection::BottomToTop,
false,
&mut icon_color_cache,
&colorscheme,
);
});
});
}
criterion_group!(benches, results_list_benchmark);
criterion_group!(benches, draw_results_list);

View File

@ -323,7 +323,7 @@ fn impl_unit_channel(ast: &syn::DeriveInput) -> TokenStream {
// Generate a unit enum from the given enum
let unit_enum = quote! {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, EnumString)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Hash)]
#[strum(serialize_all = "kebab_case")]
pub enum UnitChannel {
#(

View File

@ -1,16 +1,14 @@
use rustc_hash::FxHashSet;
use std::sync::Arc;
use crate::screen::mode::Mode;
use anyhow::Result;
use tokio::sync::{mpsc, Mutex};
use tracing::{debug, info};
use tokio::sync::mpsc;
use tracing::{debug, info, trace};
use crate::channels::entry::Entry;
use crate::channels::TelevisionChannel;
use crate::config::{parse_key, Config};
use crate::keymap::Keymap;
use crate::television::Television;
use crate::television::{Mode, Television};
use crate::{
action::Action,
event::{Event, EventLoop, Key},
@ -23,9 +21,8 @@ pub struct App {
// maybe move these two into config instead of passing them
// via the cli?
tick_rate: f64,
frame_rate: f64,
/// The television instance that handles channels and entries.
television: Arc<Mutex<Television>>,
television: Television,
/// A flag that indicates whether the application should quit during the next frame.
should_quit: bool,
/// A flag that indicates whether the application should suspend during the next frame.
@ -92,7 +89,6 @@ impl App {
let (render_tx, _) = mpsc::unbounded_channel();
let (_, event_rx) = mpsc::unbounded_channel();
let (event_abort_tx, _) = mpsc::unbounded_channel();
let frame_rate = config.config.frame_rate;
let tick_rate = config.config.tick_rate;
let keymap = Keymap::from(&config.keybindings).with_mode_mappings(
Mode::Channel,
@ -106,12 +102,11 @@ impl App {
)?;
debug!("{:?}", keymap);
let television =
Arc::new(Mutex::new(Television::new(channel, config, input)));
Television::new(action_tx.clone(), channel, config, input);
Ok(Self {
keymap,
tick_rate,
frame_rate,
television,
should_quit: false,
should_suspend: false,
@ -148,19 +143,10 @@ impl App {
let (render_tx, render_rx) = mpsc::unbounded_channel();
self.render_tx = render_tx.clone();
let action_tx_r = self.action_tx.clone();
let television_r = self.television.clone();
let frame_rate = self.frame_rate;
let rendering_task = tokio::spawn(async move {
render(
render_rx,
action_tx_r,
television_r,
frame_rate,
is_output_tty,
)
.await
render(render_rx, action_tx_r, is_output_tty).await
});
render_tx.send(RenderingTask::Render)?;
self.action_tx.send(Action::Render)?;
// event handling loop
debug!("Starting event handling loop");
@ -168,14 +154,11 @@ impl App {
loop {
// handle event and convert to action
if let Some(event) = self.event_rx.recv().await {
let action = self.convert_event_to_action(event).await;
let action = self.convert_event_to_action(event);
action_tx.send(action)?;
// it's fine to send a rendering task here, because the rendering loop
// batches and deduplicates rendering tasks
render_tx.send(RenderingTask::Render)?;
}
let action_outcome = self.handle_actions().await?;
let action_outcome = self.handle_actions()?;
if self.should_quit {
// send a termination signal to the event loop
@ -199,7 +182,7 @@ impl App {
///
/// # Returns
/// The action that corresponds to the given event.
async fn convert_event_to_action(&self, event: Event<Key>) -> Action {
fn convert_event_to_action(&self, event: Event<Key>) -> Action {
match event {
Event::Input(keycode) => {
info!("{:?}", keycode);
@ -219,7 +202,7 @@ impl App {
}
// get action based on keybindings
self.keymap
.get(&self.television.lock().await.mode)
.get(&self.television.mode)
.and_then(|keymap| keymap.get(&keycode).cloned())
.unwrap_or(if let Key::Char(c) = keycode {
Action::AddInputChar(c)
@ -246,10 +229,10 @@ impl App {
///
/// # Errors
/// If an error occurs during the execution of the application.
async fn handle_actions(&mut self) -> Result<ActionOutcome> {
fn handle_actions(&mut self) -> Result<ActionOutcome> {
while let Ok(action) = self.action_rx.try_recv() {
if action != Action::Tick && action != Action::Render {
debug!("{action:?}");
if action != Action::Tick {
trace!("{action:?}");
}
match action {
Action::Quit => {
@ -269,15 +252,13 @@ impl App {
self.render_tx.send(RenderingTask::Quit)?;
if let Some(entries) = self
.television
.lock()
.await
.get_selected_entries(Some(Mode::Channel))
{
return Ok(ActionOutcome::Entries(entries));
}
return Ok(ActionOutcome::Input(
self.television.lock().await.current_pattern.clone(),
self.television.current_pattern.clone(),
));
}
Action::SelectPassthrough(passthrough) => {
@ -285,8 +266,6 @@ impl App {
self.render_tx.send(RenderingTask::Quit)?;
if let Some(entries) = self
.television
.lock()
.await
.get_selected_entries(Some(Mode::Channel))
{
return Ok(ActionOutcome::Passthrough(
@ -303,14 +282,14 @@ impl App {
self.render_tx.send(RenderingTask::Resize(w, h))?;
}
Action::Render => {
self.render_tx.send(RenderingTask::Render)?;
self.render_tx.send(RenderingTask::Render(
self.television.dump_context(),
))?;
}
_ => {}
}
// forward action to the television handler
if let Some(action) =
self.television.lock().await.update(action.clone()).await?
{
if let Some(action) = self.television.update(&action)? {
self.action_tx.send(action)?;
};
}

View File

@ -264,8 +264,8 @@ const MAX_FILE_SIZE: u64 = 4 * 1024 * 1024;
/// This is a soft limit, we might go over it a bit.
///
/// A typical line should take somewhere around 100 bytes in memory (for utf8 english text),
/// so this should take around 100 x `5_000_000` = 500MB of memory.
const MAX_LINES_IN_MEM: usize = 5_000_000;
/// so this should take around 100 x `10_000_000` = 1GB of memory.
const MAX_LINES_IN_MEM: usize = 10_000_000;
#[allow(clippy::unused_async)]
async fn crawl_for_candidates(

View File

@ -1,13 +1,14 @@
use crate::action::Action;
use crate::event::{convert_raw_event_to_key, Key};
use crate::screen::mode::Mode;
use crate::television::Mode;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use rustc_hash::FxHashMap;
use serde::{Deserialize, Deserializer};
use std::fmt::Display;
use std::hash::Hash;
use std::ops::{Deref, DerefMut};
#[derive(Clone, Debug, Deserialize)]
#[derive(Clone, Debug, Deserialize, PartialEq, Hash)]
pub enum Binding {
SingleKey(Key),
MultipleKeys(Vec<Key>),
@ -28,9 +29,16 @@ impl Display for Binding {
}
}
#[derive(Clone, Debug, Default)]
#[derive(Clone, Debug, Default, PartialEq)]
pub struct KeyBindings(pub FxHashMap<Mode, FxHashMap<Action, Binding>>);
impl Hash for KeyBindings {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
// we're not actually using this for hashing, so this really only is a placeholder
state.write_u8(0);
}
}
impl Deref for KeyBindings {
type Target = FxHashMap<Mode, FxHashMap<Action, Binding>>;
fn deref(&self) -> &Self::Target {

View File

@ -1,5 +1,5 @@
#![allow(clippy::module_name_repetitions, clippy::ref_option)]
use std::{env, path::PathBuf};
use std::{env, hash::Hash, path::PathBuf};
use anyhow::Result;
use directories::ProjectDirs;
@ -11,7 +11,7 @@ use serde::Deserialize;
use shell_integration::ShellIntegrationConfig;
pub use themes::Theme;
use tracing::{debug, warn};
use ui::UiConfig;
pub use ui::UiConfig;
mod keybindings;
mod previewers;
@ -22,7 +22,7 @@ mod ui;
const DEFAULT_CONFIG: &str = include_str!("../../.config/config.toml");
#[allow(dead_code, clippy::module_name_repetitions)]
#[derive(Clone, Debug, Deserialize, Default)]
#[derive(Clone, Debug, Deserialize, Default, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct AppConfig {
#[serde(default = "get_data_dir")]
@ -35,8 +35,17 @@ pub struct AppConfig {
pub tick_rate: f64,
}
impl Hash for AppConfig {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.data_dir.hash(state);
self.config_dir.hash(state);
self.frame_rate.to_bits().hash(state);
self.tick_rate.to_bits().hash(state);
}
}
#[allow(dead_code)]
#[derive(Clone, Debug, Deserialize, Default)]
#[derive(Clone, Debug, Deserialize, Default, PartialEq, Hash)]
#[serde(deny_unknown_fields)]
pub struct Config {
/// General application configuration
@ -77,7 +86,6 @@ lazy_static! {
const CONFIG_FILE_NAME: &str = "config.toml";
impl Config {
// FIXME: default management is a bit of a mess right now
#[allow(clippy::missing_panics_doc, clippy::missing_errors_doc)]
pub fn new() -> Result<Self> {
// Load the default_config values as base defaults

View File

@ -1,7 +1,7 @@
use crate::preview::{previewers, PreviewerConfig};
use serde::Deserialize;
#[derive(Clone, Debug, Deserialize, Default)]
#[derive(Clone, Debug, Deserialize, Default, PartialEq, Hash)]
pub struct PreviewersConfig {
#[serde(default)]
pub basic: BasicPreviewerConfig,
@ -17,10 +17,10 @@ impl From<PreviewersConfig> for PreviewerConfig {
}
}
#[derive(Clone, Debug, Deserialize, Default)]
#[derive(Clone, Debug, Deserialize, Default, PartialEq, Hash)]
pub struct BasicPreviewerConfig {}
#[derive(Clone, Debug, Deserialize)]
#[derive(Clone, Debug, Deserialize, PartialEq, Hash)]
#[serde(default)]
pub struct FilePreviewerConfig {
//pub max_file_size: u64,
@ -36,5 +36,5 @@ impl Default for FilePreviewerConfig {
}
}
#[derive(Clone, Debug, Deserialize, Default)]
#[derive(Clone, Debug, Deserialize, Default, PartialEq, Hash)]
pub struct EnvVarPreviewerConfig {}

View File

@ -1,14 +1,24 @@
use std::hash::Hash;
use crate::config::parse_key;
use crate::event::Key;
use rustc_hash::FxHashMap;
use serde::Deserialize;
#[derive(Clone, Debug, Deserialize, Default)]
#[derive(Clone, Debug, Deserialize, Default, PartialEq)]
#[serde(default)]
pub struct ShellIntegrationConfig {
pub commands: FxHashMap<String, String>,
pub keybindings: FxHashMap<String, String>,
}
impl Hash for ShellIntegrationConfig {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
// we're not actually using this for hashing, so this really only is a placeholder
state.write_u8(0);
}
}
const SMART_AUTOCOMPLETE_CONFIGURATION_KEY: &str = "smart_autocomplete";
const COMMAND_HISTORY_CONFIGURATION_KEY: &str = "command_history";
const DEFAULT_SHELL_AUTOCOMPLETE_KEY: char = 'T';

View File

@ -6,7 +6,7 @@ use super::themes::DEFAULT_THEME;
const DEFAULT_UI_SCALE: u16 = 100;
#[derive(Clone, Debug, Deserialize)]
#[derive(Clone, Debug, Deserialize, PartialEq, Hash)]
#[serde(default)]
pub struct UiConfig {
pub use_nerd_font_icons: bool,

265
television/draw.rs Normal file
View File

@ -0,0 +1,265 @@
use std::{hash::Hash, time::Instant};
use anyhow::Result;
use ratatui::{layout::Rect, Frame};
use rustc_hash::FxHashSet;
use tokio::sync::mpsc::Sender;
use crate::{
action::Action,
channels::{
entry::{Entry, PreviewType, ENTRY_PLACEHOLDER},
UnitChannel,
},
config::Config,
picker::Picker,
preview::PreviewState,
screen::{
colors::Colorscheme, help::draw_help_bar, input::draw_input_box,
keybindings::build_keybindings_table, layout::Layout,
preview::draw_preview_content_block,
remote_control::draw_remote_control, results::draw_results_list,
spinner::Spinner,
},
television::{Message, Mode},
utils::metadata::AppMetadata,
};
#[derive(Debug, Clone, PartialEq)]
pub struct ChannelState {
pub current_channel: UnitChannel,
pub selected_entries: FxHashSet<Entry>,
pub total_count: u32,
pub running: bool,
}
impl ChannelState {
pub fn new(
current_channel: UnitChannel,
selected_entries: FxHashSet<Entry>,
total_count: u32,
running: bool,
) -> Self {
Self {
current_channel,
selected_entries,
total_count,
running,
}
}
}
impl Hash for ChannelState {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.current_channel.hash(state);
self.selected_entries
.iter()
.for_each(|entry| entry.hash(state));
self.total_count.hash(state);
self.running.hash(state);
}
}
#[derive(Debug, Clone, PartialEq, Hash)]
pub struct TvState {
pub mode: Mode,
pub selected_entry: Option<Entry>,
pub results_area_height: u16,
pub results_picker: Picker,
pub rc_picker: Picker,
pub channel_state: ChannelState,
pub spinner: Spinner,
pub preview_state: PreviewState,
}
impl TvState {
#[allow(clippy::too_many_arguments)]
pub fn new(
mode: Mode,
selected_entry: Option<Entry>,
results_area_height: u16,
results_picker: Picker,
rc_picker: Picker,
channel_state: ChannelState,
spinner: Spinner,
preview_state: PreviewState,
) -> Self {
Self {
mode,
selected_entry,
results_area_height,
results_picker,
rc_picker,
channel_state,
spinner,
preview_state,
}
}
}
#[derive(Debug, Clone)]
pub struct Ctx {
pub tv_state: TvState,
pub config: Config,
pub colorscheme: Colorscheme,
pub app_metadata: AppMetadata,
pub tv_tx_handle: Sender<Message>,
pub instant: Instant,
}
impl Ctx {
pub fn new(
tv_state: TvState,
config: Config,
colorscheme: Colorscheme,
app_metadata: AppMetadata,
tv_tx_handle: Sender<Message>,
instant: Instant,
) -> Self {
Self {
tv_state,
config,
colorscheme,
app_metadata,
tv_tx_handle,
instant,
}
}
}
impl PartialEq for Ctx {
fn eq(&self, other: &Self) -> bool {
self.tv_state == other.tv_state
&& self.config == other.config
&& self.colorscheme == other.colorscheme
&& self.app_metadata == other.app_metadata
}
}
impl Eq for Ctx {}
impl Hash for Ctx {
fn hash<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)
}
}
pub fn draw(ctx: &Ctx, f: &mut Frame<'_>, area: Rect) -> Result<()> {
let selected_entry = ctx
.tv_state
.selected_entry
.clone()
.unwrap_or(ENTRY_PLACEHOLDER);
let show_preview = ctx.config.ui.show_preview_panel
&& !matches!(selected_entry.preview_type, PreviewType::None);
let show_remote = !matches!(ctx.tv_state.mode, Mode::Channel);
let layout =
Layout::build(area, &ctx.config.ui, show_remote, show_preview);
// help bar (metadata, keymaps, logo)
draw_help_bar(
f,
&layout.help_bar,
ctx.tv_state.channel_state.current_channel,
build_keybindings_table(
&ctx.config.keybindings.to_displayable(),
ctx.tv_state.mode,
&ctx.colorscheme,
),
ctx.tv_state.mode,
&ctx.app_metadata,
&ctx.colorscheme,
);
if layout.results.height.saturating_sub(2)
!= ctx.tv_state.results_area_height
{
ctx.tv_tx_handle.try_send(Message::ResultListHeightChanged(
layout.results.height.saturating_sub(2),
))?;
}
// results list
draw_results_list(
f,
layout.results,
&ctx.tv_state.results_picker.entries,
&ctx.tv_state.channel_state.selected_entries,
&mut ctx.tv_state.results_picker.relative_state.clone(),
ctx.config.ui.input_bar_position,
ctx.config.ui.use_nerd_font_icons,
&ctx.colorscheme,
&ctx.config
.keybindings
.get(&ctx.tv_state.mode)
.unwrap()
.get(&Action::ToggleHelp)
// just display the first keybinding
.unwrap()
.to_string(),
&ctx.config
.keybindings
.get(&ctx.tv_state.mode)
.unwrap()
.get(&Action::TogglePreview)
// just display the first keybinding
.unwrap()
.to_string(),
)?;
// input box
draw_input_box(
f,
layout.input,
ctx.tv_state.results_picker.total_items,
ctx.tv_state.channel_state.total_count,
&ctx.tv_state.results_picker.input,
&ctx.tv_state.results_picker.state,
ctx.tv_state.channel_state.running,
&ctx.tv_state.spinner,
&ctx.colorscheme,
)?;
if show_preview {
draw_preview_content_block(
f,
layout.preview_window.unwrap(),
&ctx.tv_state.preview_state,
ctx.config.ui.use_nerd_font_icons,
&ctx.colorscheme,
)?;
}
// remote control
if show_remote {
draw_remote_control(
f,
layout.remote_control.unwrap(),
&ctx.tv_state.rc_picker.entries,
ctx.config.ui.use_nerd_font_icons,
&mut ctx.tv_state.rc_picker.state.clone(),
&mut ctx.tv_state.rc_picker.input.clone(),
&ctx.tv_state.mode,
&ctx.colorscheme,
)?;
}
Ok(())
}

View File

@ -195,7 +195,8 @@ impl EventLoop {
tx.send(Event::FocusGained).unwrap_or_else(|_| warn!("Unable to send FocusGained event"));
},
Ok(crossterm::event::Event::Resize(x, y)) => {
tx.send(Event::Resize(x, y)).unwrap_or_else(|_| warn!("Unable to send Resize event"));
let (_, (new_x, new_y)) = flush_resize_events((x, y));
tx.send(Event::Resize(new_x, new_y)).unwrap_or_else(|_| warn!("Unable to send Resize event"));
},
_ => {}
}
@ -214,6 +215,22 @@ impl EventLoop {
}
}
// Resize events can occur in batches.
// With a simple loop they can be flushed.
// This function will keep the first and last resize event.
fn flush_resize_events(first_resize: (u16, u16)) -> ((u16, u16), (u16, u16)) {
let mut last_resize = first_resize;
while let Ok(true) = crossterm::event::poll(Duration::from_millis(50)) {
if let Ok(crossterm::event::Event::Resize(x, y)) =
crossterm::event::read()
{
last_resize = (x, y);
}
}
(first_resize, last_resize)
}
pub fn convert_raw_event_to_key(event: KeyEvent) -> Key {
debug!("Raw event: {:?}", event);
if event.kind == KeyEventKind::Release {

View File

@ -1,7 +1,7 @@
use rustc_hash::FxHashMap;
use std::ops::Deref;
use crate::screen::mode::Mode;
use crate::television::Mode;
use anyhow::Result;
use crate::action::Action;

View File

@ -4,6 +4,7 @@ pub mod cable;
pub mod channels;
pub mod cli;
pub mod config;
pub mod draw;
pub mod errors;
pub mod event;
pub mod input;

View File

@ -57,8 +57,6 @@ async fn main() -> Result<()> {
config.config.tick_rate =
args.tick_rate.unwrap_or(config.config.tick_rate);
config.config.frame_rate =
args.frame_rate.unwrap_or(config.config.frame_rate);
if args.no_preview {
config.ui.show_preview_panel = false;
}

View File

@ -1,12 +1,17 @@
use crate::utils::{input::Input, strings::EMPTY_STRING};
use crate::{
channels::entry::Entry,
utils::{input::Input, strings::EMPTY_STRING},
};
use ratatui::widgets::ListState;
#[derive(Debug)]
#[derive(Debug, Clone, PartialEq, Hash)]
pub struct Picker {
pub(crate) state: ListState,
pub(crate) relative_state: ListState,
inverted: bool,
pub(crate) input: Input,
pub entries: Vec<Entry>,
pub total_items: u32,
}
impl Default for Picker {
@ -22,6 +27,8 @@ impl Picker {
relative_state: ListState::default(),
inverted: false,
input: Input::new(input.unwrap_or(EMPTY_STRING.to_string())),
entries: Vec::new(),
total_items: 0,
}
}
@ -99,7 +106,7 @@ impl Picker {
let selected = self.selected().unwrap_or(0);
let relative_selected = self.relative_selected().unwrap_or(0);
self.select(Some(selected.saturating_add(1) % total_items));
self.relative_select(Some((relative_selected + 1).min(height)));
self.relative_select(Some((relative_selected + 1).min(height - 1)));
if self.selected().unwrap() == 0 {
self.relative_select(Some(0));
}
@ -111,7 +118,7 @@ impl Picker {
self.select(Some((selected + (total_items - 1)) % total_items));
self.relative_select(Some(relative_selected.saturating_sub(1)));
if self.selected().unwrap() == total_items - 1 {
self.relative_select(Some(height));
self.relative_select(Some((height - 1).min(total_items - 1)));
}
}
}
@ -129,7 +136,7 @@ mod tests {
let mut picker = Picker::default();
picker.select(Some(0));
picker.relative_select(Some(0));
picker.select_next(1, 4, 2);
picker.select_next(1, 4, 3);
assert_eq!(picker.selected(), Some(1), "selected");
assert_eq!(picker.relative_selected(), Some(1), "relative_selected");
}
@ -143,7 +150,7 @@ mod tests {
let mut picker = Picker::default();
picker.select(Some(1));
picker.relative_select(Some(1));
picker.select_next(1, 4, 2);
picker.select_next(1, 4, 3);
assert_eq!(picker.selected(), Some(2), "selected");
assert_eq!(picker.relative_selected(), Some(2), "relative_selected");
}
@ -157,7 +164,7 @@ mod tests {
let mut picker = Picker::default();
picker.select(Some(2));
picker.relative_select(Some(2));
picker.select_next(1, 4, 2);
picker.select_next(1, 4, 3);
assert_eq!(picker.selected(), Some(3), "selected");
assert_eq!(picker.relative_selected(), Some(2), "relative_selected");
}
@ -171,7 +178,7 @@ mod tests {
let mut picker = Picker::default();
picker.select(Some(3));
picker.relative_select(Some(2));
picker.select_next(1, 4, 2);
picker.select_next(1, 4, 3);
assert_eq!(picker.selected(), Some(0), "selected");
assert_eq!(picker.relative_selected(), Some(0), "relative_selected");
}
@ -185,7 +192,7 @@ mod tests {
let mut picker = Picker::default();
picker.select(Some(2));
picker.relative_select(Some(2));
picker.select_next(1, 3, 2);
picker.select_next(1, 3, 4);
assert_eq!(picker.selected(), Some(0), "selected");
assert_eq!(picker.relative_selected(), Some(0), "relative_selected");
}
@ -199,7 +206,7 @@ mod tests {
let mut picker = Picker::default();
picker.select(Some(1));
picker.relative_select(Some(1));
picker.select_prev(1, 4, 2);
picker.select_prev(1, 4, 3);
assert_eq!(picker.selected(), Some(0), "selected");
assert_eq!(picker.relative_selected(), Some(0), "relative_selected");
}
@ -213,7 +220,7 @@ mod tests {
let mut picker = Picker::default();
picker.select(Some(0));
picker.relative_select(Some(0));
picker.select_prev(1, 4, 2);
picker.select_prev(1, 4, 3);
assert_eq!(picker.selected(), Some(3), "selected");
assert_eq!(picker.relative_selected(), Some(2), "relative_selected");
}
@ -227,7 +234,7 @@ mod tests {
let mut picker = Picker::default();
picker.select(Some(3));
picker.relative_select(Some(2));
picker.select_prev(1, 4, 2);
picker.select_prev(1, 4, 3);
assert_eq!(picker.selected(), Some(2), "selected");
assert_eq!(picker.relative_selected(), Some(1), "relative_selected");
}
@ -241,7 +248,7 @@ mod tests {
let mut picker = Picker::default();
picker.select(Some(2));
picker.relative_select(Some(2));
picker.select_prev(1, 4, 2);
picker.select_prev(1, 4, 3);
assert_eq!(picker.selected(), Some(1), "selected");
assert_eq!(picker.relative_selected(), Some(1), "relative_selected");
}

View File

@ -19,7 +19,7 @@ pub use previewers::env::EnvVarPreviewerConfig;
pub use previewers::files::FilePreviewer;
pub use previewers::files::FilePreviewerConfig;
#[derive(Clone, Debug)]
#[derive(Clone, Debug, PartialEq, Hash)]
pub enum PreviewContent {
Empty,
FileTooLarge,
@ -60,7 +60,7 @@ pub const TIMEOUT_MSG: &str = "Preview timed out";
/// # Fields
/// - `title`: The title of the preview.
/// - `content`: The content of the preview.
#[derive(Clone, Debug)]
#[derive(Clone, Debug, PartialEq, Hash)]
pub struct Preview {
pub title: String,
pub content: PreviewContent,
@ -99,19 +99,58 @@ impl Preview {
total_lines,
}
}
}
pub fn total_lines(&self) -> u16 {
match &self.content {
PreviewContent::SyntectHighlightedText(hl_lines) => {
hl_lines.lines.len().try_into().unwrap_or(u16::MAX)
}
PreviewContent::PlainText(lines) => {
lines.len().try_into().unwrap_or(u16::MAX)
}
PreviewContent::AnsiText(text) => {
text.lines().count().try_into().unwrap_or(u16::MAX)
}
_ => 0,
#[derive(Debug, Default, Clone, PartialEq, Hash)]
pub struct PreviewState {
pub preview: Arc<Preview>,
pub scroll: u16,
pub target_line: Option<u16>,
}
const PREVIEW_MIN_SCROLL_LINES: u16 = 3;
impl PreviewState {
pub fn new(
preview: Arc<Preview>,
scroll: u16,
target_line: Option<u16>,
) -> Self {
PreviewState {
preview,
scroll,
target_line,
}
}
pub fn scroll_down(&mut self, offset: u16) {
self.scroll = self.scroll.saturating_add(offset).min(
self.preview
.total_lines
.saturating_sub(PREVIEW_MIN_SCROLL_LINES),
);
}
pub fn scroll_up(&mut self, offset: u16) {
self.scroll = self.scroll.saturating_sub(offset);
}
pub fn reset(&mut self) {
self.preview = Arc::new(Preview::default());
self.scroll = 0;
self.target_line = None;
}
pub fn update(
&mut self,
preview: Arc<Preview>,
scroll: u16,
target_line: Option<u16>,
) {
if self.preview.title != preview.title {
self.preview = preview;
self.scroll = scroll;
self.target_line = target_line;
}
}
}
@ -150,7 +189,7 @@ impl PreviewerConfig {
}
}
const REQUEST_STACK_SIZE: usize = 20;
const REQUEST_STACK_SIZE: usize = 10;
impl Previewer {
pub fn new(config: Option<PreviewerConfig>) -> Self {
@ -174,15 +213,10 @@ impl Previewer {
}
}
fn cached(&self, entry: &Entry) -> Option<Arc<Preview>> {
match &entry.preview_type {
PreviewType::Files => self.file.cached(entry),
PreviewType::Command(_) => self.command.cached(entry),
PreviewType::Basic | PreviewType::EnvVar => None,
PreviewType::None => Some(Arc::new(Preview::default())),
}
}
// we could use a target scroll here to make the previewer
// faster, but since it's already running in the background and quite
// fast for most standard file sizes, plus we're caching the previews,
// I'm not sure the extra complexity is worth it.
pub fn preview(&mut self, entry: &Entry) -> Option<Arc<Preview>> {
// if we haven't acknowledged the request yet, acknowledge it
self.requests.push(entry.clone());
@ -190,9 +224,10 @@ impl Previewer {
if let Some(preview) = self.dispatch_request(entry) {
return Some(preview);
}
// lookup request stack and return the most recent preview available
for request in self.requests.back_to_front() {
if let Some(preview) = self.cached(&request) {
if let Some(preview) = self.dispatch_request(&request) {
return Some(preview);
}
}

View File

@ -12,7 +12,7 @@ use std::sync::{
};
use syntect::{highlighting::Theme, parsing::SyntaxSet};
use tracing::{debug, warn};
use tracing::{debug, trace, warn};
use crate::channels::entry;
use crate::preview::cache::PreviewCache;
@ -80,7 +80,7 @@ impl FilePreviewer {
pub fn preview(&mut self, entry: &entry::Entry) -> Option<Arc<Preview>> {
if let Some(preview) = self.cached(entry) {
debug!("Preview cache hit for {:?}", entry.name);
trace!("Preview cache hit for {:?}", entry.name);
if preview.partial_offset.is_some() {
// preview is partial, spawn a task to compute the next chunk
// and return the partial preview
@ -90,7 +90,7 @@ impl FilePreviewer {
Some(preview)
} else {
// preview is not in cache, spawn a task to compute the preview
debug!("Preview cache miss for {:?}", entry.name);
trace!("Preview cache miss for {:?}", entry.name);
self.handle_preview_request(entry, None);
None
}
@ -102,7 +102,7 @@ impl FilePreviewer {
partial_preview: Option<Arc<Preview>>,
) {
if self.in_flight_previews.lock().contains(&entry.name) {
debug!("Preview already in flight for {:?}", entry.name);
trace!("Preview already in flight for {:?}", entry.name);
}
if self.concurrent_preview_tasks.load(Ordering::Relaxed)
@ -139,7 +139,7 @@ impl FilePreviewer {
/// The size of the buffer used to read the file in bytes.
/// This ends up being the max size of partial previews.
const PARTIAL_BUFREAD_SIZE: usize = 64 * 1024;
const PARTIAL_BUFREAD_SIZE: usize = 5 * 1024 * 1024;
pub fn try_preview(
entry: &entry::Entry,

View File

@ -1,20 +1,19 @@
use anyhow::Result;
use crossterm::terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate};
use crossterm::{execute, queue};
use ratatui::layout::Rect;
use std::{
io::{stderr, stdout, LineWriter},
sync::Arc,
};
use std::io::{stderr, stdout, LineWriter};
use tracing::{debug, warn};
use tokio::sync::{mpsc, Mutex};
use tokio::sync::mpsc;
use crate::television::Television;
use crate::{action::Action, tui::Tui};
use crate::draw::Ctx;
use crate::{action::Action, draw::draw, tui::Tui};
#[derive(Debug, PartialEq, PartialOrd, Ord, Eq, Clone)]
pub enum RenderingTask {
ClearScreen,
Render,
Render(Ctx),
Resize(u16, u16),
Resume,
Suspend,
@ -39,8 +38,6 @@ impl IoStream {
pub async fn render(
mut render_rx: mpsc::UnboundedReceiver<RenderingTask>,
action_tx: mpsc::UnboundedSender<Action>,
television: Arc<Mutex<Television>>,
frame_rate: f64,
is_output_tty: bool,
) -> Result<()> {
let stream = if is_output_tty {
@ -50,21 +47,15 @@ pub async fn render(
debug!("Rendering to stderr");
IoStream::BufferedStderr.to_stream()
};
let mut tui = Tui::new(stream)?.frame_rate(frame_rate);
let mut tui = Tui::new(stream)?;
debug!("Entering tui");
tui.enter()?;
debug!("Registering action handler");
television
.lock()
.await
.register_action_handler(action_tx.clone())?;
let mut buffer = Vec::with_capacity(128);
let mut buffer = Vec::with_capacity(256);
// Rendering loop
'rendering: while render_rx.recv_many(&mut buffer, 128).await > 0 {
'rendering: while render_rx.recv_many(&mut buffer, 256).await > 0 {
// deduplicate events
buffer.sort_unstable();
buffer.dedup();
@ -73,17 +64,17 @@ pub async fn render(
RenderingTask::ClearScreen => {
tui.terminal.clear()?;
}
RenderingTask::Render => {
RenderingTask::Render(context) => {
if let Ok(size) = tui.size() {
// Ratatui uses `u16`s to encode terminal dimensions and its
// content for each terminal cell is stored linearly in a
// buffer with a `u16` index which means we can't support
// terminal areas larger than `u16::MAX`.
if size.width.checked_mul(size.height).is_some() {
let mut television = television.lock().await;
queue!(stderr(), BeginSynchronizedUpdate).ok();
tui.terminal.draw(|frame| {
if let Err(err) =
television.draw(frame, frame.area())
draw(&context, frame, frame.area())
{
warn!("Failed to draw: {:?}", err);
let _ = action_tx.send(Action::Error(
@ -91,6 +82,7 @@ pub async fn render(
));
}
})?;
execute!(stderr(), EndSynchronizedUpdate).ok();
} else {
warn!("Terminal area too large");
}

View File

@ -1,6 +1,6 @@
use ratatui::style::Color;
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Colorscheme {
pub general: GeneralColorscheme,
pub help: HelpColorscheme,
@ -10,19 +10,19 @@ pub struct Colorscheme {
pub mode: ModeColorscheme,
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct GeneralColorscheme {
pub border_fg: Color,
pub background: Option<Color>,
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct HelpColorscheme {
pub metadata_field_name_fg: Color,
pub metadata_field_value_fg: Color,
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ResultsColorscheme {
pub result_name_fg: Color,
pub result_preview_fg: Color,
@ -32,7 +32,7 @@ pub struct ResultsColorscheme {
pub match_foreground_color: Color,
}
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct PreviewColorscheme {
pub title_fg: Color,
pub highlight_bg: Color,
@ -41,13 +41,13 @@ pub struct PreviewColorscheme {
pub gutter_selected_fg: Color,
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct InputColorscheme {
pub input_fg: Color,
pub results_count_fg: Color,
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ModeColorscheme {
pub channel: Color,
pub remote_control: Color,

View File

@ -3,7 +3,8 @@ use crate::channels::UnitChannel;
use crate::screen::colors::{Colorscheme, GeneralColorscheme};
use crate::screen::logo::build_logo_paragraph;
use crate::screen::metadata::build_metadata_table;
use crate::screen::mode::{mode_color, Mode};
use crate::screen::mode::mode_color;
use crate::television::Mode;
use crate::utils::metadata::AppMetadata;
use ratatui::layout::Rect;
use ratatui::prelude::{Color, Style};

View File

@ -10,10 +10,7 @@ use ratatui::{
Frame,
};
use crate::screen::{
colors::Colorscheme,
spinner::{Spinner, SpinnerState},
};
use crate::screen::{colors::Colorscheme, spinner::Spinner};
// TODO: refactor arguments (e.g. use a struct for the spinner+state, same
#[allow(clippy::too_many_arguments)]
@ -22,11 +19,10 @@ pub fn draw_input_box(
rect: Rect,
results_count: u32,
total_count: u32,
input_state: &mut Input,
results_picker_state: &mut ListState,
input_state: &Input,
results_picker_state: &ListState,
matcher_running: bool,
spinner: &Spinner,
spinner_state: &mut SpinnerState,
colorscheme: &Colorscheme,
) -> Result<()> {
let input_block = Block::default()
@ -89,11 +85,7 @@ pub fn draw_input_box(
f.render_widget(input, inner_input_chunks[1]);
if matcher_running {
f.render_stateful_widget(
spinner,
inner_input_chunks[3],
spinner_state,
);
f.render_widget(spinner, inner_input_chunks[3]);
}
let result_count_block = Block::default();
@ -119,8 +111,9 @@ pub fn draw_input_box(
// specified coordinates after rendering
f.set_cursor_position((
// Put cursor past the end of the input text
inner_input_chunks[1].x
+ u16::try_from(input_state.visual_cursor().max(scroll) - scroll)?,
inner_input_chunks[1].x.saturating_add(u16::try_from(
input_state.visual_cursor().max(scroll) - scroll,
)?),
// Move one line down, from the border to the input line
inner_input_chunks[1].y,
));

View File

@ -1,7 +1,8 @@
use rustc_hash::FxHashMap;
use std::fmt::Display;
use crate::screen::{colors::Colorscheme, mode::Mode};
use crate::screen::colors::Colorscheme;
use crate::television::Mode;
use ratatui::{
layout::Constraint,
style::{Color, Style},

View File

@ -4,6 +4,8 @@ use ratatui::layout;
use ratatui::layout::{Constraint, Direction, Rect};
use serde::Deserialize;
use crate::config::UiConfig;
pub struct Dimensions {
pub x: u16,
pub y: u16,
@ -44,7 +46,7 @@ impl HelpBarLayout {
}
}
#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq)]
#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Hash)]
pub enum InputPosition {
#[serde(rename = "top")]
Top,
@ -62,7 +64,7 @@ impl Display for InputPosition {
}
}
#[derive(Debug, Clone, Copy, Deserialize, Default)]
#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Hash)]
pub enum PreviewTitlePosition {
#[serde(rename = "top")]
#[default]
@ -107,19 +109,20 @@ impl Layout {
}
pub fn build(
dimensions: &Dimensions,
area: Rect,
with_remote: bool,
with_help_bar: bool,
with_preview: bool,
input_position: InputPosition,
ui_config: &UiConfig,
show_remote: bool,
show_preview: bool,
//
) -> Self {
let show_preview = show_preview && ui_config.show_preview_panel;
let dimensions = Dimensions::from(ui_config.ui_scale);
let main_block = centered_rect(dimensions.x, dimensions.y, area);
// split the main block into two vertical chunks (help bar + rest)
let main_rect: Rect;
let help_bar_layout: Option<HelpBarLayout>;
if with_help_bar {
if ui_config.show_help_bar {
let hz_chunks = layout::Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Max(9), Constraint::Fill(1)])
@ -152,10 +155,10 @@ impl Layout {
// split the main block into 1, 2, or 3 vertical chunks
// (results + preview + remote)
let mut constraints = vec![Constraint::Fill(1)];
if with_preview {
if show_preview {
constraints.push(Constraint::Fill(1));
}
if with_remote {
if show_remote {
// in order to fit with the help bar logo
constraints.push(Constraint::Length(24));
}
@ -170,28 +173,28 @@ impl Layout {
let left_chunks = layout::Layout::default()
.direction(Direction::Vertical)
.constraints(match input_position {
.constraints(match ui_config.input_bar_position {
InputPosition::Top => {
results_constraints.into_iter().rev().collect()
}
InputPosition::Bottom => results_constraints,
})
.split(vt_chunks[0]);
let (input, results) = match input_position {
let (input, results) = match ui_config.input_bar_position {
InputPosition::Bottom => (left_chunks[1], left_chunks[0]),
InputPosition::Top => (left_chunks[0], left_chunks[1]),
};
// right block: preview title + preview
let mut remote_idx = 1;
let preview_window = if with_preview {
let preview_window = if show_preview {
remote_idx += 1;
Some(vt_chunks[1])
} else {
None
};
let remote_control = if with_remote {
let remote_control = if show_remote {
Some(vt_chunks[remote_idx])
} else {
None

View File

@ -1,10 +1,8 @@
use std::fmt::Display;
use crate::channels::UnitChannel;
use crate::screen::{
colors::Colorscheme,
mode::{mode_color, Mode},
};
use crate::screen::{colors::Colorscheme, mode::mode_color};
use crate::television::Mode;
use crate::utils::metadata::AppMetadata;
use ratatui::{
layout::Constraint,

View File

@ -1,7 +1,6 @@
use ratatui::style::Color;
use serde::{Deserialize, Serialize};
use crate::screen::colors::ModeColorscheme;
use crate::{screen::colors::ModeColorscheme, television::Mode};
pub fn mode_color(mode: Mode, colorscheme: &ModeColorscheme) -> Color {
match mode {
@ -10,11 +9,3 @@ pub fn mode_color(mode: Mode, colorscheme: &ModeColorscheme) -> Color {
Mode::SendToChannel => colorscheme.send_to_channel,
}
}
// FIXME: Mode shouldn't be in the screen crate
#[derive(PartialEq, Copy, Clone, Hash, Eq, Debug, Serialize, Deserialize)]
pub enum Mode {
Channel,
RemoteControl,
SendToChannel,
}

View File

@ -1,12 +1,9 @@
use crate::channels::entry::Entry;
use crate::preview::PreviewState;
use crate::preview::{
ansi::IntoText, Preview, PreviewContent, FILE_TOO_LARGE_MSG, LOADING_MSG,
ansi::IntoText, PreviewContent, FILE_TOO_LARGE_MSG, LOADING_MSG,
PREVIEW_NOT_SUPPORTED_MSG, TIMEOUT_MSG,
};
use crate::screen::{
cache::RenderedPreviewCache,
colors::{Colorscheme, PreviewColorscheme},
};
use crate::screen::colors::{Colorscheme, PreviewColorscheme};
use crate::utils::strings::{
replace_non_printable, shrink_with_ellipsis, ReplaceNonPrintableConfig,
EMPTY_STRING,
@ -20,19 +17,46 @@ use ratatui::{
prelude::{Color, Line, Modifier, Span, Style, Stylize, Text},
};
use std::str::FromStr;
use std::sync::{Arc, Mutex};
#[allow(dead_code)]
const FILL_CHAR_SLANTED: char = '';
const FILL_CHAR_EMPTY: char = ' ';
#[allow(clippy::needless_pass_by_value)]
#[allow(clippy::too_many_arguments)]
pub fn draw_preview_content_block(
f: &mut Frame,
rect: Rect,
preview_state: &PreviewState,
use_nerd_font_icons: bool,
colorscheme: &Colorscheme,
) -> Result<()> {
let inner = draw_content_outer_block(
f,
rect,
colorscheme,
preview_state.preview.icon,
&preview_state.preview.title,
use_nerd_font_icons,
)?;
// render the preview content
let rp = build_preview_paragraph(
inner,
&preview_state.preview.content,
preview_state.target_line,
preview_state.scroll,
colorscheme,
);
f.render_widget(rp, inner);
Ok(())
}
pub fn build_preview_paragraph<'a>(
inner: Rect,
preview_content: PreviewContent,
preview_content: &'a PreviewContent,
target_line: Option<u16>,
preview_scroll: u16,
colorscheme: Colorscheme,
colorscheme: &'a Colorscheme,
) -> Paragraph<'a> {
let preview_block =
Block::default().style(Style::default()).padding(Padding {
@ -58,14 +82,16 @@ pub fn build_preview_paragraph<'a>(
preview_block,
colorscheme.preview,
)
.scroll((preview_scroll, 0))
}
PreviewContent::SyntectHighlightedText(highlighted_lines) => {
build_syntect_highlighted_paragraph(
highlighted_lines.lines,
&highlighted_lines.lines,
preview_block,
target_line,
preview_scroll,
colorscheme.preview,
inner.height,
)
}
// meta
@ -104,12 +130,11 @@ pub fn build_preview_paragraph<'a>(
const ANSI_BEFORE_CONTEXT_SIZE: u16 = 10;
const ANSI_CONTEXT_SIZE: usize = 150;
#[allow(clippy::needless_pass_by_value)]
fn build_ansi_text_paragraph(
text: String,
preview_block: Block,
fn build_ansi_text_paragraph<'a>(
text: &'a str,
preview_block: Block<'a>,
preview_scroll: u16,
) -> Paragraph {
) -> Paragraph<'a> {
let lines = text.lines();
let skip =
preview_scroll.saturating_sub(ANSI_BEFORE_CONTEXT_SIZE) as usize;
@ -137,14 +162,13 @@ fn build_ansi_text_paragraph(
.scroll((preview_scroll, 0))
}
#[allow(clippy::needless_pass_by_value)]
fn build_plain_text_paragraph(
text: Vec<String>,
preview_block: Block<'_>,
fn build_plain_text_paragraph<'a>(
text: &'a [String],
preview_block: Block<'a>,
target_line: Option<u16>,
preview_scroll: u16,
colorscheme: PreviewColorscheme,
) -> Paragraph<'_> {
) -> Paragraph<'a> {
let mut lines = Vec::new();
for (i, line) in text.iter().enumerate() {
lines.push(Line::from(vec![
@ -179,12 +203,11 @@ fn build_plain_text_paragraph(
.scroll((preview_scroll, 0))
}
#[allow(clippy::needless_pass_by_value)]
fn build_plain_text_wrapped_paragraph(
text: String,
preview_block: Block<'_>,
fn build_plain_text_wrapped_paragraph<'a>(
text: &'a str,
preview_block: Block<'a>,
colorscheme: PreviewColorscheme,
) -> Paragraph<'_> {
) -> Paragraph<'a> {
let mut lines = Vec::new();
for line in text.lines() {
lines.push(Line::styled(
@ -198,22 +221,24 @@ fn build_plain_text_wrapped_paragraph(
.wrap(Wrap { trim: true })
}
#[allow(clippy::needless_pass_by_value)]
fn build_syntect_highlighted_paragraph(
highlighted_lines: Vec<Vec<(syntect::highlighting::Style, String)>>,
preview_block: Block,
fn build_syntect_highlighted_paragraph<'a>(
highlighted_lines: &'a [Vec<(syntect::highlighting::Style, String)>],
preview_block: Block<'a>,
target_line: Option<u16>,
preview_scroll: u16,
colorscheme: PreviewColorscheme,
) -> Paragraph {
height: u16,
) -> Paragraph<'a> {
compute_paragraph_from_highlighted_lines(
&highlighted_lines,
highlighted_lines,
target_line.map(|l| l as usize),
preview_scroll,
colorscheme,
height,
)
.block(preview_block)
.alignment(Alignment::Left)
.scroll((preview_scroll, 0))
//.scroll((preview_scroll, 0))
}
pub fn build_meta_preview_paragraph<'a>(
@ -325,109 +350,6 @@ fn draw_content_outer_block(
Ok(inner)
}
#[allow(clippy::too_many_arguments)]
pub fn draw_preview_content_block(
f: &mut Frame,
rect: Rect,
entry: &Entry,
preview: &Option<Arc<Preview>>,
rendered_preview_cache: &Arc<Mutex<RenderedPreviewCache<'static>>>,
preview_scroll: u16,
use_nerd_font_icons: bool,
colorscheme: &Colorscheme,
) -> Result<()> {
if let Some(preview) = preview {
let inner = draw_content_outer_block(
f,
rect,
colorscheme,
preview.icon,
&preview.title,
use_nerd_font_icons,
)?;
// check if the rendered preview content is already in the cache
let cache_key = compute_cache_key(entry);
if let Some(rp) =
rendered_preview_cache.lock().unwrap().get(&cache_key)
{
// we got a hit, render the cached preview content
let p = rp.paragraph.as_ref().clone();
f.render_widget(p.scroll((preview_scroll, 0)), inner);
return Ok(());
}
// render the preview content and cache it
let rp = build_preview_paragraph(
//preview_inner_block,
inner,
preview.content.clone(),
entry.line_number.map(|l| u16::try_from(l).unwrap_or(0)),
preview_scroll,
colorscheme.clone(),
);
// only cache the preview content if it's not a partial preview
// and the preview title matches the entry name
if preview.partial_offset.is_none() && preview.title == entry.name {
rendered_preview_cache.lock().unwrap().insert(
cache_key,
preview.icon,
&preview.title,
&Arc::new(rp.clone()),
);
}
f.render_widget(rp.scroll((preview_scroll, 0)), inner);
return Ok(());
}
// else if last_preview exists
if let Some(last_preview) =
&rendered_preview_cache.lock().unwrap().last_preview
{
let inner = draw_content_outer_block(
f,
rect,
colorscheme,
last_preview.icon,
&last_preview.title,
use_nerd_font_icons,
)?;
f.render_widget(
last_preview
.paragraph
.as_ref()
.clone()
.scroll((preview_scroll, 0)),
inner,
);
return Ok(());
}
// otherwise render empty preview
let inner = draw_content_outer_block(
f,
rect,
colorscheme,
None,
"",
use_nerd_font_icons,
)?;
let preview_outer_block = Block::default()
.title_top(Line::from(Span::styled(
" Preview ",
Style::default().fg(colorscheme.preview.title_fg),
)))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(colorscheme.general.border_fg))
.style(
Style::default()
.bg(colorscheme.general.background.unwrap_or_default()),
)
.padding(Padding::new(0, 1, 1, 0));
f.render_widget(preview_outer_block, inner);
Ok(())
}
fn build_line_number_span<'a>(line_number: usize) -> Span<'a> {
Span::from(format!("{line_number:5} "))
}
@ -435,11 +357,15 @@ fn build_line_number_span<'a>(line_number: usize) -> Span<'a> {
fn compute_paragraph_from_highlighted_lines(
highlighted_lines: &[Vec<(syntect::highlighting::Style, String)>],
line_specifier: Option<usize>,
preview_scroll: u16,
colorscheme: PreviewColorscheme,
height: u16,
) -> Paragraph<'static> {
let preview_lines: Vec<Line> = highlighted_lines
.iter()
.enumerate()
.skip(preview_scroll.saturating_sub(1).into())
.take(height.into())
.map(|(i, l)| {
let line_number =
build_line_number_span(i + 1).style(Style::default().fg(
@ -501,11 +427,3 @@ fn convert_syn_color_to_ratatui_color(
) -> Color {
Color::Rgb(color.r, color.g, color.b)
}
fn compute_cache_key(entry: &Entry) -> String {
let mut cache_key = entry.name.clone();
if let Some(line_number) = entry.line_number {
cache_key.push_str(&line_number.to_string());
}
cache_key
}

View File

@ -1,10 +1,9 @@
use rustc_hash::FxHashMap;
use crate::channels::entry::Entry;
use crate::screen::colors::{Colorscheme, GeneralColorscheme};
use crate::screen::logo::build_remote_logo_paragraph;
use crate::screen::mode::{mode_color, Mode};
use crate::screen::mode::mode_color;
use crate::screen::results::build_results_list;
use crate::television::Mode;
use crate::utils::input::Input;
use anyhow::Result;
@ -25,7 +24,6 @@ pub fn draw_remote_control(
use_nerd_font_icons: bool,
picker_state: &mut ListState,
input_state: &mut Input,
icon_color_cache: &mut FxHashMap<String, Color>,
mode: &Mode,
colorscheme: &Colorscheme,
) -> Result<()> {
@ -46,7 +44,6 @@ pub fn draw_remote_control(
entries,
use_nerd_font_icons,
picker_state,
icon_color_cache,
colorscheme,
);
draw_rc_input(f, layout[1], input_state, colorscheme)?;
@ -65,7 +62,6 @@ fn draw_rc_channels(
entries: &[Entry],
use_nerd_font_icons: bool,
picker_state: &mut ListState,
icon_color_cache: &mut FxHashMap<String, Color>,
colorscheme: &Colorscheme,
) {
let rc_block = Block::default()
@ -84,7 +80,6 @@ fn draw_rc_channels(
None,
ListDirection::TopToBottom,
use_nerd_font_icons,
icon_color_cache,
&colorscheme.results,
);

View File

@ -13,7 +13,7 @@ use ratatui::widgets::{
Block, BorderType, Borders, List, ListDirection, ListState, Padding,
};
use ratatui::Frame;
use rustc_hash::{FxHashMap, FxHashSet};
use rustc_hash::FxHashSet;
use std::str::FromStr;
const POINTER_SYMBOL: &str = "> ";
@ -26,7 +26,6 @@ pub fn build_results_list<'a, 'b>(
selected_entries: Option<&FxHashSet<Entry>>,
list_direction: ListDirection,
use_icons: bool,
icon_color_cache: &mut FxHashMap<String, Color>,
colorscheme: &ResultsColorscheme,
) -> List<'a>
where
@ -50,20 +49,10 @@ where
// optional icon
if let Some(icon) = entry.icon.as_ref() {
if use_icons {
if let Some(icon_color) = icon_color_cache.get(icon.color) {
spans.push(Span::styled(
icon.to_string(),
Style::default().fg(*icon_color),
));
} else {
let icon_color = Color::from_str(icon.color).unwrap();
icon_color_cache
.insert(icon.color.to_string(), icon_color);
spans.push(Span::styled(
icon.to_string(),
Style::default().fg(icon_color),
));
}
spans.push(Span::styled(
icon.to_string(),
Style::default().fg(Color::from_str(icon.color).unwrap()),
));
spans.push(Span::raw(" "));
}
@ -160,7 +149,6 @@ pub fn draw_results_list(
relative_picker_state: &mut ListState,
input_bar_position: InputPosition,
use_nerd_font_icons: bool,
icon_color_cache: &mut FxHashMap<String, Color>,
colorscheme: &Colorscheme,
help_keybinding: &str,
preview_keybinding: &str,
@ -191,7 +179,6 @@ pub fn draw_results_list(
InputPosition::Top => ListDirection::TopToBottom,
},
use_nerd_font_icons,
icon_color_cache,
&colorscheme.results,
);

View File

@ -1,23 +1,29 @@
use ratatui::{
buffer::Buffer, layout::Rect, style::Style, widgets::StatefulWidget,
};
use ratatui::{buffer::Buffer, layout::Rect, style::Style, widgets::Widget};
const FRAMES: &[&str] = &["", "", "", "", "", "", "", "", "", ""];
/// A spinner widget.
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone, Copy, PartialEq, Hash)]
pub struct Spinner {
frames: &'static [&'static str],
state: SpinnerState,
}
impl Spinner {
pub fn new(frames: &'static [&str]) -> Spinner {
Spinner { frames }
Spinner {
frames,
state: SpinnerState::new(frames.len()),
}
}
pub fn frame(&self, index: usize) -> &str {
self.frames[index]
}
pub fn tick(&mut self) {
self.state.tick();
}
}
impl Default for Spinner {
@ -26,7 +32,7 @@ impl Default for Spinner {
}
}
#[derive(Debug)]
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
pub struct SpinnerState {
pub current_frame: usize,
total_frames: usize,
@ -51,31 +57,24 @@ impl From<&Spinner> for SpinnerState {
}
}
impl StatefulWidget for Spinner {
type State = SpinnerState;
/// Renders the spinner in the given area.
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
impl Widget for Spinner {
fn render(self, area: Rect, buf: &mut Buffer) {
buf.set_string(
area.left(),
area.top(),
self.frame(state.current_frame),
self.frame(self.state.current_frame),
Style::default(),
);
state.tick();
}
}
impl StatefulWidget for &Spinner {
type State = SpinnerState;
/// Renders the spinner in the given area.
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
impl Widget for &Spinner {
fn render(self, area: Rect, buf: &mut Buffer) {
buf.set_string(
area.left(),
area.top(),
self.frame(state.current_frame),
self.frame(self.state.current_frame),
Style::default(),
);
state.tick();
}
}

View File

@ -1,63 +1,68 @@
use crate::action::Action;
use crate::cable::load_cable_channels;
use crate::channels::entry::{Entry, PreviewType, ENTRY_PLACEHOLDER};
use crate::channels::{
remote_control::{load_builtin_channels, RemoteControl},
OnAir, TelevisionChannel, UnitChannel,
};
use crate::config::{Config, KeyBindings, Theme};
use crate::draw::{ChannelState, Ctx, TvState};
use crate::input::convert_action_to_input_request;
use crate::picker::Picker;
use crate::preview::Previewer;
use crate::screen::cache::RenderedPreviewCache;
use crate::preview::{PreviewState, Previewer};
use crate::screen::colors::Colorscheme;
use crate::screen::help::draw_help_bar;
use crate::screen::input::draw_input_box;
use crate::screen::keybindings::{
build_keybindings_table, DisplayableAction, DisplayableKeybindings,
};
use crate::screen::layout::{Dimensions, InputPosition, Layout};
use crate::screen::mode::Mode;
use crate::screen::preview::draw_preview_content_block;
use crate::screen::remote_control::draw_remote_control;
use crate::screen::results::draw_results_list;
use crate::screen::keybindings::{DisplayableAction, DisplayableKeybindings};
use crate::screen::layout::InputPosition;
use crate::screen::spinner::{Spinner, SpinnerState};
use crate::utils::metadata::AppMetadata;
use crate::utils::strings::EMPTY_STRING;
use crate::{cable::load_cable_channels, keymap::Keymap};
use anyhow::Result;
use copypasta::{ClipboardContext, ClipboardProvider};
use ratatui::{layout::Rect, style::Color, Frame};
use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use tokio::sync::mpsc::UnboundedSender;
use tokio::sync::mpsc::{Receiver, Sender, UnboundedSender};
#[derive(PartialEq, Copy, Clone, Hash, Eq, Debug, Serialize, Deserialize)]
pub enum Mode {
Channel,
RemoteControl,
SendToChannel,
}
pub struct Television {
action_tx: Option<UnboundedSender<Action>>,
action_tx: UnboundedSender<Action>,
pub config: Config,
pub keymap: Keymap,
pub(crate) channel: TelevisionChannel,
pub(crate) remote_control: TelevisionChannel,
pub channel: TelevisionChannel,
pub remote_control: TelevisionChannel,
pub mode: Mode,
pub current_pattern: String,
pub(crate) results_picker: Picker,
pub(crate) rc_picker: Picker,
results_area_height: u32,
pub results_picker: Picker,
pub rc_picker: Picker,
results_area_height: u16,
pub previewer: Previewer,
pub preview_scroll: Option<u16>,
pub preview_pane_height: u16,
current_preview_total_lines: u16,
pub icon_color_cache: FxHashMap<String, Color>,
pub rendered_preview_cache: Arc<Mutex<RenderedPreviewCache<'static>>>,
pub(crate) spinner: Spinner,
pub(crate) spinner_state: SpinnerState,
pub preview_state: PreviewState,
pub spinner: Spinner,
pub spinner_state: SpinnerState,
pub app_metadata: AppMetadata,
pub colorscheme: Colorscheme,
pub ticks: u64,
// these are really here as a means to communicate between the render thread
// and the main thread to update `Television`'s state without needing to pass
// a mutable reference to `draw`
pub inner_rx: Receiver<Message>,
pub inner_tx: Sender<Message>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Message {
ResultListHeightChanged(u16),
}
impl Television {
#[must_use]
pub fn new(
action_tx: UnboundedSender<Action>,
mut channel: TelevisionChannel,
config: Config,
input: Option<String>,
@ -67,7 +72,6 @@ impl Television {
results_picker = results_picker.inverted();
}
let previewer = Previewer::new(Some(config.previewers.clone().into()));
let keymap = Keymap::from(&config.keybindings);
let cable_channels = load_cable_channels().unwrap_or_default();
let builtin_channels = load_builtin_channels(Some(
&cable_channels.keys().collect::<Vec<_>>(),
@ -84,10 +88,12 @@ impl Television {
channel.find(&input.unwrap_or(EMPTY_STRING.to_string()));
let spinner = Spinner::default();
// capacity is quite arbitrary here, we can adjust it later
let (inner_tx, inner_rx) = tokio::sync::mpsc::channel(10);
Self {
action_tx: None,
action_tx,
config,
keymap,
channel,
remote_control: TelevisionChannel::RemoteControl(
RemoteControl::new(builtin_channels, Some(cable_channels)),
@ -98,17 +104,14 @@ impl Television {
rc_picker: Picker::default(),
results_area_height: 0,
previewer,
preview_scroll: None,
preview_pane_height: 0,
current_preview_total_lines: 0,
icon_color_cache: FxHashMap::default(),
rendered_preview_cache: Arc::new(Mutex::new(
RenderedPreviewCache::default(),
)),
preview_state: PreviewState::default(),
spinner,
spinner_state: SpinnerState::from(&spinner),
app_metadata,
colorscheme,
ticks: 0,
inner_rx,
inner_tx,
}
}
@ -122,12 +125,41 @@ impl Television {
);
}
pub fn dump_context(&self) -> Ctx {
let channel_state = ChannelState::new(
self.current_channel(),
self.channel.selected_entries().clone(),
self.channel.total_count(),
self.channel.running(),
);
let tv_state = TvState::new(
self.mode,
self.get_selected_entry(Some(Mode::Channel)),
self.results_area_height,
self.results_picker.clone(),
self.rc_picker.clone(),
channel_state,
self.spinner,
self.preview_state.clone(),
);
Ctx::new(
tv_state,
self.config.clone(),
self.colorscheme.clone(),
self.app_metadata.clone(),
self.inner_tx.clone(),
// now timestamp
std::time::Instant::now(),
)
}
pub fn current_channel(&self) -> UnitChannel {
UnitChannel::from(&self.channel)
}
pub fn change_channel(&mut self, channel: TelevisionChannel) {
self.reset_preview_scroll();
self.preview_state.reset();
self.reset_picker_selection();
self.reset_picker_input();
self.current_pattern = EMPTY_STRING.to_string();
@ -147,7 +179,7 @@ impl Television {
}
#[must_use]
pub fn get_selected_entry(&mut self, mode: Option<Mode>) -> Option<Entry> {
pub fn get_selected_entry(&self, mode: Option<Mode>) -> Option<Entry> {
match mode.unwrap_or(self.mode) {
Mode::Channel => {
if let Some(i) = self.results_picker.selected() {
@ -168,7 +200,7 @@ impl Television {
#[must_use]
pub fn get_selected_entries(
&mut self,
&self,
mode: Option<Mode>,
) -> Option<FxHashSet<Entry>> {
if self.channel.selected_entries().is_empty()
@ -221,10 +253,6 @@ impl Television {
);
}
fn reset_preview_scroll(&mut self) {
self.preview_scroll = None;
}
fn reset_picker_selection(&mut self) {
match self.mode {
Mode::Channel => self.results_picker.reset_selection(),
@ -242,85 +270,232 @@ impl Television {
}
}
}
pub fn scroll_preview_down(&mut self, offset: u16) {
if self.preview_scroll.is_none() {
self.preview_scroll = Some(0);
}
if let Some(scroll) = self.preview_scroll {
self.preview_scroll = Some(
(scroll + offset).min(
self.current_preview_total_lines
.saturating_sub(2 * self.preview_pane_height / 3),
),
);
}
}
pub fn scroll_preview_up(&mut self, offset: u16) {
if let Some(scroll) = self.preview_scroll {
self.preview_scroll = Some(scroll.saturating_sub(offset));
}
}
}
const RENDER_EVERY_N_TICKS: u64 = 10;
impl Television {
/// Register an action handler that can send actions for processing if necessary.
///
/// # Arguments
/// * `tx` - An unbounded sender that can send actions.
///
/// # Returns
/// * `Result<()>` - An Ok result or an error.
pub fn register_action_handler(
fn should_render(&self, action: &Action) -> bool {
self.ticks == RENDER_EVERY_N_TICKS
|| matches!(
action,
Action::AddInputChar(_)
| Action::DeletePrevChar
| Action::DeletePrevWord
| Action::DeleteNextChar
| Action::GoToPrevChar
| Action::GoToNextChar
| Action::GoToInputStart
| Action::GoToInputEnd
| Action::ToggleSelectionDown
| Action::ToggleSelectionUp
| Action::ConfirmSelection
| Action::SelectNextEntry
| Action::SelectPrevEntry
| Action::SelectNextPage
| Action::SelectPrevPage
| Action::ScrollPreviewDown
| Action::ScrollPreviewUp
| Action::ScrollPreviewHalfPageDown
| Action::ScrollPreviewHalfPageUp
| Action::ToggleRemoteControl
| Action::ToggleSendToChannel
| Action::ToggleHelp
| Action::TogglePreview
| Action::CopyEntryToClipboard
)
|| self.channel.running()
}
pub fn update_preview_state(
&mut self,
tx: UnboundedSender<Action>,
selected_entry: &Entry,
) -> Result<()> {
self.action_tx = Some(tx);
if self.config.ui.show_preview_panel
&& !matches!(selected_entry.preview_type, PreviewType::None)
{
// preview content
if let Some(preview) = self.previewer.preview(selected_entry) {
if self.preview_state.preview.title != preview.title {
self.preview_state.update(
preview,
// scroll to center the selected entry
selected_entry
.line_number
.unwrap_or(0)
.saturating_sub(
(self.results_area_height / 2).into(),
)
.try_into()?,
selected_entry
.line_number
.and_then(|l| l.try_into().ok()),
);
self.action_tx.send(Action::Render)?;
}
}
} else {
self.preview_state.reset();
}
Ok(())
}
fn should_render(&self, action: &Action) -> bool {
matches!(
action,
Action::AddInputChar(_)
| Action::DeletePrevChar
| Action::DeletePrevWord
| Action::DeleteNextChar
| Action::GoToPrevChar
| Action::GoToNextChar
| Action::GoToInputStart
| Action::GoToInputEnd
| Action::ToggleSelectionDown
| Action::ToggleSelectionUp
| Action::ConfirmSelection
| Action::SelectNextEntry
| Action::SelectPrevEntry
| Action::SelectNextPage
| Action::SelectPrevPage
| Action::ScrollPreviewDown
| Action::ScrollPreviewUp
| Action::ScrollPreviewHalfPageDown
| Action::ScrollPreviewHalfPageUp
| Action::ToggleRemoteControl
| Action::ToggleSendToChannel
| Action::ToggleHelp
| Action::TogglePreview
| Action::CopyEntryToClipboard
) || self.channel.running()
pub fn update_results_picker_state(&mut self) {
if self.results_picker.selected().is_none()
&& self.channel.result_count() > 0
{
self.results_picker.select(Some(0));
self.results_picker.relative_select(Some(0));
}
self.results_picker.entries = self.channel.results(
self.results_area_height.into(),
u32::try_from(self.results_picker.offset()).unwrap(),
);
self.results_picker.total_items = self.channel.result_count();
}
#[allow(clippy::unused_async)]
/// Update the state of the component based on a received action.
///
/// # Arguments
/// * `action` - An action that may modify the state of the television.
///
/// # Returns
/// * `Result<Option<Action>>` - An action to be processed or none.
pub async fn update(&mut self, action: Action) -> Result<Option<Action>> {
pub fn update_rc_picker_state(&mut self) {
if self.rc_picker.selected().is_none()
&& self.remote_control.result_count() > 0
{
self.rc_picker.select(Some(0));
self.rc_picker.relative_select(Some(0));
}
self.rc_picker.entries = self.remote_control.results(
// this'll be more than the actual rc height but it's fine
self.results_area_height.into(),
u32::try_from(self.rc_picker.offset()).unwrap(),
);
self.rc_picker.total_items = self.remote_control.total_count();
}
pub fn handle_input_action(&mut self, action: &Action) {
let input = match self.mode {
Mode::Channel => &mut self.results_picker.input,
Mode::RemoteControl | Mode::SendToChannel => {
&mut self.rc_picker.input
}
};
input.handle(convert_action_to_input_request(action).unwrap());
match action {
Action::AddInputChar(_)
| Action::DeletePrevChar
| Action::DeletePrevWord
| Action::DeleteNextChar => {
let new_pattern = input.value().to_string();
if new_pattern != self.current_pattern {
self.current_pattern.clone_from(&new_pattern);
self.find(&new_pattern);
self.reset_picker_selection();
self.preview_state.reset();
}
}
_ => {}
}
}
pub fn handle_toggle_rc(&mut self) {
match self.mode {
Mode::Channel => {
self.mode = Mode::RemoteControl;
self.init_remote_control();
}
Mode::RemoteControl => {
// this resets the RC picker
self.reset_picker_input();
self.init_remote_control();
self.remote_control.find(EMPTY_STRING);
self.reset_picker_selection();
self.mode = Mode::Channel;
}
Mode::SendToChannel => {}
}
}
pub fn handle_toggle_send_to_channel(&mut self) {
match self.mode {
Mode::Channel | Mode::RemoteControl => {
self.mode = Mode::SendToChannel;
self.remote_control = TelevisionChannel::RemoteControl(
RemoteControl::with_transitions_from(&self.channel),
);
}
Mode::SendToChannel => {
self.reset_picker_input();
self.remote_control.find(EMPTY_STRING);
self.reset_picker_selection();
self.mode = Mode::Channel;
}
}
}
pub fn handle_toggle_selection(&mut self, action: &Action) {
if matches!(self.mode, Mode::Channel) {
if let Some(entry) = self.get_selected_entry(None) {
self.channel.toggle_selection(&entry);
if matches!(action, Action::ToggleSelectionDown) {
self.select_next_entry(1);
} else {
self.select_prev_entry(1);
}
}
}
}
pub fn handle_confirm_selection(&mut self) -> Result<()> {
match self.mode {
Mode::Channel => {
self.action_tx.send(Action::SelectAndExit)?;
}
Mode::RemoteControl => {
if let Some(entry) = self.get_selected_entry(None) {
let new_channel =
self.remote_control.zap(entry.name.as_str())?;
// this resets the RC picker
self.reset_picker_selection();
self.reset_picker_input();
self.remote_control.find(EMPTY_STRING);
self.mode = Mode::Channel;
self.change_channel(new_channel);
}
}
Mode::SendToChannel => {
if let Some(entry) = self.get_selected_entry(None) {
let new_channel = self
.channel
.transition_to(entry.name.as_str().try_into()?);
self.reset_picker_selection();
self.reset_picker_input();
self.remote_control.find(EMPTY_STRING);
self.mode = Mode::Channel;
self.change_channel(new_channel);
}
}
}
Ok(())
}
pub fn handle_copy_entry_to_clipboard(&mut self) {
if self.mode == Mode::Channel {
if let Some(entries) = self.get_selected_entries(None) {
let mut ctx = ClipboardContext::new().unwrap();
ctx.set_contents(
entries
.iter()
.map(|e| e.name.clone())
.collect::<Vec<_>>()
.join(" "),
)
.unwrap();
}
}
}
pub fn handle_action(&mut self, action: &Action) -> Result<()> {
// handle actions
match action {
// handle input actions
Action::AddInputChar(_)
| Action::DeletePrevChar
| Action::DeletePrevWord
@ -329,145 +504,47 @@ impl Television {
| Action::GoToInputStart
| Action::GoToNextChar
| Action::GoToPrevChar => {
let input = match self.mode {
Mode::Channel => &mut self.results_picker.input,
Mode::RemoteControl | Mode::SendToChannel => {
&mut self.rc_picker.input
}
};
input
.handle(convert_action_to_input_request(&action).unwrap());
match action {
Action::AddInputChar(_)
| Action::DeletePrevChar
| Action::DeletePrevWord
| Action::DeleteNextChar => {
let new_pattern = input.value().to_string();
if new_pattern != self.current_pattern {
self.current_pattern.clone_from(&new_pattern);
self.find(&new_pattern);
self.reset_picker_selection();
self.reset_preview_scroll();
}
}
_ => {}
}
self.handle_input_action(action);
}
Action::SelectNextEntry => {
self.reset_preview_scroll();
self.preview_state.reset();
self.select_next_entry(1);
}
Action::SelectPrevEntry => {
self.reset_preview_scroll();
self.preview_state.reset();
self.select_prev_entry(1);
}
Action::SelectNextPage => {
self.reset_preview_scroll();
self.select_next_entry(self.results_area_height);
self.preview_state.reset();
self.select_next_entry(self.results_area_height.into());
}
Action::SelectPrevPage => {
self.reset_preview_scroll();
self.select_prev_entry(self.results_area_height);
self.preview_state.reset();
self.select_prev_entry(self.results_area_height.into());
}
Action::ScrollPreviewDown => self.preview_state.scroll_down(1),
Action::ScrollPreviewUp => self.preview_state.scroll_up(1),
Action::ScrollPreviewHalfPageDown => {
self.preview_state.scroll_down(20);
}
Action::ScrollPreviewHalfPageUp => {
self.preview_state.scroll_up(20);
}
Action::ToggleRemoteControl => {
self.handle_toggle_rc();
}
Action::ScrollPreviewDown => self.scroll_preview_down(1),
Action::ScrollPreviewUp => self.scroll_preview_up(1),
Action::ScrollPreviewHalfPageDown => self.scroll_preview_down(20),
Action::ScrollPreviewHalfPageUp => self.scroll_preview_up(20),
Action::ToggleRemoteControl => match self.mode {
Mode::Channel => {
self.mode = Mode::RemoteControl;
self.init_remote_control();
}
Mode::RemoteControl => {
// this resets the RC picker
self.reset_picker_input();
self.init_remote_control();
self.remote_control.find(EMPTY_STRING);
self.reset_picker_selection();
self.mode = Mode::Channel;
}
Mode::SendToChannel => {}
},
Action::ToggleSelectionDown | Action::ToggleSelectionUp => {
if matches!(self.mode, Mode::Channel) {
if let Some(entry) = self.get_selected_entry(None) {
self.channel.toggle_selection(&entry);
if matches!(action, Action::ToggleSelectionDown) {
self.select_next_entry(1);
} else {
self.select_prev_entry(1);
}
}
}
self.handle_toggle_selection(action);
}
Action::ConfirmSelection => {
match self.mode {
Mode::Channel => {
self.action_tx
.as_ref()
.unwrap()
.send(Action::SelectAndExit)?;
}
Mode::RemoteControl => {
if let Some(entry) =
self.get_selected_entry(Some(Mode::RemoteControl))
{
let new_channel = self
.remote_control
.zap(entry.name.as_str())?;
// this resets the RC picker
self.reset_picker_selection();
self.reset_picker_input();
self.remote_control.find(EMPTY_STRING);
self.mode = Mode::Channel;
self.change_channel(new_channel);
}
}
Mode::SendToChannel => {
if let Some(entry) =
self.get_selected_entry(Some(Mode::RemoteControl))
{
let new_channel = self.channel.transition_to(
entry.name.as_str().try_into().unwrap(),
);
self.reset_picker_selection();
self.reset_picker_input();
self.remote_control.find(EMPTY_STRING);
self.mode = Mode::Channel;
self.change_channel(new_channel);
}
}
}
self.handle_confirm_selection()?;
}
Action::CopyEntryToClipboard => {
if self.mode == Mode::Channel {
if let Some(entries) = self.get_selected_entries(None) {
let mut ctx = ClipboardContext::new().unwrap();
ctx.set_contents(
entries
.iter()
.map(|e| e.name.clone())
.collect::<Vec<_>>()
.join(" "),
)
.unwrap();
}
}
self.handle_copy_entry_to_clipboard();
}
Action::ToggleSendToChannel => {
self.handle_toggle_send_to_channel();
}
Action::ToggleSendToChannel => match self.mode {
Mode::Channel | Mode::RemoteControl => {
self.mode = Mode::SendToChannel;
self.remote_control = TelevisionChannel::RemoteControl(
RemoteControl::with_transitions_from(&self.channel),
);
}
Mode::SendToChannel => {
self.reset_picker_input();
self.remote_control.find(EMPTY_STRING);
self.reset_picker_selection();
self.mode = Mode::Channel;
}
},
Action::ToggleHelp => {
self.config.ui.show_help_bar = !self.config.ui.show_help_bar;
}
@ -477,180 +554,45 @@ impl Television {
}
_ => {}
}
Ok(if self.should_render(&action) {
Some(Action::Render)
} else {
None
})
Ok(())
}
/// Render the television on the screen.
#[allow(clippy::unused_async)]
/// Update the television state based on the action provided.
///
/// # Arguments
/// * `f` - A frame used for rendering.
/// * `area` - The area in which the television should be drawn.
///
/// # Returns
/// * `Result<()>` - An Ok result or an error.
pub fn draw(&mut self, f: &mut Frame<'_>, area: Rect) -> Result<()> {
/// This function may return an Action that'll be processed by the parent `App`.
pub fn update(&mut self, action: &Action) -> Result<Option<Action>> {
if let Ok(Message::ResultListHeightChanged(height)) =
self.inner_rx.try_recv()
{
self.results_area_height = height;
self.action_tx.send(Action::Render)?;
}
let selected_entry = self
.get_selected_entry(Some(Mode::Channel))
.unwrap_or(ENTRY_PLACEHOLDER);
let layout = Layout::build(
&Dimensions::from(self.config.ui.ui_scale),
area,
!matches!(self.mode, Mode::Channel),
self.config.ui.show_help_bar,
self.config.ui.show_preview_panel
&& !matches!(selected_entry.preview_type, PreviewType::None),
self.config.ui.input_bar_position,
);
self.update_preview_state(&selected_entry)?;
// help bar (metadata, keymaps, logo)
draw_help_bar(
f,
&layout.help_bar,
self.current_channel(),
build_keybindings_table(
&self.config.keybindings.to_displayable(),
self.mode,
&self.colorscheme,
),
self.mode,
&self.app_metadata,
&self.colorscheme,
);
self.update_results_picker_state();
self.results_area_height =
u32::from(layout.results.height.saturating_sub(2)); // 2 for the borders
self.preview_pane_height = match layout.preview_window {
Some(preview) => preview.height,
None => 0,
};
self.update_rc_picker_state();
// results list
let result_count = self.channel.result_count();
if result_count > 0 && self.results_picker.selected().is_none() {
self.results_picker.select(Some(0));
self.results_picker.relative_select(Some(0));
}
let entries = self.channel.results(
self.results_area_height,
u32::try_from(self.results_picker.offset())?,
);
draw_results_list(
f,
layout.results,
&entries,
self.channel.selected_entries(),
&mut self.results_picker.relative_state,
self.config.ui.input_bar_position,
self.config.ui.use_nerd_font_icons,
&mut self.icon_color_cache,
&self.colorscheme,
&self
.config
.keybindings
.get(&self.mode)
.unwrap()
.get(&Action::ToggleHelp)
// just display the first keybinding
.unwrap()
.to_string(),
&self
.config
.keybindings
.get(&self.mode)
.unwrap()
.get(&Action::TogglePreview)
// just display the first keybinding
.unwrap()
.to_string(),
)?;
self.handle_action(action)?;
// input box
draw_input_box(
f,
layout.input,
result_count,
self.channel.total_count(),
&mut self.results_picker.input,
&mut self.results_picker.state,
self.channel.running(),
&self.spinner,
&mut self.spinner_state,
&self.colorscheme,
)?;
self.ticks += 1;
if self.config.ui.show_preview_panel
&& !matches!(selected_entry.preview_type, PreviewType::None)
{
// preview content
let maybe_preview = self.previewer.preview(&selected_entry);
let _ = self.previewer.preview(&selected_entry);
if let Some(preview) = &maybe_preview {
self.current_preview_total_lines = preview.total_lines;
// initialize preview scroll
self.maybe_init_preview_scroll(
selected_entry
.line_number
.map(|l| u16::try_from(l).unwrap_or(0)),
layout.preview_window.unwrap().height,
);
Ok(if self.should_render(action) {
if self.channel.running() {
self.spinner.tick();
}
self.ticks = 0;
draw_preview_content_block(
f,
layout.preview_window.unwrap(),
&selected_entry,
&maybe_preview,
&self.rendered_preview_cache,
self.preview_scroll.unwrap_or(0),
self.config.ui.use_nerd_font_icons,
&self.colorscheme,
)?;
}
// remote control
if matches!(self.mode, Mode::RemoteControl | Mode::SendToChannel) {
// NOTE: this should be done in the `update` method
let result_count = self.remote_control.result_count();
if result_count > 0 && self.rc_picker.selected().is_none() {
self.rc_picker.select(Some(0));
self.rc_picker.relative_select(Some(0));
}
let entries = self.remote_control.results(
area.height.saturating_sub(2).into(),
u32::try_from(self.rc_picker.offset())?,
);
draw_remote_control(
f,
layout.remote_control.unwrap(),
&entries,
self.config.ui.use_nerd_font_icons,
&mut self.rc_picker.state,
&mut self.rc_picker.input,
&mut self.icon_color_cache,
&self.mode,
&self.colorscheme,
)?;
}
Ok(())
}
pub fn maybe_init_preview_scroll(
&mut self,
target_line: Option<u16>,
height: u16,
) {
if self.preview_scroll.is_none() && !self.channel.running() {
self.preview_scroll =
Some(target_line.unwrap_or(0).saturating_sub(height / 3));
}
Some(Action::Render)
} else {
None
})
}
}

View File

@ -5,14 +5,15 @@ use std::{
use anyhow::Result;
use crossterm::{
cursor, execute,
cursor,
event::DisableMouseCapture,
execute,
terminal::{
disable_raw_mode, enable_raw_mode, is_raw_mode_enabled,
EnterAlternateScreen, LeaveAlternateScreen,
},
};
use ratatui::{backend::CrosstermBackend, layout::Size};
use tokio::task::JoinHandle;
use tracing::debug;
#[allow(dead_code)]
@ -20,8 +21,6 @@ pub struct Tui<W>
where
W: Write,
{
pub task: JoinHandle<()>,
pub frame_rate: f64,
pub terminal: ratatui::Terminal<CrosstermBackend<W>>,
}
@ -32,17 +31,10 @@ where
{
pub fn new(writer: W) -> Result<Self> {
Ok(Self {
task: tokio::spawn(async {}),
frame_rate: 60.0,
terminal: ratatui::Terminal::new(CrosstermBackend::new(writer))?,
})
}
pub fn frame_rate(mut self, frame_rate: f64) -> Self {
self.frame_rate = frame_rate;
self
}
pub fn size(&self) -> Result<Size> {
Ok(self.terminal.size()?)
}
@ -52,7 +44,7 @@ where
let mut buffered_stderr = LineWriter::new(stderr());
execute!(buffered_stderr, EnterAlternateScreen)?;
self.terminal.clear()?;
execute!(buffered_stderr, cursor::Hide)?;
execute!(buffered_stderr, DisableMouseCapture)?;
Ok(())
}

View File

@ -1,6 +1,6 @@
use rustc_hash::{FxBuildHasher, FxHashSet};
use std::collections::{HashSet, VecDeque};
use tracing::debug;
use tracing::{debug, trace};
/// A ring buffer that also keeps track of the keys it contains to avoid duplicates.
///
@ -81,7 +81,7 @@ where
pub fn push(&mut self, item: T) -> Option<T> {
// If the key is already in the buffer, do nothing
if self.contains(&item) {
debug!("Key already in ring buffer: {:?}", item);
trace!("Key already in ring buffer: {:?}", item);
return None;
}
let mut popped_key = None;

View File

@ -30,7 +30,7 @@ pub struct StateChanged {
pub type InputResponse = Option<StateChanged>;
/// An input buffer with cursor support.
#[derive(Default, Debug, Clone)]
#[derive(Default, Debug, Clone, PartialEq, Hash)]
pub struct Input {
value: String,
cursor: usize,

View File

@ -1,3 +1,4 @@
#[derive(Debug, Clone, PartialEq, Hash, Eq)]
pub struct AppMetadata {
pub version: String,
pub current_directory: String,

View File

@ -118,7 +118,7 @@ fn set_syntax_set<'a>(
})
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq, Hash)]
pub struct HighlightedLines {
pub lines: Vec<Vec<(Style, String)>>,
//pub state: Option<HighlightingState>,