great progress

This commit is contained in:
alexpasmantier 2024-10-27 00:37:13 +02:00
parent 9eea37a5b5
commit 76d32a01c2
29 changed files with 1192 additions and 754 deletions

View File

@ -8,13 +8,13 @@ ctrl-d = "ScrollPreviewHalfPageDown"
ctrl-u = "ScrollPreviewHalfPageUp" ctrl-u = "ScrollPreviewHalfPageUp"
enter = "SelectEntry" enter = "SelectEntry"
ctrl-enter = "SendToChannel" ctrl-enter = "SendToChannel"
ctrl-s = "ToggleChannelSelection" ctrl-s = "ToggleRemoteControl"
[keybindings.Guide] [keybindings.RemoteControl]
esc = "Quit" esc = "Quit"
down = "SelectNextEntry" down = "SelectNextEntry"
up = "SelectPrevEntry" up = "SelectPrevEntry"
ctrl-n = "SelectNextEntry" ctrl-n = "SelectNextEntry"
ctrl-p = "SelectPrevEntry" ctrl-p = "SelectPrevEntry"
enter = "SelectEntry" enter = "SelectEntry"
ctrl-s = "ToggleChannelSelection" ctrl-s = "ToggleRemoteControl"

14
Cargo.lock generated
View File

@ -1541,6 +1541,12 @@ dependencies = [
"hashbrown 0.15.0", "hashbrown 0.15.0",
] ]
[[package]]
name = "indoc"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5"
[[package]] [[package]]
name = "infer" name = "infer"
version = "0.16.0" version = "0.16.0"
@ -2038,24 +2044,24 @@ dependencies = [
[[package]] [[package]]
name = "ratatui" name = "ratatui"
version = "0.28.1" version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d" checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
dependencies = [ dependencies = [
"bitflags 2.6.0", "bitflags 2.6.0",
"cassowary", "cassowary",
"compact_str", "compact_str",
"crossterm", "crossterm",
"indoc",
"instability", "instability",
"itertools", "itertools",
"lru", "lru",
"paste", "paste",
"serde", "serde",
"strum", "strum",
"strum_macros",
"unicode-segmentation", "unicode-segmentation",
"unicode-truncate", "unicode-truncate",
"unicode-width 0.1.14", "unicode-width 0.2.0",
] ]
[[package]] [[package]]

View File

@ -9,10 +9,10 @@ build = "build.rs"
repository = "https://github.com/alexpasmantier/television" repository = "https://github.com/alexpasmantier/television"
keywords = ["search", "fuzzy", "preview", "tui", "terminal"] keywords = ["search", "fuzzy", "preview", "tui", "terminal"]
categories = [ categories = [
"command-line-utilities", "command-line-utilities",
"command-line-interface", "command-line-interface",
"concurrency", "concurrency",
"development-tools", "development-tools",
] ]
@ -28,12 +28,12 @@ members = ["crates/television_derive"]
television-derive = { version = "0.1.0", path = "crates/television_derive" } television-derive = { version = "0.1.0", path = "crates/television_derive" }
better-panic = "0.3.0" better-panic = "0.3.0"
clap = { version = "4.4.5", features = [ clap = { version = "4.4.5", features = [
"derive", "derive",
"cargo", "cargo",
"wrap_help", "wrap_help",
"unicode", "unicode",
"string", "string",
"unstable-styles", "unstable-styles",
] } ] }
color-eyre = "0.6.3" color-eyre = "0.6.3"
config = "0.14.0" config = "0.14.0"
@ -50,7 +50,7 @@ libc = "0.2.158"
nucleo = "0.5.0" nucleo = "0.5.0"
nucleo-matcher = "0.3.1" nucleo-matcher = "0.3.1"
parking_lot = "0.12.3" parking_lot = "0.12.3"
ratatui = { version = "0.28.1", features = ["serde", "macros"] } ratatui = { version = "0.29.0", features = ["serde", "macros"] }
regex = "1.10.6" regex = "1.10.6"
serde = { version = "1.0.208", features = ["derive"] } serde = { version = "1.0.208", features = ["derive"] }
serde_json = "1.0.125" serde_json = "1.0.125"

View File

@ -42,6 +42,6 @@ pub enum Action {
Error(String), Error(String),
NoOp, NoOp,
// channel actions // channel actions
ToggleChannelSelection, ToggleRemoteControl,
SendToChannel, SendToChannel,
} }

View File

@ -6,9 +6,9 @@ mod alias;
mod env; mod env;
mod files; mod files;
mod git_repos; mod git_repos;
pub mod remote_control;
pub mod stdin; pub mod stdin;
mod text; mod text;
pub mod tv_guide;
/// The interface that all television channels must implement. /// The interface that all television channels must implement.
/// ///
@ -94,13 +94,34 @@ pub trait OnAir: Send {
#[allow(dead_code, clippy::module_name_repetitions)] #[allow(dead_code, clippy::module_name_repetitions)]
#[derive(UnitChannel, CliChannel, Broadcast)] #[derive(UnitChannel, CliChannel, Broadcast)]
pub enum TelevisionChannel { pub enum TelevisionChannel {
/// The environment variables channel.
///
/// This channel allows to search through environment variables.
Env(env::Channel), Env(env::Channel),
/// The files channel.
///
/// This channel allows to search through files.
Files(files::Channel), Files(files::Channel),
/// The git repositories channel.
///
/// This channel allows to search through git repositories.
GitRepos(git_repos::Channel), GitRepos(git_repos::Channel),
/// The text channel.
///
/// This channel allows to search through the contents of text files.
Text(text::Channel), Text(text::Channel),
/// The standard input channel.
///
/// This channel allows to search through whatever is passed through stdin.
Stdin(stdin::Channel), Stdin(stdin::Channel),
/// The alias channel.
///
/// This channel allows to search through aliases.
Alias(alias::Channel), Alias(alias::Channel),
TvGuide(tv_guide::TvGuide), /// The remote control channel.
///
/// This channel allows to switch between different channels.
RemoteControl(remote_control::RemoteControl),
} }
/// NOTE: this could be generated by a derive macro /// NOTE: this could be generated by a derive macro

View File

@ -52,7 +52,7 @@ fn get_raw_aliases(shell: &str) -> Vec<String> {
let aliases = String::from_utf8(output.stdout).unwrap(); let aliases = String::from_utf8(output.stdout).unwrap();
aliases aliases
.lines() .lines()
.map(std::string::ToString::to_string) .map(ToString::to_string)
.collect() .collect()
} }
"zsh" => { "zsh" => {
@ -65,7 +65,7 @@ fn get_raw_aliases(shell: &str) -> Vec<String> {
let aliases = String::from_utf8(output.stdout).unwrap(); let aliases = String::from_utf8(output.stdout).unwrap();
aliases aliases
.lines() .lines()
.map(std::string::ToString::to_string) .map(ToString::to_string)
.collect() .collect()
} }
// TODO: add more shells // TODO: add more shells
@ -179,7 +179,7 @@ impl OnAir for Channel {
.collect() .collect()
} }
fn get_result(&self, index: u32) -> Option<super::Entry> { fn get_result(&self, index: u32) -> Option<Entry> {
let snapshot = self.matcher.snapshot(); let snapshot = self.matcher.snapshot();
snapshot.get_matched_item(index).map(|item| { snapshot.get_matched_item(index).map(|item| {
Entry::new(item.data.name.clone(), PreviewType::EnvVar) Entry::new(item.data.name.clone(), PreviewType::EnvVar)

View File

@ -76,14 +76,6 @@ impl OnAir for Channel {
} }
} }
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<Entry> { fn results(&mut self, num_entries: u32, offset: u32) -> Vec<Entry> {
let status = self.matcher.tick(Self::MATCHER_TICK_TIMEOUT); let status = self.matcher.tick(Self::MATCHER_TICK_TIMEOUT);
let snapshot = self.matcher.snapshot(); let snapshot = self.matcher.snapshot();
@ -157,6 +149,14 @@ impl OnAir for Channel {
}) })
} }
fn result_count(&self) -> u32 {
self.result_count
}
fn total_count(&self) -> u32 {
self.total_count
}
fn running(&self) -> bool { fn running(&self) -> bool {
self.running self.running
} }

View File

