diff --git a/.config/config.toml b/.config/config.toml index 2e286e0..c902023 100644 --- a/.config/config.toml +++ b/.config/config.toml @@ -1,18 +1,22 @@ -[keybindings.Input] -q = "Quit" +[keybindings.Channel] esc = "Quit" -ctrl-c = "Quit" -ctrl-z = "Suspend" -tab = "GoToNextPane" -backtab = "GoToPrevPane" -ctrl-left = "GoToPaneLeft" -ctrl-right = "GoToPaneRight" down = "SelectNextEntry" up = "SelectPrevEntry" ctrl-n = "SelectNextEntry" ctrl-p = "SelectPrevEntry" -ctrl-down = "ScrollPreviewDown" -ctrl-up = "ScrollPreviewUp" +alt-down = "ScrollPreviewHalfPageDown" ctrl-d = "ScrollPreviewHalfPageDown" +alt-up = "ScrollPreviewHalfPageUp" ctrl-u = "ScrollPreviewHalfPageUp" enter = "SelectEntry" +ctrl-s = "ToChannelSelection" + +[keybindings.ChannelSelection] +esc = "Quit" +down = "SelectNextEntry" +up = "SelectPrevEntry" +ctrl-n = "SelectNextEntry" +ctrl-p = "SelectPrevEntry" +enter = "SelectEntry" +ctrl-enter = "PipeInto" + diff --git a/crates/television/action.rs b/crates/television/action.rs index ded6fe9..f9484ea 100644 --- a/crates/television/action.rs +++ b/crates/television/action.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use strum::Display; -#[derive(Debug, Clone, PartialEq, Eq, Display, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Display)] pub enum Action { // input actions AddInputChar(char), @@ -17,6 +17,7 @@ pub enum Action { ClearScreen, // results actions SelectEntry, + SelectAndExit, SelectNextEntry, SelectPrevEntry, // navigation actions @@ -41,5 +42,6 @@ pub enum Action { Error(String), NoOp, // channel actions - SyncFinderResults, + ToChannelSelection, + PipeInto, } diff --git a/crates/television/app.rs b/crates/television/app.rs index cc567c4..74447ea 100644 --- a/crates/television/app.rs +++ b/crates/television/app.rs @@ -53,7 +53,6 @@ use std::sync::Arc; use color_eyre::Result; -use serde::{Deserialize, Serialize}; use tokio::sync::{mpsc, Mutex}; use tracing::{debug, info}; @@ -76,7 +75,6 @@ pub struct App { television: Arc>, should_quit: bool, should_suspend: bool, - mode: Mode, action_tx: mpsc::UnboundedSender, action_rx: mpsc::UnboundedReceiver, event_rx: mpsc::UnboundedReceiver>, @@ -84,17 +82,6 @@ pub struct App { render_tx: mpsc::UnboundedSender, } -#[derive( - Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, -)] -pub enum Mode { - #[default] - Help, - Input, - Preview, - Results, -} - impl App { pub fn new( channel: CliTvChannel, @@ -114,7 +101,6 @@ impl App { should_quit: false, should_suspend: false, config: Config::new()?, - mode: Mode::Input, action_tx, action_rx, event_rx, @@ -140,7 +126,6 @@ impl App { let rendering_task = tokio::spawn(async move { render( render_rx, - //render_tx, action_tx_r, config_r, television_r, @@ -178,28 +163,23 @@ impl App { match event { Event::Input(keycode) => { info!("{:?}", keycode); - // if the current component is the television - // and the mode is input, automatically handle - // (these mappings aren't exposed to the user) - if self.television.lock().await.is_input_focused() { - match keycode { - Key::Backspace => return Action::DeletePrevChar, - Key::Delete => return Action::DeleteNextChar, - Key::Left => return Action::GoToPrevChar, - Key::Right => return Action::GoToNextChar, - Key::Home | Key::Ctrl('a') => { - return Action::GoToInputStart - } - Key::End | Key::Ctrl('e') => { - return Action::GoToInputEnd - } - Key::Char(c) => return Action::AddInputChar(c), - _ => {} + // text input events + match keycode { + Key::Backspace => return Action::DeletePrevChar, + Key::Delete => return Action::DeleteNextChar, + Key::Left => return Action::GoToPrevChar, + Key::Right => return Action::GoToNextChar, + Key::Home | Key::Ctrl('a') => { + return Action::GoToInputStart } + Key::End | Key::Ctrl('e') => return Action::GoToInputEnd, + Key::Char(c) => return Action::AddInputChar(c), + _ => {} } + // get action based on keybindings self.config .keybindings - .get(&self.mode) + .get(&self.television.lock().await.mode) .and_then(|keymap| keymap.get(&keycode).cloned()) .unwrap_or(if let Key::Char(c) = keycode { Action::AddInputChar(c) @@ -234,7 +214,7 @@ impl App { self.should_suspend = false; self.render_tx.send(RenderingTask::Resume)?; } - Action::SelectEntry => { + Action::SelectAndExit => { self.should_quit = true; self.render_tx.send(RenderingTask::Quit)?; return Ok(self diff --git a/crates/television/channels.rs b/crates/television/channels.rs index 3b950a0..3b71fb9 100644 --- a/crates/television/channels.rs +++ b/crates/television/channels.rs @@ -1,7 +1,9 @@ use crate::entry::Entry; -use television_derive::CliChannel; +use color_eyre::eyre::Result; +use television_derive::{CliChannel, TvChannel}; mod alias; +pub mod channels; mod env; mod files; mod git_repos; @@ -87,8 +89,8 @@ pub trait TelevisionChannel: Send { /// instance from the selected CLI enum variant. /// #[allow(dead_code, clippy::module_name_repetitions)] -#[derive(CliChannel)] -pub enum AvailableChannels { +#[derive(CliChannel, TvChannel)] +pub enum AvailableChannel { Env(env::Channel), Files(files::Channel), GitRepos(git_repos::Channel), @@ -96,3 +98,22 @@ pub enum AvailableChannels { Stdin(stdin::Channel), Alias(alias::Channel), } + +/// NOTE: this could be generated by a derive macro +impl TryFrom<&Entry> for AvailableChannel { + type Error = String; + + fn try_from(entry: &Entry) -> Result { + match entry.name.as_ref() { + "env" => Ok(AvailableChannel::Env(env::Channel::default())), + "files" => Ok(AvailableChannel::Files(files::Channel::default())), + "gitrepos" => { + Ok(AvailableChannel::GitRepos(git_repos::Channel::default())) + } + "text" => Ok(AvailableChannel::Text(text::Channel::default())), + "stdin" => Ok(AvailableChannel::Stdin(stdin::Channel::default())), + "alias" => Ok(AvailableChannel::Alias(alias::Channel::default())), + _ => Err(format!("Unknown channel: {}", entry.name)), + } + } +} diff --git a/crates/television/channels/alias.rs b/crates/television/channels/alias.rs index fa63b97..f10d9f6 100644 --- a/crates/television/channels/alias.rs +++ b/crates/television/channels/alias.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use devicons::FileIcon; -use nucleo::{Config, Nucleo}; +use nucleo::{Config, Injector, Nucleo}; use tracing::debug; use crate::channels::TelevisionChannel; @@ -16,6 +16,12 @@ struct Alias { value: String, } +impl Alias { + fn new(name: String, value: String) -> Self { + Self { name, value } + } +} + pub struct Channel { matcher: Nucleo, last_pattern: String, @@ -68,22 +74,6 @@ fn get_raw_aliases(shell: &str) -> Vec { impl Channel { pub fn new() -> Self { - let raw_shell = get_current_shell().unwrap_or("bash".to_string()); - let shell = raw_shell.split('/').last().unwrap(); - debug!("Current shell: {}", shell); - let raw_aliases = get_raw_aliases(shell); - debug!("Aliases: {:?}", raw_aliases); - - let parsed_aliases = raw_aliases - .iter() - .map(|alias| { - let mut parts = alias.split('='); - let name = parts.next().unwrap().to_string(); - let value = parts.next().unwrap().to_string(); - Alias { name, value } - }) - .collect::>(); - let matcher = Nucleo::new( Config::DEFAULT, Arc::new(|| {}), @@ -91,12 +81,7 @@ impl Channel { 1, ); let injector = matcher.injector(); - - for alias in parsed_aliases { - let _ = injector.push(alias.clone(), |_, cols| { - cols[0] = (alias.name.clone() + &alias.value).into(); - }); - } + tokio::spawn(load_aliases(injector)); Self { matcher, @@ -108,7 +93,13 @@ impl Channel { } } - const MATCHER_TICK_TIMEOUT: u64 = 10; + const MATCHER_TICK_TIMEOUT: u64 = 2; +} + +impl Default for Channel { + fn default() -> Self { + Self::new() + } } impl TelevisionChannel for Channel { @@ -208,3 +199,33 @@ impl TelevisionChannel for Channel { self.running } } + +#[allow(clippy::unused_async)] +async fn load_aliases(injector: Injector) { + let raw_shell = get_current_shell().unwrap_or("bash".to_string()); + let shell = raw_shell.split('/').last().unwrap(); + debug!("Current shell: {}", shell); + let raw_aliases = get_raw_aliases(shell); + + let parsed_aliases = raw_aliases + .iter() + .filter_map(|alias| { + let mut parts = alias.split('='); + if let Some(name) = parts.next() { + if let Some(value) = parts.next() { + return Some(Alias::new( + name.to_string(), + value.to_string(), + )); + } + } + None + }) + .collect::>(); + + for alias in parsed_aliases { + let _ = injector.push(alias.clone(), |_, cols| { + cols[0] = (alias.name.clone() + &alias.value).into(); + }); + } +} diff --git a/crates/television/channels/channels.rs b/crates/television/channels/channels.rs new file mode 100644 index 0000000..86eb5b7 --- /dev/null +++ b/crates/television/channels/channels.rs @@ -0,0 +1,135 @@ +use std::sync::Arc; + +use clap::ValueEnum; +use devicons::FileIcon; +use nucleo::{ + pattern::{CaseMatching, Normalization}, + Config, Nucleo, +}; + +use crate::{ + channels::{CliTvChannel, TelevisionChannel}, + entry::Entry, + fuzzy::MATCHER, + previewers::PreviewType, +}; + +pub struct SelectionChannel { + matcher: Nucleo, + last_pattern: String, + result_count: u32, + total_count: u32, + running: bool, +} + +const NUM_THREADS: usize = 1; + +const CHANNEL_BLACKLIST: [CliTvChannel; 1] = [CliTvChannel::Stdin]; + +impl SelectionChannel { + pub fn new() -> Self { + let matcher = Nucleo::new( + Config::DEFAULT, + Arc::new(|| {}), + Some(NUM_THREADS), + 1, + ); + let injector = matcher.injector(); + for variant in CliTvChannel::value_variants() { + if CHANNEL_BLACKLIST.contains(variant) { + continue; + } + let _ = injector.push(*variant, |e, cols| { + cols[0] = (*e).to_string().into(); + }); + } + SelectionChannel { + matcher, + last_pattern: String::new(), + result_count: 0, + total_count: 0, + running: false, + } + } + + const MATCHER_TICK_TIMEOUT: u64 = 2; +} + +const TV_ICON: FileIcon = FileIcon { + icon: 'πŸ“Ί', + color: "#ffffff", +}; + +impl TelevisionChannel for SelectionChannel { + fn find(&mut self, pattern: &str) { + if pattern != self.last_pattern { + self.matcher.pattern.reparse( + 0, + pattern, + CaseMatching::Smart, + Normalization::Smart, + pattern.starts_with(&self.last_pattern), + ); + self.last_pattern = pattern.to_string(); + } + } + + fn result_count(&self) -> u32 { + self.result_count + } + + fn total_count(&self) -> u32 { + self.total_count + } + + fn results(&mut self, num_entries: u32, offset: u32) -> Vec { + let status = self.matcher.tick(Self::MATCHER_TICK_TIMEOUT); + let snapshot = self.matcher.snapshot(); + if status.changed { + self.result_count = snapshot.matched_item_count(); + self.total_count = snapshot.item_count(); + } + self.running = status.running; + let mut indices = Vec::new(); + let mut matcher = MATCHER.lock(); + + snapshot + .matched_items( + offset + ..(num_entries + offset) + .min(snapshot.matched_item_count()), + ) + .map(move |item| { + snapshot.pattern().column_pattern(0).indices( + item.matcher_columns[0].slice(..), + &mut matcher, + &mut indices, + ); + indices.sort_unstable(); + indices.dedup(); + let indices = indices.drain(..); + + let name = item.matcher_columns[0].to_string(); + Entry::new(name.clone(), PreviewType::Basic) + .with_name_match_ranges( + indices.map(|i| (i, i + 1)).collect(), + ) + .with_icon(TV_ICON) + }) + .collect() + } + + fn get_result(&self, index: u32) -> Option { + let snapshot = self.matcher.snapshot(); + snapshot.get_matched_item(index).map(|item| { + let name = item.matcher_columns[0].to_string(); + // TODO: Add new Previewer for Channel selection which displays a + // short description of the channel + Entry::new(name.clone(), PreviewType::Basic).with_icon(TV_ICON) + }) + } + + fn running(&self) -> bool { + self.running + } +} diff --git a/crates/television/channels/env.rs b/crates/television/channels/env.rs index d78f1bd..ca4994d 100644 --- a/crates/television/channels/env.rs +++ b/crates/television/channels/env.rs @@ -53,7 +53,13 @@ impl Channel { } } - const MATCHER_TICK_TIMEOUT: u64 = 10; + const MATCHER_TICK_TIMEOUT: u64 = 2; +} + +impl Default for Channel { + fn default() -> Self { + Self::new() + } } impl TelevisionChannel for Channel { diff --git a/crates/television/channels/files.rs b/crates/television/channels/files.rs index 7d1d3f7..8ae0365 100644 --- a/crates/television/channels/files.rs +++ b/crates/television/channels/files.rs @@ -3,7 +3,11 @@ use nucleo::{ pattern::{CaseMatching, Normalization}, Config, Injector, Nucleo, }; -use std::{os::unix::ffi::OsStrExt, path::PathBuf, sync::Arc}; +use std::{ + os::unix::ffi::OsStrExt, + path::{Path, PathBuf}, + sync::Arc, +}; use ignore::DirEntry; @@ -26,7 +30,7 @@ pub struct Channel { } impl Channel { - pub fn new() -> Self { + pub fn new(starting_dir: &Path) -> Self { let matcher = Nucleo::new( Config::DEFAULT.match_paths(), Arc::new(|| {}), @@ -35,7 +39,7 @@ impl Channel { ); // start loading files in the background tokio::spawn(load_files( - std::env::current_dir().unwrap(), + starting_dir.to_path_buf(), matcher.injector(), )); Channel { @@ -47,7 +51,13 @@ impl Channel { } } - const MATCHER_TICK_TIMEOUT: u64 = 10; + const MATCHER_TICK_TIMEOUT: u64 = 2; +} + +impl Default for Channel { + fn default() -> Self { + Self::new(&std::env::current_dir().unwrap()) + } } impl TelevisionChannel for Channel { diff --git a/crates/television/channels/git_repos.rs b/crates/television/channels/git_repos.rs index d151998..4c67b6d 100644 --- a/crates/television/channels/git_repos.rs +++ b/crates/television/channels/git_repos.rs @@ -23,6 +23,7 @@ pub struct Channel { result_count: u32, total_count: u32, running: bool, + icon: FileIcon, } impl Channel { @@ -44,10 +45,17 @@ impl Channel { result_count: 0, total_count: 0, running: false, + icon: FileIcon::from("git"), } } - const MATCHER_TICK_TIMEOUT: u64 = 10; + const MATCHER_TICK_TIMEOUT: u64 = 2; +} + +impl Default for Channel { + fn default() -> Self { + Self::new() + } } impl TelevisionChannel for Channel { @@ -82,6 +90,7 @@ impl TelevisionChannel for Channel { self.running = status.running; let mut indices = Vec::new(); let mut matcher = MATCHER.lock(); + let icon = self.icon; snapshot .matched_items( @@ -104,7 +113,7 @@ impl TelevisionChannel for Channel { .with_name_match_ranges( indices.map(|i| (i, i + 1)).collect(), ) - .with_icon(FileIcon::from(&path)) + .with_icon(icon) }) .collect() } @@ -114,7 +123,7 @@ impl TelevisionChannel for Channel { snapshot.get_matched_item(index).map(|item| { let path = item.matcher_columns[0].to_string(); Entry::new(path.clone(), PreviewType::Directory) - .with_icon(FileIcon::from(&path)) + .with_icon(self.icon) }) } diff --git a/crates/television/channels/stdin.rs b/crates/television/channels/stdin.rs index d1c7468..4478a5f 100644 --- a/crates/television/channels/stdin.rs +++ b/crates/television/channels/stdin.rs @@ -26,7 +26,6 @@ impl Channel { pub fn new() -> Self { let mut lines = Vec::new(); for line in std::io::stdin().lock().lines().map_while(Result::ok) { - debug!("Read line: {:?}", line); lines.push(line); } let matcher = Nucleo::new( @@ -51,7 +50,13 @@ impl Channel { } } - const MATCHER_TICK_TIMEOUT: u64 = 10; + const MATCHER_TICK_TIMEOUT: u64 = 2; +} + +impl Default for Channel { + fn default() -> Self { + Self::new() + } } impl TelevisionChannel for Channel { diff --git a/crates/television/channels/text.rs b/crates/television/channels/text.rs index dd78d5f..8dd7494 100644 --- a/crates/television/channels/text.rs +++ b/crates/television/channels/text.rs @@ -6,7 +6,7 @@ use nucleo::{ use std::{ fs::File, io::{BufRead, Read, Seek}, - path::PathBuf, + path::{Path, PathBuf}, sync::Arc, }; @@ -50,11 +50,11 @@ pub struct Channel { } impl Channel { - pub fn new() -> Self { + pub fn new(working_dir: &Path) -> Self { let matcher = Nucleo::new(Config::DEFAULT, Arc::new(|| {}), None, 1); // start loading files in the background tokio::spawn(load_candidates( - std::env::current_dir().unwrap(), + working_dir.to_path_buf(), matcher.injector(), )); Channel { @@ -66,7 +66,13 @@ impl Channel { } } - const MATCHER_TICK_TIMEOUT: u64 = 10; + const MATCHER_TICK_TIMEOUT: u64 = 2; +} + +impl Default for Channel { + fn default() -> Self { + Self::new(&std::env::current_dir().unwrap()) + } } impl TelevisionChannel for Channel { diff --git a/crates/television/config.rs b/crates/television/config.rs index 3d3d2f6..03a81ed 100644 --- a/crates/television/config.rs +++ b/crates/television/config.rs @@ -11,11 +11,10 @@ use tracing::{error, info}; use crate::{ action::Action, - app::Mode, event::{convert_raw_event_to_key, Key}, + television::Mode, }; -//const CONFIG: &str = include_str!("../.config/config.json5"); const CONFIG: &str = include_str!("../../.config/config.toml"); #[allow(dead_code, clippy::module_name_repetitions)] @@ -523,9 +522,9 @@ mod tests { let c = Config::new()?; assert_eq!( c.keybindings - .get(&Mode::Input) + .get(&Mode::Channel) .unwrap() - .get(&parse_key("").unwrap()) + .get(&parse_key("esc").unwrap()) .unwrap(), &Action::Quit ); diff --git a/crates/television/event.rs b/crates/television/event.rs index 5fa2010..b3f8c51 100644 --- a/crates/television/event.rs +++ b/crates/television/event.rs @@ -1,4 +1,5 @@ use std::{ + fmt::Display, future::Future, pin::Pin, task::{Context, Poll as TaskPoll}, @@ -14,7 +15,7 @@ use crossterm::event::{ }; use serde::{Deserialize, Serialize}; use tokio::sync::mpsc; -use tracing::warn; +use tracing::{debug, warn}; #[derive(Debug, Clone, Copy)] pub enum Event { @@ -36,6 +37,7 @@ pub enum Key { Right, Up, Down, + CtrlSpace, CtrlBackspace, CtrlEnter, CtrlLeft, @@ -43,8 +45,14 @@ pub enum Key { CtrlUp, CtrlDown, CtrlDelete, + AltSpace, + AltEnter, AltBackspace, AltDelete, + AltUp, + AltDown, + AltLeft, + AltRight, Home, End, PageUp, @@ -61,6 +69,49 @@ pub enum Key { Tab, } +impl Display for Key { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Key::Backspace => write!(f, "Backspace"), + Key::Enter => write!(f, "Enter"), + Key::Left => write!(f, "←"), + Key::Right => write!(f, "β†’"), + Key::Up => write!(f, "↑"), + Key::Down => write!(f, "↓"), + Key::CtrlSpace => write!(f, "Ctrl-Space"), + Key::CtrlBackspace => write!(f, "Ctrl-Backspace"), + Key::CtrlEnter => write!(f, "Ctrl-Enter"), + Key::CtrlLeft => write!(f, "Ctrl-←"), + Key::CtrlRight => write!(f, "Ctrl-β†’"), + Key::CtrlUp => write!(f, "Ctrl-↑"), + Key::CtrlDown => write!(f, "Ctrl-↓"), + Key::CtrlDelete => write!(f, "Ctrl-Del"), + Key::AltSpace => write!(f, "Alt+Space"), + Key::AltEnter => write!(f, "Alt+Enter"), + Key::AltBackspace => write!(f, "Alt+Backspace"), + Key::AltDelete => write!(f, "Alt+Delete"), + Key::AltUp => write!(f, "Alt↑"), + Key::AltDown => write!(f, "Alt↓"), + Key::AltLeft => write!(f, "Alt←"), + Key::AltRight => write!(f, "Altβ†’"), + Key::Home => write!(f, "Home"), + Key::End => write!(f, "End"), + Key::PageUp => write!(f, "PageUp"), + Key::PageDown => write!(f, "PageDown"), + Key::BackTab => write!(f, "BackTab"), + Key::Delete => write!(f, "Delete"), + Key::Insert => write!(f, "Insert"), + Key::F(k) => write!(f, "F{k}"), + Key::Char(c) => write!(f, "{c}"), + Key::Alt(c) => write!(f, "Alt+{c}"), + Key::Ctrl(c) => write!(f, "Ctrl-{c}"), + Key::Null => write!(f, "Null"), + Key::Esc => write!(f, "Esc"), + Key::Tab => write!(f, "Tab"), + } + } +} + #[allow(clippy::module_name_repetitions)] pub struct EventLoop { pub rx: mpsc::UnboundedReceiver>, @@ -163,6 +214,7 @@ impl EventLoop { } pub fn convert_raw_event_to_key(event: KeyEvent) -> Key { + debug!("Raw event: {:?}", event); match event.code { Backspace => match event.modifiers { KeyModifiers::CONTROL => Key::CtrlBackspace, @@ -176,22 +228,27 @@ pub fn convert_raw_event_to_key(event: KeyEvent) -> Key { }, Enter => match event.modifiers { KeyModifiers::CONTROL => Key::CtrlEnter, + KeyModifiers::ALT => Key::AltEnter, _ => Key::Enter, }, Up => match event.modifiers { KeyModifiers::CONTROL => Key::CtrlUp, + KeyModifiers::ALT => Key::AltUp, _ => Key::Up, }, Down => match event.modifiers { KeyModifiers::CONTROL => Key::CtrlDown, + KeyModifiers::ALT => Key::AltDown, _ => Key::Down, }, Left => match event.modifiers { KeyModifiers::CONTROL => Key::CtrlLeft, + KeyModifiers::ALT => Key::AltLeft, _ => Key::Left, }, Right => match event.modifiers { KeyModifiers::CONTROL => Key::CtrlRight, + KeyModifiers::ALT => Key::AltRight, _ => Key::Right, }, Home => Key::Home, @@ -203,6 +260,12 @@ pub fn convert_raw_event_to_key(event: KeyEvent) -> Key { Insert => Key::Insert, F(k) => Key::F(k), Esc => Key::Esc, + Char(' ') => match event.modifiers { + KeyModifiers::NONE | KeyModifiers::SHIFT => Key::Char(' '), + KeyModifiers::CONTROL => Key::CtrlSpace, + KeyModifiers::ALT => Key::AltSpace, + _ => Key::Null, + }, Char(c) => match event.modifiers { KeyModifiers::NONE | KeyModifiers::SHIFT => Key::Char(c), KeyModifiers::CONTROL => Key::Ctrl(c), diff --git a/crates/television/previewers.rs b/crates/television/previewers.rs index 070abba..62e9fce 100644 --- a/crates/television/previewers.rs +++ b/crates/television/previewers.rs @@ -7,6 +7,7 @@ mod cache; mod directory; mod env; mod files; +mod meta; // previewer types pub use basic::BasicPreviewer; @@ -99,7 +100,7 @@ impl Previewer { pub async fn preview(&mut self, entry: &Entry) -> Arc { match entry.preview_type { PreviewType::Basic => self.basic.preview(entry), - PreviewType::Directory => self.directory.preview(entry), + PreviewType::Directory => self.directory.preview(entry).await, PreviewType::EnvVar => self.env_var.preview(entry), PreviewType::Files => self.file.preview(entry).await, } diff --git a/crates/television/previewers/directory.rs b/crates/television/previewers/directory.rs index ff9ce4c..c283426 100644 --- a/crates/television/previewers/directory.rs +++ b/crates/television/previewers/directory.rs @@ -3,37 +3,50 @@ use std::sync::Arc; use devicons::FileIcon; use termtree::Tree; +use tokio::sync::Mutex; use crate::entry::Entry; use crate::previewers::cache::PreviewCache; -use crate::previewers::{Preview, PreviewContent}; +use crate::previewers::{meta, Preview, PreviewContent}; use crate::utils::files::walk_builder; pub struct DirectoryPreviewer { - cache: PreviewCache, + cache: Arc>, } impl DirectoryPreviewer { pub fn new() -> Self { DirectoryPreviewer { - cache: PreviewCache::default(), + cache: Arc::new(Mutex::new(PreviewCache::default())), } } - pub fn preview(&mut self, entry: &Entry) -> Arc { - if let Some(preview) = self.cache.get(&entry.name) { + pub async fn preview(&mut self, entry: &Entry) -> Arc { + if let Some(preview) = self.cache.lock().await.get(&entry.name) { return preview; } - let preview = Arc::new(build_tree_preview(entry)); - self.cache.insert(entry.name.clone(), preview.clone()); + let preview = meta::loading(&entry.name); + self.cache + .lock() + .await + .insert(entry.name.clone(), preview.clone()); + let entry_c = entry.clone(); + let cache = self.cache.clone(); + tokio::spawn(async move { + let preview = Arc::new(build_tree_preview(&entry_c)); + cache + .lock() + .await + .insert(entry_c.name.clone(), preview.clone()); + }); preview } } fn build_tree_preview(entry: &Entry) -> Preview { let path = Path::new(&entry.name); - let tree = tree(path).unwrap(); + let tree = tree(path, MAX_DEPTH, FIRST_LEVEL_MAX_ENTRIES, &mut 0); let tree_string = tree.to_string(); Preview { title: entry.name.clone(), @@ -47,33 +60,53 @@ fn build_tree_preview(entry: &Entry) -> Preview { } fn label>(p: P, strip: &str) -> String { - //let path = p.as_ref().file_name().unwrap().to_str().unwrap().to_owned(); + let icon = FileIcon::from(&p); let path = p.as_ref().strip_prefix(strip).unwrap(); - let icon = FileIcon::from(&path); format!("{} {}", icon, path.display()) } -/// PERF: (urgent) change to use the ignore crate here -fn tree>(p: P) -> std::io::Result> { - let result = std::fs::read_dir(&p)? - .filter_map(std::result::Result::ok) - .fold( - Tree::new(label( - p.as_ref(), - p.as_ref().parent().unwrap().to_str().unwrap(), - )), - |mut root, entry| { - let m = entry.metadata().unwrap(); - if m.is_dir() { - root.push(tree(entry.path()).unwrap()); - } else { - root.push(Tree::new(label( - entry.path(), - p.as_ref().to_str().unwrap(), - ))); - } - root - }, - ); - Ok(result) +const MAX_DEPTH: u8 = 4; +const FIRST_LEVEL_MAX_ENTRIES: u8 = 30; +const NESTED_MAX_ENTRIES: u8 = 10; +const MAX_ENTRIES: u8 = 200; + +fn tree>( + p: P, + max_depth: u8, + nested_max_entries: u8, + total_entry_count: &mut u8, +) -> Tree { + let mut root = Tree::new(label( + p.as_ref(), + p.as_ref().parent().unwrap().to_str().unwrap(), + )); + let w = walk_builder(p.as_ref(), 1, None).max_depth(Some(1)).build(); + let mut level_entry_count: u8 = 0; + + for path in w.skip(1).filter_map(std::result::Result::ok) { + let m = path.metadata().unwrap(); + if m.is_dir() && max_depth > 1 { + root.push(tree( + path.path(), + max_depth - 1, + NESTED_MAX_ENTRIES, + total_entry_count, + )); + } else { + root.push(Tree::new(label( + path.path(), + p.as_ref().to_str().unwrap(), + ))); + } + level_entry_count += 1; + *total_entry_count += 1; + if level_entry_count >= nested_max_entries + || *total_entry_count >= MAX_ENTRIES + { + root.push(Tree::new(String::from("..."))); + break; + } + } + + root } diff --git a/crates/television/previewers/files.rs b/crates/television/previewers/files.rs index 0ebe046..41c72fc 100644 --- a/crates/television/previewers/files.rs +++ b/crates/television/previewers/files.rs @@ -15,7 +15,7 @@ use syntect::{ use tracing::{debug, warn}; use crate::entry; -use crate::previewers::{Preview, PreviewContent}; +use crate::previewers::{meta, Preview, PreviewContent}; use crate::utils::files::FileType; use crate::utils::files::{get_file_size, is_known_text_extension}; use crate::utils::strings::{ @@ -63,7 +63,7 @@ impl FilePreviewer { if get_file_size(&path_buf).map_or(false, |s| s > Self::MAX_FILE_SIZE) { debug!("File too large: {:?}", entry.name); - let preview = file_too_large(&entry.name); + let preview = meta::file_too_large(&entry.name); self.cache_preview(entry.name.clone(), preview.clone()) .await; return preview; @@ -94,7 +94,7 @@ impl FilePreviewer { } Err(e) => { warn!("Error opening file: {:?}", e); - let p = not_supported(&entry.name); + let p = meta::not_supported(&entry.name); self.cache_preview(entry.name.clone(), p.clone()) .await; p @@ -105,7 +105,7 @@ impl FilePreviewer { debug!("Previewing image file: {:?}", entry.name); // insert a loading preview into the cache //let preview = loading(&entry.name); - let preview = not_supported(&entry.name); + let preview = meta::not_supported(&entry.name); self.cache_preview(entry.name.clone(), preview.clone()) .await; //// compute the image preview in the background @@ -114,14 +114,14 @@ impl FilePreviewer { } FileType::Other => { debug!("Previewing other file: {:?}", entry.name); - let preview = not_supported(&entry.name); + let preview = meta::not_supported(&entry.name); self.cache_preview(entry.name.clone(), preview.clone()) .await; preview } FileType::Unknown => { debug!("Unknown file type: {:?}", entry.name); - let preview = not_supported(&entry.name); + let preview = meta::not_supported(&entry.name); self.cache_preview(entry.name.clone(), preview.clone()) .await; preview @@ -225,8 +225,9 @@ impl FilePreviewer { let mut buffer = [0u8; 256]; if let Ok(bytes_read) = f.read(&mut buffer) { if bytes_read > 0 - && proportion_of_printable_ascii_characters(&buffer) - > PRINTABLE_ASCII_THRESHOLD + && proportion_of_printable_ascii_characters( + &buffer[..bytes_read], + ) > PRINTABLE_ASCII_THRESHOLD { file_type = FileType::Text; } @@ -266,7 +267,7 @@ fn plain_text_preview(title: &str, reader: BufReader<&File>) -> Arc { Ok(line) => lines.push(preprocess_line(&line)), Err(e) => { warn!("Error reading file: {:?}", e); - return not_supported(title); + return meta::not_supported(title); } } if lines.len() >= TEMP_PLAIN_TEXT_PREVIEW_HEIGHT { @@ -279,25 +280,6 @@ fn plain_text_preview(title: &str, reader: BufReader<&File>) -> Arc { )) } -fn not_supported(title: &str) -> Arc { - Arc::new(Preview::new( - title.to_string(), - PreviewContent::NotSupported, - )) -} - -fn file_too_large(title: &str) -> Arc { - Arc::new(Preview::new( - title.to_string(), - PreviewContent::FileTooLarge, - )) -} - -#[allow(dead_code)] -fn loading(title: &str) -> Arc { - Arc::new(Preview::new(title.to_string(), PreviewContent::Loading)) -} - fn compute_highlights( file_path: &Path, lines: Vec, diff --git a/crates/television/previewers/meta.rs b/crates/television/previewers/meta.rs new file mode 100644 index 0000000..d12d377 --- /dev/null +++ b/crates/television/previewers/meta.rs @@ -0,0 +1,21 @@ +use crate::previewers::{Preview, PreviewContent}; +use std::sync::Arc; + +pub fn not_supported(title: &str) -> Arc { + Arc::new(Preview::new( + title.to_string(), + PreviewContent::NotSupported, + )) +} + +pub fn file_too_large(title: &str) -> Arc { + Arc::new(Preview::new( + title.to_string(), + PreviewContent::FileTooLarge, + )) +} + +#[allow(dead_code)] +pub fn loading(title: &str) -> Arc { + Arc::new(Preview::new(title.to_string(), PreviewContent::Loading)) +} diff --git a/crates/television/television.rs b/crates/television/television.rs index caae561..a9bcda9 100644 --- a/crates/television/television.rs +++ b/crates/television/television.rs @@ -8,21 +8,25 @@ use ratatui::{ text::{Line, Span}, widgets::{ block::{Position, Title}, - Block, BorderType, Borders, ListState, Padding, Paragraph, + Block, BorderType, Borders, ListState, Padding, Paragraph, Wrap, }, Frame, }; +use serde::{Deserialize, Serialize}; use std::{collections::HashMap, str::FromStr}; use tokio::sync::mpsc::UnboundedSender; +use tracing::debug; -use crate::ui::get_border_style; -use crate::ui::input::actions::InputActionHandler; use crate::ui::input::Input; use crate::ui::layout::{Dimensions, Layout}; use crate::ui::preview::DEFAULT_PREVIEW_TITLE_FG; use crate::ui::results::build_results_list; use crate::utils::strings::EMPTY_STRING; use crate::{action::Action, config::Config}; +use crate::{channels::channels::SelectionChannel, ui::get_border_style}; +use crate::{ + channels::AvailableChannel, ui::input::actions::InputActionHandler, +}; use crate::{ channels::{CliTvChannel, TelevisionChannel}, utils::strings::shrink_with_ellipsis, @@ -33,21 +37,18 @@ use crate::{ }; use crate::{previewers::Previewer, ui::spinner::SpinnerState}; -#[derive(PartialEq, Copy, Clone)] -enum Pane { - Results, - Preview, - Input, +#[derive(PartialEq, Copy, Clone, Hash, Eq, Debug, Serialize, Deserialize)] +pub enum Mode { + Channel, + ChannelSelection, } -static PANES: [Pane; 3] = [Pane::Input, Pane::Results, Pane::Preview]; - pub struct Television { action_tx: Option>, - config: Config, - channel: Box, + pub config: Config, + channel: AvailableChannel, current_pattern: String, - current_pane: Pane, + pub mode: Mode, input: Input, picker_state: ListState, relative_picker_state: ListState, @@ -82,7 +83,7 @@ impl Television { config: Config::default(), channel: tv_channel, current_pattern: EMPTY_STRING.to_string(), - current_pane: Pane::Input, + mode: Mode::Channel, input: Input::new(EMPTY_STRING.to_string()), picker_state: ListState::default(), relative_picker_state: ListState::default(), @@ -98,6 +99,14 @@ impl Television { } } + pub fn change_channel(&mut self, channel: Box) { + self.reset_preview_scroll(); + self.reset_results_selection(); + self.current_pattern = EMPTY_STRING.to_string(); + self.input.reset(); + self.channel = channel; + } + fn find(&mut self, pattern: &str) { self.channel.find(pattern); } @@ -194,95 +203,10 @@ impl Television { } } - fn get_current_pane_index(&self) -> usize { - PANES - .iter() - .position(|pane| *pane == self.current_pane) - .unwrap() - } - - pub fn next_pane(&mut self) { - let current_index = self.get_current_pane_index(); - let next_index = (current_index + 1) % PANES.len(); - self.current_pane = PANES[next_index]; - } - - pub fn previous_pane(&mut self) { - let current_index = self.get_current_pane_index(); - let previous_index = if current_index == 0 { - PANES.len() - 1 - } else { - current_index - 1 - }; - self.current_pane = PANES[previous_index]; - } - - /// β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - /// β”‚ Results β”‚β”‚ Preview β”‚ - /// β”‚ β”‚β”‚ β”‚ - /// β”‚ β”‚β”‚ β”‚ - /// β”‚ β”‚β”‚ β”‚ - /// β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜β”‚ β”‚ - /// β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”β”‚ β”‚ - /// β”‚ Search x β”‚β”‚ β”‚ - /// β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - pub fn move_to_pane_on_top(&mut self) { - if self.current_pane == Pane::Input { - self.current_pane = Pane::Results; - } - } - - /// β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - /// β”‚ Results x β”‚β”‚ Preview β”‚ - /// β”‚ β”‚β”‚ β”‚ - /// β”‚ β”‚β”‚ β”‚ - /// β”‚ β”‚β”‚ β”‚ - /// β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜β”‚ β”‚ - /// β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”β”‚ β”‚ - /// β”‚ Search β”‚β”‚ β”‚ - /// β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - pub fn move_to_pane_below(&mut self) { - if self.current_pane == Pane::Results { - self.current_pane = Pane::Input; - } - } - - /// β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - /// β”‚ Results x β”‚β”‚ Preview β”‚ - /// β”‚ β”‚β”‚ β”‚ - /// β”‚ β”‚β”‚ β”‚ - /// β”‚ β”‚β”‚ β”‚ - /// β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜β”‚ β”‚ - /// β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”β”‚ β”‚ - /// β”‚ Search x β”‚β”‚ β”‚ - /// β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - pub fn move_to_pane_right(&mut self) { - match self.current_pane { - Pane::Results | Pane::Input => { - self.current_pane = Pane::Preview; - } - Pane::Preview => {} - } - } - - /// β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - /// β”‚ Results β”‚β”‚ Preview x β”‚ - /// β”‚ β”‚β”‚ β”‚ - /// β”‚ β”‚β”‚ β”‚ - /// β”‚ β”‚β”‚ β”‚ - /// β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜β”‚ β”‚ - /// β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”β”‚ β”‚ - /// β”‚ Search β”‚β”‚ β”‚ - /// β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - pub fn move_to_pane_left(&mut self) { - if self.current_pane == Pane::Preview { - self.current_pane = Pane::Results; - } - } - - #[must_use] - pub fn is_input_focused(&self) -> bool { - Pane::Input == self.current_pane + fn reset_results_selection(&mut self) { + self.picker_state.select(Some(0)); + self.relative_picker_state.select(Some(0)); + self.picker_view_offset = 0; } } @@ -334,24 +258,6 @@ impl Television { /// * `Result>` - An action to be processed or none. pub async fn update(&mut self, action: Action) -> Result> { match action { - Action::GoToPaneUp => { - self.move_to_pane_on_top(); - } - Action::GoToPaneDown => { - self.move_to_pane_below(); - } - Action::GoToPaneLeft => { - self.move_to_pane_left(); - } - Action::GoToPaneRight => { - self.move_to_pane_right(); - } - Action::GoToNextPane => { - self.next_pane(); - } - Action::GoToPrevPane => { - self.previous_pane(); - } // handle input actions Action::AddInputChar(_) | Action::DeletePrevChar @@ -359,9 +265,7 @@ impl Television { | Action::GoToInputEnd | Action::GoToInputStart | Action::GoToNextChar - | Action::GoToPrevChar - if self.is_input_focused() => - { + | Action::GoToPrevChar => { self.input.handle_action(&action); match action { Action::AddInputChar(_) @@ -372,9 +276,7 @@ impl Television { self.current_pattern.clone_from(&new_pattern); self.find(&new_pattern); self.reset_preview_scroll(); - self.picker_state.select(Some(0)); - self.relative_picker_state.select(Some(0)); - self.picker_view_offset = 0; + self.reset_results_selection(); } } _ => {} @@ -392,6 +294,31 @@ impl Television { Action::ScrollPreviewUp => self.scroll_preview_up(1), Action::ScrollPreviewHalfPageDown => self.scroll_preview_down(20), Action::ScrollPreviewHalfPageUp => self.scroll_preview_up(20), + Action::ToChannelSelection => { + self.mode = Mode::ChannelSelection; + let selection_channel = Box::new(SelectionChannel::new()); + self.change_channel(selection_channel); + } + Action::SelectEntry => { + if let Some(entry) = self.get_selected_entry() { + match self.mode { + Mode::Channel => self + .action_tx + .as_ref() + .unwrap() + .send(Action::SelectAndExit)?, + Mode::ChannelSelection => { + self.mode = Mode::Channel; + let new_channel = + AvailableChannel::from_entry(&entry)?; + self.change_channel(new_channel); + } + } + } + } + Action::PipeInto => { + if let Some(entry) = self.get_selected_entry() {} + } _ => {} } Ok(None) @@ -407,10 +334,28 @@ impl Television { /// # Returns /// /// * `Result<()>` - An Ok result or an error. - pub fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { - let layout = Layout::all_panes_centered(Dimensions::default(), area); - //let layout = - //Layout::results_only_centered(Dimensions::new(40, 60), area); + pub fn draw(&mut self, f: &mut Frame, area: Rect) -> Result<()> { + let layout = Layout::build( + &Dimensions::default(), + area, + match self.mode { + Mode::Channel => true, + Mode::ChannelSelection => false, + }, + ); + + let help_block = Block::default() + .borders(Borders::NONE) + .style(Style::default()) + .padding(Padding::uniform(1)); + + let help_text = self + .build_help_paragraph(layout.help_bar.width.saturating_sub(4))? + .style(Style::default().fg(Color::DarkGray).italic()) + .alignment(Alignment::Center) + .block(help_block); + + f.render_widget(help_text, layout.help_bar); self.results_area_height = u32::from(layout.results.height); if let Some(preview_window) = layout.preview_window { @@ -426,7 +371,7 @@ impl Television { ) .borders(Borders::ALL) .border_type(BorderType::Rounded) - .border_style(get_border_style(Pane::Results == self.current_pane)) + .border_style(get_border_style(false)) .style(Style::default()) .padding(Padding::right(1)); @@ -438,12 +383,12 @@ impl Television { } let entries = self.channel.results( - (layout.results.height - 2).into(), + (layout.results.height.saturating_sub(2)).into(), u32::try_from(self.picker_view_offset)?, ); let results_list = build_results_list(results_block, &entries); - frame.render_stateful_widget( + f.render_stateful_widget( results_list, layout.results, &mut self.relative_picker_state, @@ -458,12 +403,12 @@ impl Television { ) .borders(Borders::ALL) .border_type(BorderType::Rounded) - .border_style(get_border_style(Pane::Input == self.current_pane)) + .border_style(get_border_style(false)) .style(Style::default()); let input_block_inner = input_block.inner(layout.input); - frame.render_widget(input_block, layout.input); + f.render_widget(input_block, layout.input); // split input block into 3 parts: prompt symbol, input, result count let inner_input_chunks = RatatuiLayout::default() @@ -491,7 +436,7 @@ impl Television { Style::default().fg(DEFAULT_INPUT_FG).bold(), )) .block(arrow_block); - frame.render_widget(arrow, inner_input_chunks[0]); + f.render_widget(arrow, inner_input_chunks[0]); let interactive_input_block = Block::default(); // keep 2 for borders and 1 for cursor @@ -502,10 +447,10 @@ impl Television { .block(interactive_input_block) .style(Style::default().fg(DEFAULT_INPUT_FG).bold().italic()) .alignment(Alignment::Left); - frame.render_widget(input, inner_input_chunks[1]); + f.render_widget(input, inner_input_chunks[1]); if self.channel.running() { - frame.render_stateful_widget( + f.render_stateful_widget( self.spinner, inner_input_chunks[3], &mut self.spinner_state, @@ -527,21 +472,19 @@ impl Television { )) .block(result_count_block) .alignment(Alignment::Right); - frame.render_widget(result_count, inner_input_chunks[2]); + f.render_widget(result_count, inner_input_chunks[2]); - if let Pane::Input = self.current_pane { - // Make the cursor visible and ask tui-rs to put it at the - // specified coordinates after rendering - frame.set_cursor_position(( - // Put cursor past the end of the input text - inner_input_chunks[1].x - + u16::try_from( - self.input.visual_cursor().max(scroll) - scroll, - )?, - // Move one line down, from the border to the input line - inner_input_chunks[1].y, - )); - } + // Make the cursor visible and ask tui-rs to put it at the + // 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( + self.input.visual_cursor().max(scroll) - scroll, + )?, + // Move one line down, from the border to the input line + inner_input_chunks[1].y, + )); if layout.preview_title.is_some() || layout.preview_window.is_some() { let selected_entry = @@ -567,7 +510,7 @@ impl Television { preview_title_spans.push(Span::styled( shrink_with_ellipsis( &preview.title, - (preview_title_area.width - 4) as usize, + preview_title_area.width.saturating_sub(4) as usize, ), Style::default().fg(DEFAULT_PREVIEW_TITLE_FG).bold(), )); @@ -580,7 +523,7 @@ impl Television { .border_style(get_border_style(false)), ) .alignment(Alignment::Left); - frame.render_widget(preview_title, preview_title_area); + f.render_widget(preview_title, preview_title_area); } if let Some(preview_area) = layout.preview_window { @@ -593,9 +536,7 @@ impl Television { ) .borders(Borders::ALL) .border_type(BorderType::Rounded) - .border_style(get_border_style( - Pane::Preview == self.current_pane, - )) + .border_style(get_border_style(false)) .style(Style::default()) .padding(Padding::right(1)); @@ -608,7 +549,7 @@ impl Television { left: 1, }); let inner = preview_outer_block.inner(preview_area); - frame.render_widget(preview_outer_block, preview_area); + f.render_widget(preview_outer_block, preview_area); //if let PreviewContent::Image(img) = &preview.content { // let image_component = StatefulImage::new(None); @@ -626,7 +567,7 @@ impl Television { .line_number .map(|l| u16::try_from(l).unwrap_or(0)), ); - frame.render_widget(preview_block, inner); + f.render_widget(preview_block, inner); //} } } diff --git a/crates/television/ui.rs b/crates/television/ui.rs index 22ea135..42dc15e 100644 --- a/crates/television/ui.rs +++ b/crates/television/ui.rs @@ -1,5 +1,6 @@ use ratatui::style::{Color, Style, Stylize}; +pub mod help; pub mod input; pub mod layout; pub mod preview; diff --git a/crates/television/ui/help.rs b/crates/television/ui/help.rs new file mode 100644 index 0000000..f197d89 --- /dev/null +++ b/crates/television/ui/help.rs @@ -0,0 +1,190 @@ +use color_eyre::eyre::{OptionExt, Result}; +use ratatui::{ + style::{Color, Style}, + text::{Line, Span}, + widgets::Paragraph, +}; +use tracing::debug; + +use crate::{ + action::Action, + config::Config, + event::Key, + television::{Mode, Television}, +}; + +const SEPARATOR: &str = " "; +const ACTION_COLOR: Color = Color::DarkGray; +const KEY_COLOR: Color = Color::LightYellow; + +impl Television { + pub fn build_help_paragraph<'a>( + &self, + width: u16, + ) -> Result> { + let keymap = self + .config + .keybindings + .get(&self.mode) + .ok_or_eyre("No keybindings found for the current Mode")?; + + let mut help_spans = Vec::new(); + + // Results navigation + let prev: Vec<_> = keymap + .iter() + .filter(|(_key, action)| **action == Action::SelectPrevEntry) + .map(|(key, _action)| format!("{key}")) + .collect(); + + let next: Vec<_> = keymap + .iter() + .filter(|(_key, action)| **action == Action::SelectNextEntry) + .map(|(key, _action)| format!("{key}")) + .collect(); + + let results_spans = vec![ + Span::styled("↕ Results: [", Style::default().fg(ACTION_COLOR)), + Span::styled(prev.join(", "), Style::default().fg(KEY_COLOR)), + Span::styled(" | ", Style::default().fg(ACTION_COLOR)), + Span::styled(next.join(", "), Style::default().fg(KEY_COLOR)), + Span::styled("]", Style::default().fg(ACTION_COLOR)), + ]; + + help_spans.extend(results_spans); + help_spans.push(Span::styled(SEPARATOR, Style::default())); + + if self.mode == Mode::Channel { + // Preview navigation + let up: Vec<_> = keymap + .iter() + .filter(|(_key, action)| { + **action == Action::ScrollPreviewHalfPageUp + }) + .map(|(key, _action)| format!("{key}")) + .collect(); + + let down: Vec<_> = keymap + .iter() + .filter(|(_key, action)| { + **action == Action::ScrollPreviewHalfPageDown + }) + .map(|(key, _action)| format!("{key}")) + .collect(); + + let preview_spans = vec![ + Span::styled( + "↕ Preview: [", + Style::default().fg(ACTION_COLOR), + ), + Span::styled(up.join(", "), Style::default().fg(KEY_COLOR)), + Span::styled(" | ", Style::default().fg(ACTION_COLOR)), + Span::styled(down.join(", "), Style::default().fg(KEY_COLOR)), + Span::styled("]", Style::default().fg(ACTION_COLOR)), + ]; + + help_spans.extend(preview_spans); + help_spans.push(Span::styled(SEPARATOR, Style::default())); + + // Channels + let channels: Vec<_> = keymap + .iter() + .filter(|(_key, action)| { + **action == Action::ToChannelSelection + }) + .map(|(key, _action)| format!("{key}")) + .collect(); + + let channels_spans = vec![ + Span::styled("Channels: [", Style::default().fg(ACTION_COLOR)), + Span::styled( + channels.join(", "), + Style::default().fg(KEY_COLOR), + ), + Span::styled("]", Style::default().fg(ACTION_COLOR)), + ]; + + help_spans.extend(channels_spans); + help_spans.push(Span::styled(SEPARATOR, Style::default())); + } + + if self.mode == Mode::ChannelSelection { + // Pipe into + let channels: Vec<_> = keymap + .iter() + .filter(|(_key, action)| **action == Action::PipeInto) + .map(|(key, _action)| format!("{key}")) + .collect(); + + let channels_spans = vec![ + Span::styled( + "Pipe into: [", + Style::default().fg(ACTION_COLOR), + ), + Span::styled( + channels.join(", "), + Style::default().fg(KEY_COLOR), + ), + Span::styled("]", Style::default().fg(ACTION_COLOR)), + ]; + + help_spans.extend(channels_spans); + help_spans.push(Span::styled(SEPARATOR, Style::default())); + + // Select Channel + let select: Vec<_> = keymap + .iter() + .filter(|(_key, action)| **action == Action::SelectEntry) + .map(|(key, _action)| format!("{key}")) + .collect(); + + let select_spans = vec![ + Span::styled("Select: [", Style::default().fg(ACTION_COLOR)), + Span::styled( + select.join(", "), + Style::default().fg(KEY_COLOR), + ), + Span::styled("]", Style::default().fg(ACTION_COLOR)), + ]; + + help_spans.extend(select_spans); + help_spans.push(Span::styled(SEPARATOR, Style::default())); + } + + // Quit + let quit: Vec<_> = keymap + .iter() + .filter(|(_key, action)| **action == Action::Quit) + .map(|(key, _action)| format!("{key}")) + .collect(); + + let quit_spans = vec![ + Span::styled("Quit: [", Style::default().fg(ACTION_COLOR)), + Span::styled(quit.join(", "), Style::default().fg(KEY_COLOR)), + Span::styled("]", Style::default().fg(ACTION_COLOR)), + ]; + + help_spans.extend(quit_spans); + + // arrange lines depending on the width + let mut lines = Vec::new(); + let mut current_line = Line::default(); + let mut current_width = 0; + + for span in help_spans { + let span_width = span.content.chars().count() as u16; + if current_width + span_width > width { + lines.push(current_line); + current_line = Line::default(); + current_width = 0; + } + + current_line.push_span(span); + current_width += span_width; + } + + lines.push(current_line); + + Ok(Paragraph::new(lines)) + } +} diff --git a/crates/television/ui/layout.rs b/crates/television/ui/layout.rs index 4b8bf11..3519335 100644 --- a/crates/television/ui/layout.rs +++ b/crates/television/ui/layout.rs @@ -19,6 +19,7 @@ impl Default for Dimensions { } pub struct Layout { + pub help_bar: Rect, pub results: Rect, pub input: Rect, pub preview_title: Option, @@ -27,12 +28,14 @@ pub struct Layout { impl Layout { pub fn new( + help_bar: Rect, results: Rect, input: Rect, preview_title: Option, preview_window: Option, ) -> Self { Self { + help_bar, results, input, preview_title, @@ -40,56 +43,56 @@ impl Layout { } } - /// TODO: add diagram - #[allow(dead_code)] - pub fn all_panes_centered( - dimensions: Dimensions, + pub fn build( + dimensions: &Dimensions, area: Rect, + with_preview: bool, ) -> Self { let main_block = centered_rect(dimensions.x, dimensions.y, area); // split the main block into two vertical chunks - let chunks = layout::Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(50), - Constraint::Percentage(50), - ]) + let hz_chunks = layout::Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Fill(1), Constraint::Length(5)]) .split(main_block); - // left block: results + input field - let left_chunks = layout::Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(10), Constraint::Length(3)]) - .split(chunks[0]); + if with_preview { + // split the main block into two vertical chunks + let vt_chunks = layout::Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(50), + Constraint::Percentage(50), + ]) + .split(hz_chunks[0]); - // right block: preview title + preview - let right_chunks = layout::Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(3), Constraint::Min(10)]) - .split(chunks[1]); + // left block: results + input field + let left_chunks = layout::Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(10), Constraint::Length(3)]) + .split(vt_chunks[0]); - Self::new( - left_chunks[0], - left_chunks[1], - Some(right_chunks[0]), - Some(right_chunks[1]), - ) - } + // right block: preview title + preview + let right_chunks = layout::Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(10)]) + .split(vt_chunks[1]); - /// TODO: add diagram - #[allow(dead_code)] - pub fn results_only_centered( - dimensions: Dimensions, - area: Rect, - ) -> Self { - let main_block = centered_rect(dimensions.x, dimensions.y, area); - // split the main block into two vertical chunks - let chunks = layout::Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(10), Constraint::Length(3)]) - .split(main_block); + Self::new( + hz_chunks[1], + left_chunks[0], + left_chunks[1], + Some(right_chunks[0]), + Some(right_chunks[1]), + ) + } else { + // split the main block into two vertical chunks + let chunks = layout::Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(10), Constraint::Length(3)]) + .split(hz_chunks[0]); - Self::new(chunks[0], chunks[1], None, None) + Self::new(hz_chunks[1], chunks[0], chunks[1], None, None) + } } } diff --git a/crates/television/utils/files.rs b/crates/television/utils/files.rs index a99c398..714cc63 100644 --- a/crates/television/utils/files.rs +++ b/crates/television/utils/files.rs @@ -361,6 +361,7 @@ lazy_static! { "tmLanguage", "tmpl", "tmTheme", + "toml", "tpl", "ts", "tsv", diff --git a/crates/television/utils/strings.rs b/crates/television/utils/strings.rs index 09fe804..f36ba31 100644 --- a/crates/television/utils/strings.rs +++ b/crates/television/utils/strings.rs @@ -145,7 +145,7 @@ pub fn shrink_with_ellipsis(s: &str, max_length: usize) -> String { return s.to_string(); } - let half_max_length = (max_length / 2) - 2; + let half_max_length = (max_length / 2).saturating_sub(2); let first_half = slice_up_to_char_boundary(s, half_max_length); let second_half = slice_at_char_boundaries(s, s.len() - half_max_length, s.len()); diff --git a/crates/television_derive/src/lib.rs b/crates/television_derive/src/lib.rs index b2bdd5e..4934162 100644 --- a/crates/television_derive/src/lib.rs +++ b/crates/television_derive/src/lib.rs @@ -34,8 +34,11 @@ fn impl_cli_channel(ast: &syn::DeriveInput) -> TokenStream { }); let cli_enum = quote! { use clap::ValueEnum; + use serde::{Deserialize, Serialize}; + use strum::Display; + use std::default::Default; - #[derive(Debug, Clone, ValueEnum, Default, Copy)] + #[derive(Debug, Clone, ValueEnum, Default, Copy, PartialEq, Eq, Serialize, Deserialize, Display)] pub enum CliTvChannel { #[default] #(#cli_enum_variants),* @@ -53,7 +56,7 @@ fn impl_cli_channel(ast: &syn::DeriveInput) -> TokenStream { let inner_type = &fields.unnamed[0].ty; quote! { - CliTvChannel::#variant_name => Box::new(#inner_type::new()) + CliTvChannel::#variant_name => AvailableChannel::#variant_name(#inner_type::default()) } } else { panic!("Enum variants should have exactly one unnamed field."); @@ -67,7 +70,7 @@ fn impl_cli_channel(ast: &syn::DeriveInput) -> TokenStream { #cli_enum impl CliTvChannel { - pub fn to_channel(self) -> Box { + pub fn to_channel(self) -> AvailableChannel { match self { #(#arms),* } @@ -77,3 +80,98 @@ fn impl_cli_channel(ast: &syn::DeriveInput) -> TokenStream { gen.into() } + +/// This macro generates the TelevisionChannel trait implementation for the +/// given enum. +#[proc_macro_derive(TvChannel)] +pub fn tv_channel_derive(input: TokenStream) -> TokenStream { + // Construct a representation of Rust code as a syntax tree + // that we can manipulate + let ast = syn::parse(input).unwrap(); + + // Build the trait implementation + impl_tv_channel(&ast) +} + +fn impl_tv_channel(ast: &syn::DeriveInput) -> TokenStream { + // check that the struct is an enum + let variants = if let syn::Data::Enum(data_enum) = &ast.data { + &data_enum.variants + } else { + panic!("#[derive(TvChannel)] is only defined for enums"); + }; + + // check that the enum has at least one variant + assert!( + !variants.is_empty(), + "#[derive(TvChannel)] requires at least one variant" + ); + + // Generate the trait implementation for the TelevisionChannel trait + // FIXME: fix this + let trait_impl = quote! { + impl TelevisionChannel for AvailableChannel { + fn find(&mut self, pattern: &str) { + match self { + #( + AvailableChannel::#variants(_) => { + self.find(pattern); + } + )* + } + } + + fn results(&mut self, num_entries: u32, offset: u32) -> Vec { + match self { + #( + AvailableChannel::#variants(_) => { + self.results(num_entries, offset) + } + )* + } + } + + fn get_result(&self, index: u32) -> Option { + match self { + #( + AvailableChannel::#variants(_) => { + self.get_result(index) + } + )* + } + } + + fn result_count(&self) -> u32 { + match self { + #( + AvailableChannel::#variants(_) => { + self.result_count() + } + )* + } + } + + fn total_count(&self) -> u32 { + match self { + #( + AvailableChannel::#variants(_) => { + self.total_count() + } + )* + } + } + + fn running(&self) -> bool { + match self { + #( + AvailableChannel::#variants(_) => { + self.running() + } + )* + } + } + } + }; + + trait_impl.into() +}