@ -101,7 +101,7 @@ impl OnAir for Channel {
.matched_items( .matched_items(
offset offset
..(num_entries + offset) ..(num_entries + offset)
.min(snapshot.matched_item_count()), .min(snapshot.matched_item_count()),
) )
.map(move |item| { .map(move |item| {
snapshot.pattern().column_pattern(0).indices( snapshot.pattern().column_pattern(0).indices(

View File

@ -1,4 +1,3 @@
use color_eyre::owo_colors::OwoColorize;
use devicons::FileIcon; use devicons::FileIcon;
use ignore::{overrides::OverrideBuilder, DirEntry}; use ignore::{overrides::OverrideBuilder, DirEntry};
use nucleo::{ use nucleo::{
@ -105,7 +104,7 @@ impl OnAir for Channel {
.matched_items( .matched_items(
offset offset
..(num_entries + offset) ..(num_entries + offset)
.min(snapshot.matched_item_count()), .min(snapshot.matched_item_count()),
) )
.map(move |item| { .map(move |item| {
snapshot.pattern().column_pattern(0).indices( snapshot.pattern().column_pattern(0).indices(
@ -201,7 +200,7 @@ fn get_ignored_paths() -> Vec<PathBuf> {
} }
#[allow(clippy::unused_async)] #[allow(clippy::unused_async)]
async fn crawl_for_repos( async fn crawl_for_repos(
starting_point: std::path::PathBuf, starting_point: PathBuf,
injector: nucleo::Injector<DirEntry>, injector: nucleo::Injector<DirEntry>,
entry_cache: Arc<Mutex<HashSet<String>>>, entry_cache: Arc<Mutex<HashSet<String>>>,
cache_valid: Arc<Mutex<bool>>, cache_valid: Arc<Mutex<bool>>,
@ -214,7 +213,7 @@ async fn crawl_for_repos(
Some(walker_overrides_builder.build().unwrap()), Some(walker_overrides_builder.build().unwrap()),
Some(get_ignored_paths()), Some(get_ignored_paths()),
) )
.build_parallel(); .build_parallel();
walker.run(|| { walker.run(|| {
let injector = injector.clone(); let injector = injector.clone();

View File

@ -14,7 +14,7 @@ use crate::{
previewers::PreviewType, previewers::PreviewType,
}; };
pub struct TvGuide { pub struct RemoteControl {
matcher: Nucleo<CliTvChannel>, matcher: Nucleo<CliTvChannel>,
last_pattern: String, last_pattern: String,
result_count: u32, result_count: u32,
@ -24,7 +24,7 @@ pub struct TvGuide {
const NUM_THREADS: usize = 1; const NUM_THREADS: usize = 1;
impl TvGuide { impl RemoteControl {
pub fn new() -> Self { pub fn new() -> Self {
let matcher = Nucleo::new( let matcher = Nucleo::new(
Config::DEFAULT, Config::DEFAULT,
@ -38,7 +38,7 @@ impl TvGuide {
cols[0] = (*e).to_string().into(); cols[0] = (*e).to_string().into();
}); });
} }
TvGuide { RemoteControl {
matcher, matcher,
last_pattern: String::new(), last_pattern: String::new(),
result_count: 0, result_count: 0,
@ -50,7 +50,7 @@ impl TvGuide {
const MATCHER_TICK_TIMEOUT: u64 = 2; const MATCHER_TICK_TIMEOUT: u64 = 2;
} }
impl Default for TvGuide { impl Default for RemoteControl {
fn default() -> Self { fn default() -> Self {
Self::new() Self::new()
} }
@ -61,7 +61,7 @@ const TV_ICON: FileIcon = FileIcon {
color: "#ffffff", color: "#ffffff",
}; };
impl OnAir for TvGuide { impl OnAir for RemoteControl {
fn find(&mut self, pattern: &str) { fn find(&mut self, pattern: &str) {
if pattern != self.last_pattern { if pattern != self.last_pattern {
self.matcher.pattern.reparse( self.matcher.pattern.reparse(
@ -75,14 +75,6 @@ impl OnAir for TvGuide {
} }
} }
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<Entry> { fn results(&mut self, num_entries: u32, offset: u32) -> Vec<Entry> {
let status = self.matcher.tick(Self::MATCHER_TICK_TIMEOUT); let status = self.matcher.tick(Self::MATCHER_TICK_TIMEOUT);
let snapshot = self.matcher.snapshot(); let snapshot = self.matcher.snapshot();
@ -130,6 +122,14 @@ impl OnAir for TvGuide {
}) })
} }
fn result_count(&self) -> u32 {
self.result_count
}
fn total_count(&self) -> u32 {
self.total_count
}
fn running(&self) -> bool { fn running(&self) -> bool {
self.running self.running
} }

View File

@ -90,7 +90,7 @@ impl OnAir for Channel {
.matched_items( .matched_items(
offset offset
..(num_entries + offset) ..(num_entries + offset)
.min(snapshot.matched_item_count()), .min(snapshot.matched_item_count()),
) )
.map(move |item| { .map(move |item| {
snapshot.pattern().column_pattern(0).indices( snapshot.pattern().column_pattern(0).indices(
@ -118,7 +118,7 @@ impl OnAir for Channel {
.collect() .collect()
} }
fn get_result(&self, index: u32) -> Option<super::Entry> { fn get_result(&self, index: u32) -> Option<Entry> {
let snapshot = self.matcher.snapshot(); let snapshot = self.matcher.snapshot();
snapshot.get_matched_item(index).map(|item| { snapshot.get_matched_item(index).map(|item| {
let content = item.matcher_columns[0].to_string(); let content = item.matcher_columns[0].to_string();

View File

@ -91,14 +91,6 @@ impl OnAir for Channel {
} }
} }
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<Entry> { fn results(&mut self, num_entries: u32, offset: u32) -> Vec<Entry> {
let status = self.matcher.tick(Self::MATCHER_TICK_TIMEOUT); let status = self.matcher.tick(Self::MATCHER_TICK_TIMEOUT);
let snapshot = self.matcher.snapshot(); let snapshot = self.matcher.snapshot();
@ -157,6 +149,14 @@ impl OnAir for Channel {
}) })
} }
fn result_count(&self) -> u32 {
self.result_count
}
fn total_count(&self) -> u32 {
self.total_count
}
fn running(&self) -> bool { fn running(&self) -> bool {
self.running self.running
} }
@ -219,9 +219,9 @@ async fn load_candidates(path: PathBuf, injector: Injector<CandidateLine>) {
Ok(bytes_read) => { Ok(bytes_read) => {
if (bytes_read == 0) if (bytes_read == 0)
|| is_not_text(&buffer) || is_not_text(&buffer)
.unwrap_or(false) .unwrap_or(false)
|| proportion_of_printable_ascii_characters(&buffer) || proportion_of_printable_ascii_characters(&buffer)
< PRINTABLE_ASCII_THRESHOLD < PRINTABLE_ASCII_THRESHOLD
{ {
return ignore::WalkState::Continue; return ignore::WalkState::Continue;
} }

View File

@ -52,7 +52,6 @@ lazy_static! {
impl Config { impl Config {
pub fn new() -> Result<Self, config::ConfigError> { pub fn new() -> Result<Self, config::ConfigError> {
//let default_config: Config = json5::from_str(CONFIG).unwrap();
let default_config: Config = toml::from_str(CONFIG).unwrap(); let default_config: Config = toml::from_str(CONFIG).unwrap();
let data_dir = get_data_dir(); let data_dir = get_data_dir();
let config_dir = get_config_dir(); let config_dir = get_config_dir();

View File

@ -154,7 +154,7 @@ impl EventLoop {
let (tx, rx) = mpsc::unbounded_channel(); let (tx, rx) = mpsc::unbounded_channel();
let tx_c = tx.clone(); let tx_c = tx.clone();
let tick_interval = let tick_interval =
tokio::time::Duration::from_secs_f64(1.0 / tick_rate); Duration::from_secs_f64(1.0 / tick_rate);
let (abort, mut abort_recv) = mpsc::unbounded_channel(); let (abort, mut abort_recv) = mpsc::unbounded_channel();

View File

@ -19,6 +19,7 @@ mod errors;
mod event; mod event;
mod fuzzy; mod fuzzy;
mod logging; mod logging;
mod picker;
mod previewers; mod previewers;
mod render; mod render;
mod television; mod television;
@ -28,8 +29,8 @@ mod utils;
#[tokio::main(flavor = "multi_thread")] #[tokio::main(flavor = "multi_thread")]
async fn main() -> Result<()> { async fn main() -> Result<()> {
crate::errors::init()?; errors::init()?;
crate::logging::init()?; logging::init()?;
let args = Cli::parse(); let args = Cli::parse();

114
crates/television/picker.rs Normal file
View File

@ -0,0 +1,114 @@
use crate::ui::input::Input;
use crate::utils::strings::EMPTY_STRING;
use ratatui::widgets::ListState;
#[derive(Debug)]
pub struct Picker {
pub(crate) state: ListState,
pub(crate) relative_state: ListState,
pub(crate) view_offset: usize,
_inverted: bool,
pub(crate) input: Input,
}
impl Default for Picker {
fn default() -> Self {
Self::new()
}
}
impl Picker {
fn new() -> Self {
Self {
state: ListState::default(),
relative_state: ListState::default(),
view_offset: 0,
_inverted: false,
input: Input::new(EMPTY_STRING.to_string()),
}
}
pub(crate) fn inverted(mut self) -> Self {
self._inverted = !self._inverted;
self
}
pub(crate) fn reset_selection(&mut self) {
self.state.select(Some(0));
self.relative_state.select(Some(0));
self.view_offset = 0;
}
pub(crate) fn reset_input(&mut self) {
self.input.reset();
}
pub(crate) fn selected(&self) -> Option<usize> {
self.state.selected()
}
pub(crate) fn select(&mut self, index: Option<usize>) {
self.state.select(index);
}
fn relative_selected(&self) -> Option<usize> {
self.relative_state.selected()
}
pub(crate) fn relative_select(&mut self, index: Option<usize>) {
self.relative_state.select(index);
}
pub(crate) fn select_next(&mut self, total_items: usize, height: usize) {
if self._inverted {
self._select_prev(total_items, height);
} else {
self._select_next(total_items, height);
}
}
pub(crate) fn select_prev(&mut self, total_items: usize, height: usize) {
if self._inverted {
self._select_next(total_items, height);
} else {
self._select_prev(total_items, height);
}
}
fn _select_next(&mut self, total_items: usize, height: usize) {
let selected = self.selected().unwrap_or(0);
let relative_selected = self.relative_selected().unwrap_or(0);
if selected > 0 {
self.select(Some(selected - 1));
self.relative_select(Some(relative_selected.saturating_sub(1)));
if relative_selected == 0 {
self.view_offset = self.view_offset.saturating_sub(1);
}
} else {
self.view_offset = total_items.saturating_sub(height - 2);
self.select(Some((total_items).saturating_sub(1)));
self.relative_select(Some(height - 3));
}
}
fn _select_prev(&mut self, total_items: usize, height: usize) {
let new_index = (self.selected().unwrap_or(0) + 1) % total_items;
self.select(Some(new_index));
if new_index == 0 {
self.view_offset = 0;
self.relative_select(Some(0));
return;
}
if self.relative_selected().unwrap_or(0) == height - 3 {
self.view_offset += 1;
self.relative_select(Some(
self.selected().unwrap_or(0).min(height - 3),
));
} else {
self.relative_select(Some(
(self.relative_selected().unwrap_or(0) + 1)
.min(self.selected().unwrap_or(0)),
));
}
}
}

View File

@ -85,7 +85,7 @@ fn tree<P: AsRef<Path>>(
.build(); .build();
let mut level_entry_count: u8 = 0; let mut level_entry_count: u8 = 0;
for path in w.skip(1).filter_map(std::result::Result::ok) { for path in w.skip(1).filter_map(Result::ok) {
let m = path.metadata().unwrap(); let m = path.metadata().unwrap();
if m.is_dir() && max_depth > 1 { if m.is_dir() && max_depth > 1 {
root.push(tree( root.push(tree(

View File

@ -5,11 +5,10 @@ use std::fs::File;
use std::io::{BufRead, BufReader, Read, Seek}; use std::io::{BufRead, BufReader, Read, Seek};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use syntect::easy::HighlightLines;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use syntect::{ use syntect::{
highlighting::{Style, Theme, ThemeSet}, highlighting::{Theme, ThemeSet},
parsing::SyntaxSet, parsing::SyntaxSet,
}; };
use tracing::{debug, warn}; use tracing::{debug, warn};
@ -82,7 +81,7 @@ impl FilePreviewer {
entry.name.clone(), entry.name.clone(),
preview.clone(), preview.clone(),
) )
.await; .await;
// compute the highlighted version in the background // compute the highlighted version in the background
let mut reader = let mut reader =
@ -226,8 +225,8 @@ impl FilePreviewer {
if let Ok(bytes_read) = f.read(&mut buffer) { if let Ok(bytes_read) = f.read(&mut buffer) {
if bytes_read > 0 if bytes_read > 0
&& proportion_of_printable_ascii_characters( && proportion_of_printable_ascii_characters(
&buffer[..bytes_read], &buffer[..bytes_read],
) > PRINTABLE_ASCII_THRESHOLD ) > PRINTABLE_ASCII_THRESHOLD
{ {
file_type = FileType::Text; file_type = FileType::Text;
} }

View File

@ -1,62 +1,44 @@
use color_eyre::Result; use crate::channels::remote_control::RemoteControl;
use futures::executor::block_on; use crate::channels::OnAir;
use ratatui::{ use crate::channels::UnitChannel;
layout::{ use crate::picker::Picker;
Alignment, Constraint, Direction, Layout as RatatuiLayout, Rect, use crate::ui::layout::{Dimensions, Layout};
},
style::{Color, Style, Stylize},
text::{Line, Span},
widgets::{
block::{Position, Title},
Block, BorderType, Borders, ListState, Padding, Paragraph, Wrap,
},
Frame,
};
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, str::FromStr};
use strum::Display;
use tokio::sync::mpsc::UnboundedSender;
use crate::ui::preview::DEFAULT_PREVIEW_TITLE_FG;
use crate::ui::results::build_results_list;
use crate::ui::{
layout::{Dimensions, Layout},
logo::build_logo_paragraph,
};
use crate::utils::strings::EMPTY_STRING; use crate::utils::strings::EMPTY_STRING;
use crate::{action::Action, config::Config}; use crate::{action::Action, config::Config};
use crate::{channels::tv_guide::TvGuide, ui::get_border_style};
use crate::{channels::OnAir, utils::strings::shrink_with_ellipsis};
use crate::{ use crate::{
channels::TelevisionChannel, ui::input::actions::InputActionHandler, channels::TelevisionChannel, ui::input::actions::InputActionHandler,
}; };
use crate::{channels::UnitChannel, ui::input::Input};
use crate::{ use crate::{
entry::{Entry, ENTRY_PLACEHOLDER}, entry::{Entry, ENTRY_PLACEHOLDER},
ui::spinner::Spinner, ui::spinner::Spinner,
}; };
use crate::{previewers::Previewer, ui::spinner::SpinnerState}; use crate::{previewers::Previewer, ui::spinner::SpinnerState};
use color_eyre::Result;
use futures::executor::block_on;
use ratatui::{layout::Rect, style::Color, widgets::Paragraph, Frame};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use strum::Display;
use tokio::sync::mpsc::UnboundedSender;
#[derive( #[derive(
PartialEq, Copy, Clone, Hash, Eq, Debug, Serialize, Deserialize, Display, PartialEq, Copy, Clone, Hash, Eq, Debug, Serialize, Deserialize, Display,
)] )]
pub enum Mode { pub enum Mode {
Channel, Channel,
Guide, RemoteControl,
SendToChannel, SendToChannel,
} }
pub struct Television { pub struct Television {
action_tx: Option<UnboundedSender<Action>>, action_tx: Option<UnboundedSender<Action>>,
pub config: Config, pub config: Config,
channel: TelevisionChannel, pub(crate) channel: TelevisionChannel,
guide: TelevisionChannel, pub(crate) remote_control: TelevisionChannel,
current_pattern: String,
pub mode: Mode, pub mode: Mode,
input: Input, current_pattern: String,
picker_state: ListState, pub(crate) results_picker: Picker,
relative_picker_state: ListState, pub(crate) rc_picker: Picker,
picker_view_offset: usize,
results_area_height: u32, results_area_height: u32,
pub previewer: Previewer, pub previewer: Previewer,
pub preview_scroll: Option<u16>, pub preview_scroll: Option<u16>,
@ -69,30 +51,26 @@ pub struct Television {
/// are rendered correctly even when resizing the terminal while still /// are rendered correctly even when resizing the terminal while still
/// benefiting from a cache mechanism. /// benefiting from a cache mechanism.
pub meta_paragraph_cache: HashMap<(String, u16, u16), Paragraph<'static>>, pub meta_paragraph_cache: HashMap<(String, u16, u16), Paragraph<'static>>,
spinner: Spinner, pub(crate) spinner: Spinner,
spinner_state: SpinnerState, pub(crate) spinner_state: SpinnerState,
} }
impl Television { impl Television {
#[must_use] #[must_use]
pub fn new(mut channel: TelevisionChannel) -> Self { pub fn new(mut channel: TelevisionChannel) -> Self {
channel.find(EMPTY_STRING); channel.find(EMPTY_STRING);
let guide = TelevisionChannel::TvGuide(TvGuide::new());
let spinner = Spinner::default(); let spinner = Spinner::default();
let spinner_state = SpinnerState::from(&spinner);
Self { Self {
action_tx: None, action_tx: None,
config: Config::default(), config: Config::default(),
channel, channel,
guide, remote_control: TelevisionChannel::RemoteControl(
current_pattern: EMPTY_STRING.to_string(), RemoteControl::new(),
),
mode: Mode::Channel, mode: Mode::Channel,
input: Input::new(EMPTY_STRING.to_string()), current_pattern: EMPTY_STRING.to_string(),
picker_state: ListState::default(), results_picker: Picker::default(),
relative_picker_state: ListState::default(), rc_picker: Picker::default().inverted(),
picker_view_offset: 0,
results_area_height: 0, results_area_height: 0,
previewer: Previewer::new(), previewer: Previewer::new(),
preview_scroll: None, preview_scroll: None,
@ -100,7 +78,7 @@ impl Television {
current_preview_total_lines: 0, current_preview_total_lines: 0,
meta_paragraph_cache: HashMap::new(), meta_paragraph_cache: HashMap::new(),
spinner, spinner,
spinner_state, spinner_state: SpinnerState::from(&spinner),
} }
} }
@ -111,9 +89,9 @@ impl Television {
/// FIXME: this needs rework /// FIXME: this needs rework
pub fn change_channel(&mut self, channel: TelevisionChannel) { pub fn change_channel(&mut self, channel: TelevisionChannel) {
self.reset_preview_scroll(); self.reset_preview_scroll();
self.reset_results_selection(); self.reset_picker_selection();
self.reset_picker_input();
self.current_pattern = EMPTY_STRING.to_string(); self.current_pattern = EMPTY_STRING.to_string();
self.input.reset();
self.channel.shutdown(); self.channel.shutdown();
self.channel = channel; self.channel = channel;
} }
@ -123,92 +101,84 @@ impl Television {
Mode::Channel => { Mode::Channel => {
self.channel.find(pattern); self.channel.find(pattern);
} }
Mode::Guide | Mode::SendToChannel => { Mode::RemoteControl | Mode::SendToChannel => {
self.guide.find(pattern); self.remote_control.find(pattern);
} }
} }
} }
#[must_use] #[must_use]
pub fn get_selected_entry(&mut self) -> Option<Entry> { pub fn get_selected_entry(&mut self) -> Option<Entry> {
self.picker_state.selected().and_then(|i| match self.mode { match self.mode {
Mode::Channel => { Mode::Channel => self.results_picker.selected().and_then(|i| {
self.channel.get_result(u32::try_from(i).unwrap()) self.channel.get_result(u32::try_from(i).unwrap())
}),
Mode::RemoteControl | Mode::SendToChannel => {
self.rc_picker.selected().and_then(|i| {
self.remote_control.get_result(u32::try_from(i).unwrap())
})
} }
Mode::Guide | Mode::SendToChannel => { }
self.guide.get_result(u32::try_from(i).unwrap())
}
})
} }
pub fn select_prev_entry(&mut self) { pub fn select_prev_entry(&mut self) {
let result_count = match self.mode { let (result_count, picker) = match self.mode {
Mode::Channel => self.channel.result_count(), Mode::Channel => {
Mode::Guide | Mode::SendToChannel => self.guide.total_count(), (self.channel.result_count(), &mut self.results_picker)
}
Mode::RemoteControl | Mode::SendToChannel => {
(self.remote_control.total_count(), &mut self.rc_picker)
}
}; };
if result_count == 0 { if result_count == 0 {
return; return;
} }
let new_index = (self.picker_state.selected().unwrap_or(0) + 1) picker.select_prev(
% result_count as usize; result_count as usize,
self.picker_state.select(Some(new_index)); self.results_area_height as usize,
if new_index == 0 { );
self.picker_view_offset = 0;
self.relative_picker_state.select(Some(0));
return;
}
if self.relative_picker_state.selected().unwrap_or(0)
== self.results_area_height as usize - 3
{
self.picker_view_offset += 1;
self.relative_picker_state.select(Some(
self.picker_state
.selected()
.unwrap_or(0)
.min(self.results_area_height as usize - 3),
));
} else {
self.relative_picker_state.select(Some(
(self.relative_picker_state.selected().unwrap_or(0) + 1)
.min(self.picker_state.selected().unwrap_or(0)),
));
}
} }
pub fn select_next_entry(&mut self) { pub fn select_next_entry(&mut self) {
let result_count = match self.mode { let (result_count, picker) = match self.mode {
Mode::Channel => self.channel.result_count(), Mode::Channel => {
Mode::Guide | Mode::SendToChannel => self.guide.total_count(), (self.channel.result_count(), &mut self.results_picker)
}
Mode::RemoteControl | Mode::SendToChannel => {
(self.remote_control.total_count(), &mut self.rc_picker)
}
}; };
if result_count == 0 { if result_count == 0 {
return; return;
} }
let selected = self.picker_state.selected().unwrap_or(0); picker.select_next(
let relative_selected = result_count as usize,
self.relative_picker_state.selected().unwrap_or(0); self.results_area_height as usize,
if selected > 0 { );
self.picker_state.select(Some(selected - 1));
self.relative_picker_state
.select(Some(relative_selected.saturating_sub(1)));
if relative_selected == 0 {
self.picker_view_offset =
self.picker_view_offset.saturating_sub(1);
}
} else {
self.picker_view_offset = result_count
.saturating_sub(self.results_area_height - 2)
as usize;
self.picker_state
.select(Some((result_count as usize).saturating_sub(1)));
self.relative_picker_state
.select(Some(self.results_area_height as usize - 3));
}
} }
fn reset_preview_scroll(&mut self) { fn reset_preview_scroll(&mut self) {
self.preview_scroll = None; self.preview_scroll = None;
} }
fn reset_picker_selection(&mut self) {
match self.mode {
Mode::Channel => self.results_picker.reset_selection(),
Mode::RemoteControl | Mode::SendToChannel => {
self.rc_picker.reset_selection()
}
}
}
fn reset_picker_input(&mut self) {
match self.mode {
Mode::Channel => self.results_picker.reset_input(),
Mode::RemoteControl | Mode::SendToChannel => {
self.rc_picker.reset_input()
}
}
}
pub fn scroll_preview_down(&mut self, offset: u16) { pub fn scroll_preview_down(&mut self, offset: u16) {
if self.preview_scroll.is_none() { if self.preview_scroll.is_none() {
self.preview_scroll = Some(0); self.preview_scroll = Some(0);
@ -228,18 +198,12 @@ impl Television {
self.preview_scroll = Some(scroll.saturating_sub(offset)); self.preview_scroll = Some(scroll.saturating_sub(offset));
} }
} }
fn reset_results_selection(&mut self) {
self.picker_state.select(Some(0));
self.relative_picker_state.select(Some(0));
self.picker_view_offset = 0;
}
} }
// Styles // Styles
// input // input
const DEFAULT_INPUT_FG: Color = Color::LightRed; pub(crate) const DEFAULT_INPUT_FG: Color = Color::LightRed;
const DEFAULT_RESULTS_COUNT_FG: Color = Color::LightRed; pub(crate) const DEFAULT_RESULTS_COUNT_FG: Color = Color::LightRed;
impl Television { impl Television {
/// Register an action handler that can send actions for processing if necessary. /// Register an action handler that can send actions for processing if necessary.
@ -286,17 +250,23 @@ impl Television {
| Action::GoToInputStart | Action::GoToInputStart
| Action::GoToNextChar | Action::GoToNextChar
| Action::GoToPrevChar => { | Action::GoToPrevChar => {
self.input.handle_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_action(&action);
match action { match action {
Action::AddInputChar(_) Action::AddInputChar(_)
| Action::DeletePrevChar | Action::DeletePrevChar
| Action::DeleteNextChar => { | Action::DeleteNextChar => {
let new_pattern = self.input.value().to_string(); let new_pattern = input.value().to_string();
if new_pattern != self.current_pattern { if new_pattern != self.current_pattern {
self.current_pattern.clone_from(&new_pattern); self.current_pattern.clone_from(&new_pattern);
self.find(&new_pattern); self.find(&new_pattern);
self.reset_preview_scroll(); self.reset_preview_scroll();
self.reset_results_selection(); self.reset_picker_selection();
} }
} }
_ => {} _ => {}
@ -314,13 +284,15 @@ impl Television {
Action::ScrollPreviewUp => self.scroll_preview_up(1), Action::ScrollPreviewUp => self.scroll_preview_up(1),
Action::ScrollPreviewHalfPageDown => self.scroll_preview_down(20), Action::ScrollPreviewHalfPageDown => self.scroll_preview_down(20),
Action::ScrollPreviewHalfPageUp => self.scroll_preview_up(20), Action::ScrollPreviewHalfPageUp => self.scroll_preview_up(20),
Action::ToggleChannelSelection => match self.mode { Action::ToggleRemoteControl => match self.mode {
Mode::Channel => { Mode::Channel => {
self.reset_screen(); self.mode = Mode::RemoteControl;
self.mode = Mode::Guide;
} }
Mode::Guide => { Mode::RemoteControl => {
self.reset_screen(); // this resets the RC picker
self.reset_picker_input();
self.remote_control.find(EMPTY_STRING);
self.reset_picker_selection();
self.mode = Mode::Channel; self.mode = Mode::Channel;
} }
Mode::SendToChannel => {} Mode::SendToChannel => {}
@ -333,10 +305,14 @@ impl Television {
.as_ref() .as_ref()
.unwrap() .unwrap()
.send(Action::SelectAndExit)?, .send(Action::SelectAndExit)?,
Mode::Guide => { Mode::RemoteControl => {
if let Ok(new_channel) = if let Ok(new_channel) =
TelevisionChannel::try_from(&entry) TelevisionChannel::try_from(&entry)
{ {
// this resets the RC picker
self.reset_picker_selection();
self.reset_picker_input();
self.remote_control.find(EMPTY_STRING);
self.mode = Mode::Channel; self.mode = Mode::Channel;
self.change_channel(new_channel); self.change_channel(new_channel);
} }
@ -356,7 +332,8 @@ impl Television {
Action::SendToChannel => { Action::SendToChannel => {
self.mode = Mode::SendToChannel; self.mode = Mode::SendToChannel;
// TODO: build new guide from current channel based on which are pipeable into // TODO: build new guide from current channel based on which are pipeable into
self.guide = TelevisionChannel::TvGuide(TvGuide::new()); self.remote_control =
TelevisionChannel::RemoteControl(RemoteControl::new());
self.reset_screen(); self.reset_screen();
} }
_ => {} _ => {}
@ -366,11 +343,11 @@ impl Television {
fn reset_screen(&mut self) { fn reset_screen(&mut self) {
self.reset_preview_scroll(); self.reset_preview_scroll();
self.reset_results_selection(); self.reset_picker_selection();
self.reset_picker_input();
self.current_pattern = EMPTY_STRING.to_string(); self.current_pattern = EMPTY_STRING.to_string();
self.input.reset();
self.channel.find(EMPTY_STRING); self.channel.find(EMPTY_STRING);
self.guide.find(EMPTY_STRING); self.remote_control.find(EMPTY_STRING);
} }
/// Render the television on the screen. /// Render the television on the screen.
@ -382,278 +359,43 @@ impl Television {
/// # Returns /// # Returns
/// * `Result<()>` - An Ok result or an error. /// * `Result<()>` - An Ok result or an error.
pub fn draw(&mut self, f: &mut Frame, area: Rect) -> Result<()> { pub fn draw(&mut self, f: &mut Frame, area: Rect) -> Result<()> {
let dimensions = match self.mode {
Mode::Channel => &Dimensions::default(),
Mode::Guide | Mode::SendToChannel => &Dimensions::new(30, 70),
};
let layout = Layout::build( let layout = Layout::build(
dimensions, &Dimensions::default(),
area, area,
match self.mode { !matches!(self.mode, Mode::Channel),
Mode::Channel => true,
Mode::Guide | Mode::SendToChannel => false,
},
); );
let metadata_block = Block::default() // help bar (metadata, keymaps, logo)
.borders(Borders::ALL) self.draw_help_bar(f, &layout)?;
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Blue))
.padding(Padding::horizontal(1))
.style(Style::default());
let metadata_table = self.build_metadata_table().block(metadata_block);
f.render_widget(metadata_table, layout.help_bar_left);
let keymaps_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Blue))
.style(Style::default())
.padding(Padding::horizontal(1));
let keymaps_table = self.build_help_table()?.block(keymaps_block);
f.render_widget(keymaps_table, layout.help_bar_middle);
let logo_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Blue))
.style(Style::default().fg(Color::Yellow))
.padding(Padding::horizontal(1));
let logo_paragraph = build_logo_paragraph().block(logo_block);
f.render_widget(logo_paragraph, layout.help_bar_right);
self.results_area_height = u32::from(layout.results.height); self.results_area_height = u32::from(layout.results.height);
if let Some(preview_window) = layout.preview_window { self.preview_pane_height = layout.preview_window.height;
self.preview_pane_height = preview_window.height;
}
// top left block: results // top left block: results
let results_block = Block::default() self.draw_results_list(f, &layout)?;
.title(
Title::from(" Results ")
.position(Position::Top)
.alignment(Alignment::Center),
)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(get_border_style(false))
.style(Style::default())
.padding(Padding::right(1));
let result_count = match self.mode {
Mode::Channel => self.channel.result_count(),
Mode::Guide | Mode::SendToChannel => self.guide.total_count(),
};
if result_count > 0 && self.picker_state.selected().is_none() {
self.picker_state.select(Some(0));
self.relative_picker_state.select(Some(0));
}
let entries = match self.mode {
Mode::Channel => self.channel.results(
layout.results.height.saturating_sub(2).into(),
u32::try_from(self.picker_view_offset)?,
),
Mode::Guide | Mode::SendToChannel => self.guide.results(
layout.results.height.saturating_sub(2).into(),
u32::try_from(self.picker_view_offset)?,
),
};
let results_list = build_results_list(results_block, &entries);
f.render_stateful_widget(
results_list,
layout.results,
&mut self.relative_picker_state,
);
// bottom left block: input // bottom left block: input
let input_block = Block::default() self.draw_input_box(f, &layout)?;
.title(
Title::from(" Pattern ")
.position(Position::Top)
.alignment(Alignment::Center),
)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(get_border_style(false))
.style(Style::default());
let input_block_inner = input_block.inner(layout.input); let selected_entry =
self.get_selected_entry().unwrap_or(ENTRY_PLACEHOLDER);
let preview = block_on(self.previewer.preview(&selected_entry));
f.render_widget(input_block, layout.input); // top right block: preview title
self.current_preview_total_lines = preview.total_lines();
self.draw_preview_title_block(f, &layout, &selected_entry, &preview)?;
// split input block into 3 parts: prompt symbol, input, result count // bottom right block: preview content
let total_count = match self.mode { self.draw_preview_content_block(
Mode::Channel => self.channel.total_count(), f,
Mode::Guide | Mode::SendToChannel => self.guide.total_count(), &layout,
}; &selected_entry,
let inner_input_chunks = RatatuiLayout::default() &preview,
.direction(Direction::Horizontal) )?;
.constraints([
// prompt symbol
Constraint::Length(2),
// input field
Constraint::Fill(1),
// result count
Constraint::Length(
3 * ((total_count as f32).log10().ceil() as u16 + 1) + 3,
),
// spinner
Constraint::Length(1),
])
.split(input_block_inner);
let arrow_block = Block::default(); // remote control
let arrow = Paragraph::new(Span::styled( if matches!(self.mode, Mode::RemoteControl) {
"> ", self.draw_remote_control(f, &layout.remote_control.unwrap())?;
Style::default().fg(DEFAULT_INPUT_FG).bold(),
))
.block(arrow_block);
f.render_widget(arrow, inner_input_chunks[0]);
let interactive_input_block = Block::default();
// keep 2 for borders and 1 for cursor
let width = inner_input_chunks[1].width.max(3) - 3;
let scroll = self.input.visual_scroll(width as usize);
let input = Paragraph::new(self.input.value())
.scroll((0, u16::try_from(scroll)?))
.block(interactive_input_block)
.style(Style::default().fg(DEFAULT_INPUT_FG).bold().italic())
.alignment(Alignment::Left);
f.render_widget(input, inner_input_chunks[1]);
if match self.mode {
Mode::Channel => self.channel.running(),
Mode::Guide | Mode::SendToChannel => self.guide.running(),
} {
f.render_stateful_widget(
self.spinner,
inner_input_chunks[3],
&mut self.spinner_state,
);
}
let result_count_block = Block::default();
let result_count_paragraph = Paragraph::new(Span::styled(
format!(
" {} / {} ",
if result_count == 0 {
0
} else {
self.picker_state.selected().unwrap_or(0) + 1
},
result_count,
),
Style::default().fg(DEFAULT_RESULTS_COUNT_FG).italic(),
))
.block(result_count_block)
.alignment(Alignment::Right);
f.render_widget(result_count_paragraph, inner_input_chunks[2]);
// 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 =
self.get_selected_entry().unwrap_or(ENTRY_PLACEHOLDER);
let preview = block_on(self.previewer.preview(&selected_entry));
if let Some(preview_title_area) = layout.preview_title {
// top right block: preview title
self.current_preview_total_lines = preview.total_lines();
let mut preview_title_spans = Vec::new();
if let Some(icon) = &selected_entry.icon {
preview_title_spans.push(Span::styled(
{
let mut icon_str = String::from(" ");
icon_str.push(icon.icon);
icon_str.push(' ');
icon_str
},
Style::default().fg(Color::from_str(icon.color)?),
));
}
preview_title_spans.push(Span::styled(
shrink_with_ellipsis(
&preview.title,
preview_title_area.width.saturating_sub(4) as usize,
),
Style::default().fg(DEFAULT_PREVIEW_TITLE_FG).bold(),
));
let preview_title =
Paragraph::new(Line::from(preview_title_spans))
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(get_border_style(false)),
)
.alignment(Alignment::Left);
f.render_widget(preview_title, preview_title_area);
}
if let Some(preview_area) = layout.preview_window {
// file preview
let preview_outer_block = Block::default()
.title(
Title::from(" Preview ")
.position(Position::Top)
.alignment(Alignment::Center),
)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(get_border_style(false))
.style(Style::default())
.padding(Padding::right(1));
let preview_inner_block = Block::default()
.style(Style::default())
.padding(Padding {
top: 0,
right: 1,
bottom: 0,
left: 1,
});
let inner = preview_outer_block.inner(preview_area);
f.render_widget(preview_outer_block, preview_area);
//if let PreviewContent::Image(img) = &preview.content {
// let image_component = StatefulImage::new(None);
// frame.render_stateful_widget(
// image_component,
// inner,
// &mut img.clone(),
// );
//} else {
let preview_block = self.build_preview_paragraph(
preview_inner_block,
inner,
&preview,
selected_entry
.line_number
.map(|l| u16::try_from(l).unwrap_or(0)),
);
f.render_widget(preview_block, inner);
//}
}
} }
Ok(()) Ok(())
} }

View File

@ -1,14 +1,15 @@
use ratatui::style::{Color, Style, Stylize}; use ratatui::style::{Color, Style};
pub mod help; pub(crate) mod help;
pub mod input; pub mod input;
pub mod keymap;
pub mod layout; pub mod layout;
pub mod logo; pub mod logo;
pub mod metadata; pub mod metadata;
pub mod preview; pub mod preview;
mod remote_control;
pub mod results; pub mod results;
pub mod spinner; pub mod spinner;
// input // input
//const DEFAULT_INPUT_FG: Color = Color::Rgb(200, 200, 200); //const DEFAULT_INPUT_FG: Color = Color::Rgb(200, 200, 200);
//const DEFAULT_RESULTS_COUNT_FG: Color = Color::Rgb(150, 150, 150); //const DEFAULT_RESULTS_COUNT_FG: Color = Color::Rgb(150, 150, 150);

View File

@ -1,237 +1,64 @@
use color_eyre::eyre::{OptionExt, Result}; use crate::television::Television;
use ratatui::{ use crate::ui::layout::Layout;
layout::Constraint, use crate::ui::logo::build_logo_paragraph;
style::{Color, Style}, use ratatui::layout::Rect;
text::{Line, Span}, use ratatui::prelude::{Color, Style};
widgets::{Cell, Row, Table}, use ratatui::widgets::{Block, BorderType, Borders, Padding};
}; use ratatui::Frame;
use std::collections::HashMap;
use crate::{ pub fn draw_logo_block(f: &mut Frame, area: Rect) {
action::Action, let logo_block = Block::default()
event::Key, .borders(Borders::ALL)
television::{Mode, Television}, .border_type(BorderType::Rounded)
}; .border_style(Style::default().fg(Color::Blue))
.style(Style::default().fg(Color::Yellow))
.padding(Padding::horizontal(1));
const ACTION_COLOR: Color = Color::DarkGray; let logo_paragraph = build_logo_paragraph().block(logo_block);
const KEY_COLOR: Color = Color::LightYellow;
f.render_widget(logo_paragraph, area);
}
impl Television { impl Television {
pub fn build_help_table<'a>(&self) -> Result<Table<'a>> { pub(crate) fn draw_help_bar(
match self.mode { &self,
Mode::Channel => self.build_help_table_for_channel(), f: &mut Frame,
Mode::Guide => self.build_help_table_for_channel_selection(), layout: &Layout,
Mode::SendToChannel => self.build_help_table_for_channel(), ) -> color_eyre::Result<()> {
} self.draw_metadata_block(f, layout.help_bar_left);
self.draw_keymaps_block(f, layout.help_bar_middle)?;
draw_logo_block(f, layout.help_bar_right);
Ok(())
} }
fn build_help_table_for_channel<'a>(&self) -> Result<Table<'a>> { fn draw_metadata_block(&self, f: &mut Frame, area: Rect) {
let keymap = self.keymap_for_mode()?; let metadata_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Blue))
.padding(Padding::horizontal(1))
.style(Style::default());
// Results navigation let metadata_table = self.build_metadata_table().block(metadata_block);
let prev = keys_for_action(keymap, &Action::SelectPrevEntry);
let next = keys_for_action(keymap, &Action::SelectNextEntry);
let results_row = Row::new(build_cells_for_key_groups(
"↕ Results navigation",
vec![prev, next],
));
// Preview navigation f.render_widget(metadata_table, area);
let up_keys =
keys_for_action(keymap, &Action::ScrollPreviewHalfPageUp);
let down_keys =
keys_for_action(keymap, &Action::ScrollPreviewHalfPageDown);
let preview_row = Row::new(build_cells_for_key_groups(
"↕ Preview navigation",
vec![up_keys, down_keys],
));
// Select entry
let select_entry_keys = keys_for_action(keymap, &Action::SelectEntry);
let select_entry_row = Row::new(build_cells_for_key_groups(
"✓ Select entry",
vec![select_entry_keys],
));
// Send to channel
let send_to_channel_keys =
keys_for_action(keymap, &Action::SendToChannel);
let send_to_channel_row = Row::new(build_cells_for_key_groups(
"⇉ Send results to",
vec![send_to_channel_keys],
));
// Switch channels
let switch_channels_keys =
keys_for_action(keymap, &Action::ToggleChannelSelection);
let switch_channels_row = Row::new(build_cells_for_key_groups(
"⨀ Switch channels",
vec![switch_channels_keys],
));
// MISC line (quit, help, etc.)
// Quit ⏼
let quit_keys = keys_for_action(keymap, &Action::Quit);
let quit_row =
Row::new(build_cells_for_key_groups("⏼ Quit", vec![quit_keys]));
let widths = vec![Constraint::Fill(1), Constraint::Fill(2)];
Ok(Table::new(
vec![
results_row,
preview_row,
select_entry_row,
send_to_channel_row,
switch_channels_row,
quit_row,
],
widths,
))
} }
fn build_help_table_for_channel_selection<'a>(&self) -> Result<Table<'a>> { fn draw_keymaps_block(
let keymap = self.keymap_for_mode()?; &self,
f: &mut Frame,
area: Rect,
) -> color_eyre::Result<()> {
let keymaps_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Blue))
.style(Style::default())
.padding(Padding::horizontal(1));
// Results navigation let keymaps_table = self.build_keymap_table()?.block(keymaps_block);
let prev = keys_for_action(keymap, &Action::SelectPrevEntry);
let next = keys_for_action(keymap, &Action::SelectNextEntry);
let results_row = Row::new(build_cells_for_key_groups(
"↕ Results",
vec![prev, next],
));
// Select entry f.render_widget(keymaps_table, area);
let select_entry_keys = keys_for_action(keymap, &Action::SelectEntry); Ok(())
let select_entry_row = Row::new(build_cells_for_key_groups(
"Select entry",
vec![select_entry_keys],
));
// Switch channels
let switch_channels_keys =
keys_for_action(keymap, &Action::ToggleChannelSelection);
let switch_channels_row = Row::new(build_cells_for_key_groups(
"Switch channels",
vec![switch_channels_keys],
));
// Quit
let quit_keys = keys_for_action(keymap, &Action::Quit);
let quit_row =
Row::new(build_cells_for_key_groups("Quit", vec![quit_keys]));
Ok(Table::new(
vec![results_row, select_entry_row, switch_channels_row, quit_row],
vec![Constraint::Fill(1), Constraint::Fill(2)],
))
}
/// Get the keymap for the current mode.
///
/// # Returns
/// A reference to the keymap for the current mode.
fn keymap_for_mode(&self) -> Result<&HashMap<Key, Action>> {
let keymap = self
.config
.keybindings
.get(&self.mode)
.ok_or_eyre("No keybindings found for the current Mode")?;
Ok(keymap)
} }
} }
/// Build the corresponding spans for a group of keys.
///
/// # Arguments
/// - `group_name`: The name of the group.
/// - `key_groups`: A vector of vectors of strings representing the keys for each group.
/// Each vector of strings represents a group of alternate keys for a given `Action`.
///
/// # Returns
/// A vector of `Span`s representing the key groups.
///
/// # Example
/// ```rust
/// use ratatui::text::Span;
/// use television::ui::help::build_spans_for_key_groups;
///
/// let key_groups = vec![
/// // alternate keys for the `SelectNextEntry` action
/// vec!["j".to_string(), "n".to_string()],
/// // alternate keys for the `SelectPrevEntry` action
/// vec!["k".to_string(), "p".to_string()],
/// ];
/// let spans = build_spans_for_key_groups("↕ Results", key_groups);
///
/// assert_eq!(spans.len(), 5);
/// ```
fn build_cells_for_key_groups(
group_name: &str,
key_groups: Vec<Vec<String>>,
) -> Vec<Cell> {
if key_groups.is_empty() || key_groups.iter().all(std::vec::Vec::is_empty)
{
return vec![group_name.into(), "No keybindings".into()];
}
let non_empty_groups = key_groups.iter().filter(|keys| !keys.is_empty());
let mut cells = vec![Cell::from(Span::styled(
group_name.to_owned() + ": ",
Style::default().fg(ACTION_COLOR),
))];
let mut spans = Vec::new();
//spans.push(Span::styled("[", Style::default().fg(KEY_COLOR)));
let key_group_spans: Vec<Span> = non_empty_groups
.map(|keys| {
let key_group = keys.join(", ");
Span::styled(key_group, Style::default().fg(KEY_COLOR))
})
.collect();
key_group_spans.iter().enumerate().for_each(|(i, span)| {
spans.push(span.clone());
if i < key_group_spans.len() - 1 {
spans.push(Span::styled(" / ", Style::default().fg(KEY_COLOR)));
}
});
//spans.push(Span::styled("]", Style::default().fg(KEY_COLOR)));
cells.push(Cell::from(Line::from(spans)));
cells
}
/// Get the keys for a given action.
///
/// # Arguments
/// - `keymap`: A hashmap of keybindings.
/// - `action`: The action to get the keys for.
///
/// # Returns
/// A vector of strings representing the keys for the given action.
///
/// # Example
/// ```rust
/// use std::collections::HashMap;
/// use television::action::Action;
/// use television::ui::help::keys_for_action;
///
/// let mut keymap = HashMap::new();
/// keymap.insert('j', Action::SelectNextEntry);
/// keymap.insert('k', Action::SelectPrevEntry);
///
/// let keys = keys_for_action(&keymap, Action::SelectNextEntry);
///
/// assert_eq!(keys, vec!["j"]);
/// ```
fn keys_for_action(
keymap: &HashMap<Key, Action>,
action: &Action,
) -> Vec<String> {
keymap
.iter()
.filter(|(_key, act)| *act == action)
.map(|(key, _act)| format!("{key}"))
.collect()
}

View File

@ -1,3 +1,17 @@
use crate::channels::OnAir;
use crate::television::Television;
use crate::ui::get_border_style;
use crate::ui::layout::Layout;
use color_eyre::eyre::Result;
use ratatui::layout::{
Alignment, Constraint, Direction, Layout as RatatuiLayout,
};
use ratatui::prelude::{Span, Style};
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::{Block, BorderType, Borders, Paragraph};
use ratatui::Frame;
pub mod actions; pub mod actions;
pub mod backend; pub mod backend;
@ -332,12 +346,12 @@ impl Input {
self.value.as_str() self.value.as_str()
} }
/// Get the currect cursor placement. /// Get the correct cursor placement.
pub fn cursor(&self) -> usize { pub fn cursor(&self) -> usize {
self.cursor self.cursor
} }
/// Get the current cursor position with account for multispace characters. /// Get the current cursor position with account for multi space characters.
pub fn visual_cursor(&self) -> usize { pub fn visual_cursor(&self) -> usize {
if self.cursor == 0 { if self.cursor == 0 {
return 0; return 0;
@ -355,7 +369,7 @@ impl Input {
}) })
} }
/// Get the scroll position with account for multispace characters. /// Get the scroll position with account for multi space characters.
pub fn visual_scroll(&self, width: usize) -> usize { pub fn visual_scroll(&self, width: usize) -> usize {
let scroll = self.visual_cursor().max(width) - width; let scroll = self.visual_cursor().max(width) - width;
let mut uscroll = 0; let mut uscroll = 0;
@ -398,9 +412,113 @@ impl std::fmt::Display for Input {
} }
} }
impl Television {
pub(crate) fn draw_input_box(
&mut self,
f: &mut Frame,
layout: &Layout,
) -> Result<()> {
let input_block = Block::default()
.title_top(Line::from(" Pattern ").alignment(Alignment::Center))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(get_border_style(false))
.style(Style::default());
let input_block_inner = input_block.inner(layout.input);
f.render_widget(input_block, layout.input);
// split input block into 4 parts: prompt symbol, input, result count, spinner
let total_count = self.channel.total_count();
let inner_input_chunks = RatatuiLayout::default()
.direction(Direction::Horizontal)
.constraints([
// prompt symbol
Constraint::Length(2),
// input field
Constraint::Fill(1),
// result count
Constraint::Length(
3 * ((total_count as f32).log10().ceil() as u16 + 1) + 3,
),
// spinner
Constraint::Length(1),
])
.split(input_block_inner);
let arrow_block = Block::default();
let arrow = Paragraph::new(Span::styled(
"> ",
Style::default()
.fg(crate::television::DEFAULT_INPUT_FG)
.bold(),
))
.block(arrow_block);
f.render_widget(arrow, inner_input_chunks[0]);
let interactive_input_block = Block::default();
// keep 2 for borders and 1 for cursor
let width = inner_input_chunks[1].width.max(3) - 3;
let scroll = self.results_picker.input.visual_scroll(width as usize);
let input = Paragraph::new(self.results_picker.input.value())
.scroll((0, u16::try_from(scroll)?))
.block(interactive_input_block)
.style(
Style::default()
.fg(crate::television::DEFAULT_INPUT_FG)
.bold()
.italic(),
)
.alignment(Alignment::Left);
f.render_widget(input, inner_input_chunks[1]);
if self.channel.running() {
f.render_stateful_widget(
self.spinner,
inner_input_chunks[3],
&mut self.spinner_state,
);
}
let result_count = self.channel.result_count();
let result_count_block = Block::default();
let result_count_paragraph = Paragraph::new(Span::styled(
format!(
" {} / {} ",
if result_count == 0 {
0
} else {
self.results_picker.selected().unwrap_or(0) + 1
},
result_count,
),
Style::default()
.fg(crate::television::DEFAULT_RESULTS_COUNT_FG)
.italic(),
))
.block(result_count_block)
.alignment(Alignment::Right);
f.render_widget(result_count_paragraph, inner_input_chunks[2]);
// 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.results_picker.input.visual_cursor().max(scroll)
- scroll,
)?,
// Move one line down, from the border to the input line
inner_input_chunks[1].y,
));
Ok(())
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
const TEXT: &str = "first second, third."; const TEXT: &str = "first second, third.";
use super::*; use super::*;

View File

@ -0,0 +1,241 @@
use color_eyre::eyre::{OptionExt, Result};
use ratatui::{
layout::Constraint,
style::{Color, Style},
text::{Line, Span},
widgets::{Cell, Row, Table},
};
use std::collections::HashMap;
use crate::{
action::Action,
event::Key,
television::{Mode, Television},
};
const ACTION_COLOR: Color = Color::DarkGray;
const KEY_COLOR: Color = Color::LightYellow;
impl Television {
pub fn build_keymap_table<'a>(&self) -> Result<Table<'a>> {
match self.mode {
Mode::Channel => self.build_keymap_table_for_channel(),
Mode::RemoteControl => {
self.build_keymap_table_for_channel_selection()
}
Mode::SendToChannel => self.build_keymap_table_for_channel(),
}
}
fn build_keymap_table_for_channel<'a>(&self) -> Result<Table<'a>> {
let keymap = self.keymap_for_mode()?;
// Results navigation
let prev = keys_for_action(keymap, &Action::SelectPrevEntry);
let next = keys_for_action(keymap, &Action::SelectNextEntry);
let results_row = Row::new(build_cells_for_key_groups(
"↕ Results navigation",
vec![prev, next],
));
// Preview navigation
let up_keys =
keys_for_action(keymap, &Action::ScrollPreviewHalfPageUp);
let down_keys =
keys_for_action(keymap, &Action::ScrollPreviewHalfPageDown);
let preview_row = Row::new(build_cells_for_key_groups(
"↕ Preview navigation",
vec![up_keys, down_keys],
));
// Select entry
let select_entry_keys = keys_for_action(keymap, &Action::SelectEntry);
let select_entry_row = Row::new(build_cells_for_key_groups(
"✓ Select entry",
vec![select_entry_keys],
));
// Send to channel
let send_to_channel_keys =
keys_for_action(keymap, &Action::SendToChannel);
let send_to_channel_row = Row::new(build_cells_for_key_groups(
"⇉ Send results to",
vec![send_to_channel_keys],
));
// Switch channels
let switch_channels_keys =
keys_for_action(keymap, &Action::ToggleRemoteControl);
let switch_channels_row = Row::new(build_cells_for_key_groups(
"⨀ Remote control",
vec![switch_channels_keys],
));
// MISC line (quit, help, etc.)
// Quit ⏼
let quit_keys = keys_for_action(keymap, &Action::Quit);
let quit_row =
Row::new(build_cells_for_key_groups("⏼ Quit", vec![quit_keys]));
let widths = vec![Constraint::Fill(1), Constraint::Fill(2)];
Ok(Table::new(
vec![
results_row,
preview_row,
select_entry_row,
send_to_channel_row,
switch_channels_row,
quit_row,
],
widths,
))
}
fn build_keymap_table_for_channel_selection<'a>(
&self,
) -> Result<Table<'a>> {
let keymap = self.keymap_for_mode()?;
// Results navigation
let prev = keys_for_action(keymap, &Action::SelectPrevEntry);
let next = keys_for_action(keymap, &Action::SelectNextEntry);
let results_row = Row::new(build_cells_for_key_groups(
"↕ Results",
vec![prev, next],
));
// Select entry
let select_entry_keys = keys_for_action(keymap, &Action::SelectEntry);
let select_entry_row = Row::new(build_cells_for_key_groups(
"Select entry",
vec![select_entry_keys],
));
// Switch channels
let switch_channels_keys =
keys_for_action(keymap, &Action::ToggleRemoteControl);
let switch_channels_row = Row::new(build_cells_for_key_groups(
"Switch channels",
vec![switch_channels_keys],
));
// Quit
let quit_keys = keys_for_action(keymap, &Action::Quit);
let quit_row =
Row::new(build_cells_for_key_groups("Quit", vec![quit_keys]));
Ok(Table::new(
vec![results_row, select_entry_row, switch_channels_row, quit_row],
vec![Constraint::Fill(1), Constraint::Fill(2)],
))
}
/// Get the keymap for the current mode.
///
/// # Returns
/// A reference to the keymap for the current mode.
fn keymap_for_mode(&self) -> Result<&HashMap<Key, Action>> {
let keymap = self
.config
.keybindings
.get(&self.mode)
.ok_or_eyre("No keybindings found for the current Mode")?;
Ok(keymap)
}
}
/// Build the corresponding spans for a group of keys.
///
/// # Arguments
/// - `group_name`: The name of the group.
/// - `key_groups`: A vector of vectors of strings representing the keys for each group.
/// Each vector of strings represents a group of alternate keys for a given `Action`.
///
/// # Returns
/// A vector of `Span`s representing the key groups.
///
/// # Example
/// ```rust
/// use ratatui::text::Span;
/// use television::ui::help::build_spans_for_key_groups;
///
/// let key_groups = vec![
/// // alternate keys for the `SelectNextEntry` action
/// vec!["j".to_string(), "n".to_string()],
/// // alternate keys for the `SelectPrevEntry` action
/// vec!["k".to_string(), "p".to_string()],
/// ];
/// let spans = build_spans_for_key_groups("↕ Results", key_groups);
///
/// assert_eq!(spans.len(), 5);
/// ```
fn build_cells_for_key_groups(
group_name: &str,
key_groups: Vec<Vec<String>>,
) -> Vec<Cell> {
if key_groups.is_empty() || key_groups.iter().all(Vec::is_empty)
{
return vec![group_name.into(), "No keybindings".into()];
}
let non_empty_groups = key_groups.iter().filter(|keys| !keys.is_empty());
let mut cells = vec![Cell::from(Span::styled(
group_name.to_owned() + ": ",
Style::default().fg(ACTION_COLOR),
))];
let mut spans = Vec::new();
//spans.push(Span::styled("[", Style::default().fg(KEY_COLOR)));
let key_group_spans: Vec<Span> = non_empty_groups
.map(|keys| {
let key_group = keys.join(", ");
Span::styled(key_group, Style::default().fg(KEY_COLOR))
})
.collect();
key_group_spans.iter().enumerate().for_each(|(i, span)| {
spans.push(span.clone());
if i < key_group_spans.len() - 1 {
spans.push(Span::styled(" / ", Style::default().fg(KEY_COLOR)));
}
});
//spans.push(Span::styled("]", Style::default().fg(KEY_COLOR)));
cells.push(Cell::from(Line::from(spans)));
cells
}
/// Get the keys for a given action.
///
/// # Arguments
/// - `keymap`: A hashmap of keybindings.
/// - `action`: The action to get the keys for.
///
/// # Returns
/// A vector of strings representing the keys for the given action.
///
/// # Example
/// ```rust
/// use std::collections::HashMap;
/// use television::action::Action;
/// use television::ui::help::keys_for_action;
///
/// let mut keymap = HashMap::new();
/// keymap.insert('j', Action::SelectNextEntry);
/// keymap.insert('k', Action::SelectPrevEntry);
///
/// let keys = keys_for_action(&keymap, Action::SelectNextEntry);
///
/// assert_eq!(keys, vec!["j"]);
/// ```
fn keys_for_action(
keymap: &HashMap<Key, Action>,
action: &Action,
) -> Vec<String> {
keymap
.iter()
.filter(|(_key, act)| *act == action)
.map(|(key, _act)| format!("{key}"))
.collect()
}

View File

@ -24,8 +24,9 @@ pub struct Layout {
pub help_bar_right: Rect, pub help_bar_right: Rect,
pub results: Rect, pub results: Rect,
pub input: Rect, pub input: Rect,
pub preview_title: Option<Rect>, pub preview_title: Rect,
pub preview_window: Option<Rect>, pub preview_window: Rect,
pub remote_control: Option<Rect>,
} }
impl Layout { impl Layout {
@ -35,8 +36,9 @@ impl Layout {
help_bar_right: Rect, help_bar_right: Rect,
results: Rect, results: Rect,
input: Rect, input: Rect,
preview_title: Option<Rect>, preview_title: Rect,
preview_window: Option<Rect>, preview_window: Rect,
remote_control: Option<Rect>,
) -> Self { ) -> Self {
Self { Self {
help_bar_left, help_bar_left,
@ -46,13 +48,14 @@ impl Layout {
input, input,
preview_title, preview_title,
preview_window, preview_window,
remote_control,
} }
} }
pub fn build( pub fn build(
dimensions: &Dimensions, dimensions: &Dimensions,
area: Rect, area: Rect,
with_preview: bool, with_remote: bool,
) -> Self { ) -> Self {
let main_block = centered_rect(dimensions.x, dimensions.y, area); let main_block = centered_rect(dimensions.x, dimensions.y, area);
// split the main block into two vertical chunks (help bar + rest) // split the main block into two vertical chunks (help bar + rest)
@ -65,60 +68,56 @@ impl Layout {
let help_bar_chunks = layout::Layout::default() let help_bar_chunks = layout::Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints([ .constraints([
// metadata
Constraint::Fill(1), Constraint::Fill(1),
// keymaps
Constraint::Fill(1), Constraint::Fill(1),
// logo
Constraint::Length(24), Constraint::Length(24),
]) ])
.split(hz_chunks[0]); .split(hz_chunks[0]);
if with_preview { // split the main block into two vertical chunks
// split the main block into two vertical chunks let constraints = if with_remote {
let vt_chunks = layout::Layout::default() vec![
.direction(Direction::Horizontal) Constraint::Fill(1),
.constraints([ Constraint::Fill(1),
Constraint::Percentage(50), Constraint::Length(24),
Constraint::Percentage(50), ]
])
.split(hz_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]);
// 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]);
Self::new(
help_bar_chunks[0],
help_bar_chunks[1],
help_bar_chunks[2],
left_chunks[0],
left_chunks[1],
Some(right_chunks[0]),
Some(right_chunks[1]),
)
} else { } else {
// split the main block into two vertical chunks vec![Constraint::Percentage(50), Constraint::Percentage(50)]
let chunks = layout::Layout::default() };
.direction(Direction::Vertical) let vt_chunks = layout::Layout::default()
.constraints([Constraint::Min(10), Constraint::Length(3)]) .direction(Direction::Horizontal)
.split(hz_chunks[1]); .constraints(constraints)
.split(hz_chunks[1]);
Self::new( // left block: results + input field
help_bar_chunks[0], let left_chunks = layout::Layout::default()
help_bar_chunks[1], .direction(Direction::Vertical)
help_bar_chunks[2], .constraints([Constraint::Min(10), Constraint::Length(3)])
chunks[0], .split(vt_chunks[0]);
chunks[1],
None, // right block: preview title + preview
None, let right_chunks = layout::Layout::default()
) .direction(Direction::Vertical)
} .constraints([Constraint::Length(3), Constraint::Min(10)])
.split(vt_chunks[1]);
Self::new(
help_bar_chunks[0],
help_bar_chunks[1],
help_bar_chunks[2],
left_chunks[0],
left_chunks[1],
right_chunks[0],
right_chunks[1],
if with_remote {
Some(vt_chunks[2])
} else {
None
},
)
} }
} }

View File

@ -24,3 +24,33 @@ pub fn build_logo_paragraph<'a>() -> Paragraph<'a> {
let logo_paragraph = Paragraph::new(lines); let logo_paragraph = Paragraph::new(lines);
logo_paragraph logo_paragraph
} }
const REMOTE_LOGO: &str = r"
_____________
/ \
| (*) (#) |
| |
| (1) (2) (3) |
| (4) (5) (6) |
| (7) (8) (9) |
| |
| _ |
| | | |
| (_¯(0)¯_) |
| | | |
| ¯ |
| |
| |
| === === === |
| |
| T.V |
`-------------´";
pub fn build_remote_logo_paragraph<'a>() -> Paragraph<'a> {
let lines = REMOTE_LOGO
.lines()
.map(std::convert::Into::into)
.collect::<Vec<_>>();
let logo_paragraph = Paragraph::new(lines);
logo_paragraph
}

View File

@ -1,11 +1,17 @@
use crate::entry::Entry;
use crate::previewers::{ use crate::previewers::{
Preview, PreviewContent, FILE_TOO_LARGE_MSG, PREVIEW_NOT_SUPPORTED_MSG, Preview, PreviewContent, FILE_TOO_LARGE_MSG, PREVIEW_NOT_SUPPORTED_MSG,
}; };
use crate::television::Television; use crate::television::Television;
use crate::utils::strings::EMPTY_STRING; use crate::ui::get_border_style;
use crate::ui::layout::Layout;
use crate::utils::strings::{shrink_with_ellipsis, EMPTY_STRING};
use color_eyre::eyre::Result;
use ratatui::layout::{Alignment, Rect}; use ratatui::layout::{Alignment, Rect};
use ratatui::prelude::{Color, Line, Modifier, Span, Style, Stylize, Text}; use ratatui::prelude::{Color, Line, Modifier, Span, Style, Stylize, Text};
use ratatui::widgets::{Block, Paragraph, Wrap}; use ratatui::widgets::{Block, BorderType, Borders, Padding, Paragraph, Wrap};
use ratatui::Frame;
use std::str::FromStr;
use std::sync::Arc; use std::sync::Arc;
use syntect::highlighting::Color as SyntectColor; use syntect::highlighting::Color as SyntectColor;
@ -17,6 +23,91 @@ const DEFAULT_PREVIEW_GUTTER_FG: Color = Color::Rgb(70, 70, 70);
const DEFAULT_PREVIEW_GUTTER_SELECTED_FG: Color = Color::Rgb(255, 150, 150); const DEFAULT_PREVIEW_GUTTER_SELECTED_FG: Color = Color::Rgb(255, 150, 150);
impl Television { impl Television {
pub(crate) fn draw_preview_title_block(
&self,
f: &mut Frame,
layout: &Layout,
selected_entry: &Entry,
preview: &Arc<Preview>,
) -> Result<()> {
let mut preview_title_spans = Vec::new();
if let Some(icon) = &selected_entry.icon {
preview_title_spans.push(Span::styled(
{
// FIXME: this should be done using padding on the parent block
let mut icon_str = String::from(" ");
icon_str.push(icon.icon);
icon_str.push(' ');
icon_str
},
Style::default().fg(Color::from_str(icon.color)?),
));
}
preview_title_spans.push(Span::styled(
shrink_with_ellipsis(
&preview.title,
layout.preview_window.width.saturating_sub(4) as usize,
),
Style::default().fg(DEFAULT_PREVIEW_TITLE_FG).bold(),
));
let preview_title = Paragraph::new(Line::from(preview_title_spans))
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(get_border_style(false)),
)
.alignment(Alignment::Left);
f.render_widget(preview_title, layout.preview_title);
Ok(())
}
pub(crate) fn draw_preview_content_block(
&mut self,
f: &mut Frame,
layout: &Layout,
selected_entry: &Entry,
preview: &Arc<Preview>,
) -> Result<()> {
let preview_outer_block = Block::default()
.title_top(Line::from(" Preview ").alignment(Alignment::Center))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(get_border_style(false))
.style(Style::default())
.padding(Padding::right(1));
let preview_inner_block =
Block::default().style(Style::default()).padding(Padding {
top: 0,
right: 1,
bottom: 0,
left: 1,
});
let inner = preview_outer_block.inner(layout.preview_window);
f.render_widget(preview_outer_block, layout.preview_window);
//if let PreviewContent::Image(img) = &preview.content {
// let image_component = StatefulImage::new(None);
// frame.render_stateful_widget(
// image_component,
// inner,
// &mut img.clone(),
// );
//} else {
let preview_block = self.build_preview_paragraph(
preview_inner_block,
inner,
&preview,
selected_entry
.line_number
.map(|l| u16::try_from(l).unwrap_or(0)),
);
f.render_widget(preview_block, inner);
//}
Ok(())
}
const FILL_CHAR_SLANTED: char = ''; const FILL_CHAR_SLANTED: char = '';
const FILL_CHAR_EMPTY: char = ' '; const FILL_CHAR_EMPTY: char = ' ';
@ -45,7 +136,7 @@ impl Television {
}, },
)), )),
Span::styled("", Span::styled("",
Style::default().fg(DEFAULT_PREVIEW_GUTTER_FG).dim()), Style::default().fg(DEFAULT_PREVIEW_GUTTER_FG).dim()),
Span::styled( Span::styled(
line.to_string(), line.to_string(),
Style::default().fg(DEFAULT_PREVIEW_CONTENT_FG).bg( Style::default().fg(DEFAULT_PREVIEW_CONTENT_FG).bg(
@ -83,9 +174,9 @@ impl Television {
self.preview_scroll.unwrap_or(0), self.preview_scroll.unwrap_or(0),
self.preview_pane_height, self.preview_pane_height,
) )
.block(preview_block) .block(preview_block)
.alignment(Alignment::Left) .alignment(Alignment::Left)
.scroll((self.preview_scroll.unwrap_or(0), 0)) .scroll((self.preview_scroll.unwrap_or(0), 0))
} }
// meta // meta
PreviewContent::Loading => self PreviewContent::Loading => self
@ -277,6 +368,6 @@ pub fn convert_syn_region_to_span<'a>(
fn convert_syn_color_to_ratatui_color( fn convert_syn_color_to_ratatui_color(
color: syntect::highlighting::Color, color: syntect::highlighting::Color,
) -> ratatui::style::Color { ) -> Color {
ratatui::style::Color::Rgb(color.r, color.g, color.b) Color::Rgb(color.r, color.g, color.b)
} }

View File

@ -0,0 +1,147 @@
use crate::channels::OnAir;
use crate::television::Television;
use crate::ui::get_border_style;
use crate::ui::logo::build_remote_logo_paragraph;
use crate::ui::results::build_results_list;
use color_eyre::eyre::Result;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::prelude::Style;
use ratatui::style::{Color, Stylize};
use ratatui::text::{Line, Span};
use ratatui::widgets::{
Block, BorderType, Borders, ListDirection, Padding, Paragraph,
};
use ratatui::Frame;
impl Television {
pub fn draw_remote_control(
&mut self,
f: &mut Frame,
area: &Rect,
) -> Result<()> {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Fill(1),
Constraint::Length(3),
Constraint::Length(20),
]
.as_ref(),
)
.split(*area);
self.draw_rc_channels(f, &layout[0])?;
self.draw_rc_input(f, &layout[1])?;
draw_rc_logo(f, layout[2]);
Ok(())
}
fn draw_rc_channels(&mut self, f: &mut Frame, area: &Rect) -> Result<()> {
let rc_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(get_border_style(false))
.style(Style::default())
.padding(Padding::right(1));
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.view_offset)?,
);
let channel_list =
build_results_list(rc_block, &entries, ListDirection::TopToBottom);
f.render_stateful_widget(
channel_list,
*area,
&mut self.rc_picker.state,
);
Ok(())
}
fn draw_rc_input(&mut self, f: &mut Frame, area: &Rect) -> Result<()> {
let input_block = Block::default()
.title_top(
Line::from("Remote Control").alignment(Alignment::Center),
)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(get_border_style(false))
.style(Style::default());
let input_block_inner = input_block.inner(*area);
f.render_widget(input_block, *area);
// split input block into 2 parts: prompt symbol, input
let inner_input_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
// prompt symbol
Constraint::Length(2),
// input field
Constraint::Fill(1),
])
.split(input_block_inner);
let prompt_symbol_block = Block::default();
let arrow = Paragraph::new(Span::styled(
"> ",
Style::default()
.fg(crate::television::DEFAULT_INPUT_FG)
.bold(),
))
.block(prompt_symbol_block);
f.render_widget(arrow, inner_input_chunks[0]);
let interactive_input_block = Block::default();
// keep 2 for borders and 1 for cursor
let width = inner_input_chunks[1].width.max(3) - 3;
let scroll = self.rc_picker.input.visual_scroll(width as usize);
let input = Paragraph::new(self.rc_picker.input.value())
.scroll((0, u16::try_from(scroll)?))
.block(interactive_input_block)
.style(
Style::default()
.fg(crate::television::DEFAULT_INPUT_FG)
.bold()
.italic(),
)
.alignment(Alignment::Left);
f.render_widget(input, inner_input_chunks[1]);
// 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.rc_picker.input.visual_cursor().max(scroll) - scroll,
)?,
// Move one line down, from the border to the input line
inner_input_chunks[1].y,
));
Ok(())
}
}
fn draw_rc_logo(f: &mut Frame, area: Rect) {
let logo_block = Block::default()
// .borders(Borders::ALL)
// .border_type(BorderType::Rounded)
// .border_style(Style::default().fg(Color::Blue))
.style(Style::default().fg(Color::Yellow));
let logo_paragraph = build_remote_logo_paragraph()
.alignment(Alignment::Center)
.block(logo_block);
f.render_widget(logo_paragraph, area);
}

View File

@ -1,7 +1,16 @@
use crate::channels::OnAir;
use crate::entry::Entry; use crate::entry::Entry;
use crate::television::Television;
use crate::ui::get_border_style;
use crate::ui::layout::Layout;
use crate::utils::strings::{next_char_boundary, slice_at_char_boundaries}; use crate::utils::strings::{next_char_boundary, slice_at_char_boundaries};
use color_eyre::eyre::Result;
use ratatui::layout::Alignment;
use ratatui::prelude::{Color, Line, Span, Style, Stylize}; use ratatui::prelude::{Color, Line, Span, Style, Stylize};
use ratatui::widgets::{Block, List, ListDirection}; use ratatui::widgets::{
Block, BorderType, Borders, List, ListDirection, Padding,
};
use ratatui::Frame;
use std::str::FromStr; use std::str::FromStr;
// Styles // Styles
@ -13,6 +22,7 @@ const DEFAULT_RESULT_SELECTED_BG: Color = Color::Rgb(50, 50, 50);
pub fn build_results_list<'a, 'b>( pub fn build_results_list<'a, 'b>(
results_block: Block<'b>, results_block: Block<'b>,
entries: &'a [Entry], entries: &'a [Entry],
list_direction: ListDirection,
) -> List<'a> ) -> List<'a>
where where
'b: 'a, 'b: 'a,
@ -107,8 +117,48 @@ where
} }
Line::from(spans) Line::from(spans)
})) }))
.direction(ListDirection::BottomToTop) .direction(list_direction)
.highlight_style(Style::default().bg(DEFAULT_RESULT_SELECTED_BG)) .highlight_style(Style::default().bg(DEFAULT_RESULT_SELECTED_BG))
.highlight_symbol("> ") .highlight_symbol("> ")
.block(results_block) .block(results_block)
} }
impl Television {
pub(crate) fn draw_results_list(
&mut self,
f: &mut Frame,
layout: &Layout,
) -> Result<()> {
let results_block = Block::default()
.title_top(Line::from(" Results ").alignment(Alignment::Center))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(get_border_style(false))
.style(Style::default())
.padding(Padding::right(1));
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(
layout.results.height.saturating_sub(2).into(),
u32::try_from(self.results_picker.view_offset)?,
);
let results_list = build_results_list(
results_block,
&entries,
ListDirection::BottomToTop,
);
f.render_stateful_widget(
results_list,
layout.results,
&mut self.results_picker.relative_state,
);
Ok(())
}
}

View File

@ -1,6 +1,27 @@
use proc_macro::TokenStream; use proc_macro::TokenStream;
use quote::quote; use quote::quote;
/// This macro generates a `CliChannel` enum and the necessary glue code
/// to convert into a `TelevisionChannel` member:
///
/// ```rust
/// use crate::channels::{TelevisionChannel, OnAir};
/// use television_derive::CliChannel;
/// use crate::channels::{files, text};
///
/// #[derive(CliChannel)]
/// enum TelevisionChannel {
/// Files(files::Channel),
/// Text(text::Channel),
/// // ...
/// }
///
/// let television_channel: TelevisionChannel = CliTvChannel::Files.to_channel();
///
/// assert!(matches!(television_channel, TelevisionChannel::Files(_)));
/// ```
///
/// The `CliChannel` enum is used to select channels from the command line.
#[proc_macro_derive(CliChannel)] #[proc_macro_derive(CliChannel)]
pub fn cli_channel_derive(input: TokenStream) -> TokenStream { pub fn cli_channel_derive(input: TokenStream) -> TokenStream {
// Construct a representation of Rust code as a syntax tree // Construct a representation of Rust code as a syntax tree
@ -11,7 +32,8 @@ pub fn cli_channel_derive(input: TokenStream) -> TokenStream {
impl_cli_channel(&ast) impl_cli_channel(&ast)
} }
const VARIANT_BLACKLIST: [&str; 2] = ["Stdin", "TvGuide"]; /// List of variant names that should be ignored when generating the CliTvChannel enum.
const VARIANT_BLACKLIST: [&str; 2] = ["Stdin", "RemoteControl"];
fn impl_cli_channel(ast: &syn::DeriveInput) -> TokenStream { fn impl_cli_channel(ast: &syn::DeriveInput) -> TokenStream {
// check that the struct is an enum // check that the struct is an enum
@ -88,8 +110,34 @@ fn impl_cli_channel(ast: &syn::DeriveInput) -> TokenStream {
gen.into() gen.into()
} }
/// This macro generates the `OnAir` trait implementation for the /// This macro generates the `OnAir` trait implementation for the given enum.
/// given enum. ///
/// The `OnAir` trait is used to interact with the different television channels
/// and forwards the method calls to the corresponding channel variants.
///
/// Example:
/// ```rust
/// use television_derive::Broadcast;
/// use crate::channels::{TelevisionChannel, OnAir};
/// use crate::channels::{files, text};
///
/// #[derive(Broadcast)]
/// enum TelevisionChannel {
/// Files(files::Channel),
/// Text(text::Channel),
/// }
///
/// let mut channel = TelevisionChannel::Files(files::Channel::default());
///
/// // Use the `OnAir` trait methods directly on TelevisionChannel
/// channel.find("pattern");
/// let results = channel.results(10, 0);
/// let result = channel.get_result(0);
/// let result_count = channel.result_count();
/// let total_count = channel.total_count();
/// let running = channel.running();
/// channel.shutdown();
/// ```
#[proc_macro_derive(Broadcast)] #[proc_macro_derive(Broadcast)]
pub fn tv_channel_derive(input: TokenStream) -> TokenStream { pub fn tv_channel_derive(input: TokenStream) -> TokenStream {
// Construct a representation of Rust code as a syntax tree // Construct a representation of Rust code as a syntax tree
@ -196,6 +244,11 @@ fn impl_tv_channel(ast: &syn::DeriveInput) -> TokenStream {
trait_impl.into() trait_impl.into()
} }
/// This macro generates a `UnitChannel` enum and the necessary glue code
/// to convert from and to a `TelevisionChannel` member.
///
/// The `UnitChannel` enum is used as a unit variant of the `TelevisionChannel`
/// enum.
#[proc_macro_derive(UnitChannel)] #[proc_macro_derive(UnitChannel)]
pub fn unit_channel_derive(input: TokenStream) -> TokenStream { pub fn unit_channel_derive(input: TokenStream) -> TokenStream {
// Construct a representation of Rust code as a syntax tree // Construct a representation of Rust code as a syntax tree