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"
enter = "SelectEntry"
ctrl-enter = "SendToChannel"
ctrl-s = "ToggleChannelSelection"
ctrl-s = "ToggleRemoteControl"
[keybindings.Guide]
[keybindings.RemoteControl]
esc = "Quit"
down = "SelectNextEntry"
up = "SelectPrevEntry"
ctrl-n = "SelectNextEntry"
ctrl-p = "SelectPrevEntry"
enter = "SelectEntry"
ctrl-s = "ToggleChannelSelection"
ctrl-s = "ToggleRemoteControl"

14
Cargo.lock generated
View File

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

View File

@ -50,7 +50,7 @@ libc = "0.2.158"
nucleo = "0.5.0"
nucleo-matcher = "0.3.1"
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"
serde = { version = "1.0.208", features = ["derive"] }
serde_json = "1.0.125"

View File

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

View File

@ -6,9 +6,9 @@ mod alias;
mod env;
mod files;
mod git_repos;
pub mod remote_control;
pub mod stdin;
mod text;
pub mod tv_guide;
/// The interface that all television channels must implement.
///
@ -94,13 +94,34 @@ pub trait OnAir: Send {
#[allow(dead_code, clippy::module_name_repetitions)]
#[derive(UnitChannel, CliChannel, Broadcast)]
pub enum TelevisionChannel {
/// The environment variables channel.
///
/// This channel allows to search through environment variables.
Env(env::Channel),
/// The files channel.
///
/// This channel allows to search through files.
Files(files::Channel),
/// The git repositories channel.
///
/// This channel allows to search through git repositories.
GitRepos(git_repos::Channel),
/// The text channel.
///
/// This channel allows to search through the contents of text files.
Text(text::Channel),
/// The standard input channel.
///
/// This channel allows to search through whatever is passed through stdin.
Stdin(stdin::Channel),
/// The alias channel.
///
/// This channel allows to search through aliases.
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

View File

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

View File

@ -1,4 +1,3 @@
use color_eyre::owo_colors::OwoColorize;
use devicons::FileIcon;
use ignore::{overrides::OverrideBuilder, DirEntry};
use nucleo::{
@ -201,7 +200,7 @@ fn get_ignored_paths() -> Vec<PathBuf> {
}
#[allow(clippy::unused_async)]
async fn crawl_for_repos(
starting_point: std::path::PathBuf,
starting_point: PathBuf,
injector: nucleo::Injector<DirEntry>,
entry_cache: Arc<Mutex<HashSet<String>>>,
cache_valid: Arc<Mutex<bool>>,

View File

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

View File

@ -118,7 +118,7 @@ impl OnAir for Channel {
.collect()
}
fn get_result(&self, index: u32) -> Option<super::Entry> {
fn get_result(&self, index: u32) -> Option<Entry> {
let snapshot = self.matcher.snapshot();
snapshot.get_matched_item(index).map(|item| {
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> {
let status = self.matcher.tick(Self::MATCHER_TICK_TIMEOUT);
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 {
self.running
}

View File

@ -52,7 +52,6 @@ lazy_static! {
impl Config {
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 data_dir = get_data_dir();
let config_dir = get_config_dir();

View File

@ -154,7 +154,7 @@ impl EventLoop {
let (tx, rx) = mpsc::unbounded_channel();
let tx_c = tx.clone();
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();

View File

@ -19,6 +19,7 @@ mod errors;
mod event;
mod fuzzy;
mod logging;
mod picker;
mod previewers;
mod render;
mod television;
@ -28,8 +29,8 @@ mod utils;
#[tokio::main(flavor = "multi_thread")]
async fn main() -> Result<()> {
crate::errors::init()?;
crate::logging::init()?;
errors::init()?;
logging::init()?;
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();
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();
if m.is_dir() && max_depth > 1 {
root.push(tree(

View File

@ -5,11 +5,10 @@ use std::fs::File;
use std::io::{BufRead, BufReader, Read, Seek};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use syntect::easy::HighlightLines;
use tokio::sync::Mutex;
use syntect::{
highlighting::{Style, Theme, ThemeSet},
highlighting::{Theme, ThemeSet},
parsing::SyntaxSet,
};
use tracing::{debug, warn};

View File

@ -1,62 +1,44 @@
use color_eyre::Result;
use futures::executor::block_on;
use ratatui::{
layout::{
Alignment, Constraint, Direction, Layout as RatatuiLayout, Rect,
},
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::channels::remote_control::RemoteControl;
use crate::channels::OnAir;
use crate::channels::UnitChannel;
use crate::picker::Picker;
use crate::ui::layout::{Dimensions, Layout};
use crate::utils::strings::EMPTY_STRING;
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::{
channels::TelevisionChannel, ui::input::actions::InputActionHandler,
};
use crate::{channels::UnitChannel, ui::input::Input};
use crate::{
entry::{Entry, ENTRY_PLACEHOLDER},
ui::spinner::Spinner,
};
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(
PartialEq, Copy, Clone, Hash, Eq, Debug, Serialize, Deserialize, Display,
)]
pub enum Mode {
Channel,
Guide,
RemoteControl,
SendToChannel,
}
pub struct Television {
action_tx: Option<UnboundedSender<Action>>,
pub config: Config,
channel: TelevisionChannel,
guide: TelevisionChannel,
current_pattern: String,
pub(crate) channel: TelevisionChannel,
pub(crate) remote_control: TelevisionChannel,
pub mode: Mode,
input: Input,
picker_state: ListState,
relative_picker_state: ListState,
picker_view_offset: usize,
current_pattern: String,
pub(crate) results_picker: Picker,
pub(crate) rc_picker: Picker,
results_area_height: u32,
pub previewer: Previewer,
pub preview_scroll: Option<u16>,
@ -69,30 +51,26 @@ pub struct Television {
/// are rendered correctly even when resizing the terminal while still
/// benefiting from a cache mechanism.
pub meta_paragraph_cache: HashMap<(String, u16, u16), Paragraph<'static>>,
spinner: Spinner,
spinner_state: SpinnerState,
pub(crate) spinner: Spinner,
pub(crate) spinner_state: SpinnerState,
}
impl Television {
#[must_use]
pub fn new(mut channel: TelevisionChannel) -> Self {
channel.find(EMPTY_STRING);
let guide = TelevisionChannel::TvGuide(TvGuide::new());
let spinner = Spinner::default();
let spinner_state = SpinnerState::from(&spinner);
Self {
action_tx: None,
config: Config::default(),
channel,
guide,
current_pattern: EMPTY_STRING.to_string(),
remote_control: TelevisionChannel::RemoteControl(
RemoteControl::new(),
),
mode: Mode::Channel,
input: Input::new(EMPTY_STRING.to_string()),
picker_state: ListState::default(),
relative_picker_state: ListState::default(),
picker_view_offset: 0,
current_pattern: EMPTY_STRING.to_string(),
results_picker: Picker::default(),
rc_picker: Picker::default().inverted(),
results_area_height: 0,
previewer: Previewer::new(),
preview_scroll: None,
@ -100,7 +78,7 @@ impl Television {
current_preview_total_lines: 0,
meta_paragraph_cache: HashMap::new(),
spinner,
spinner_state,
spinner_state: SpinnerState::from(&spinner),
}
}
@ -111,9 +89,9 @@ impl Television {
/// FIXME: this needs rework
pub fn change_channel(&mut self, channel: TelevisionChannel) {
self.reset_preview_scroll();
self.reset_results_selection();
self.reset_picker_selection();
self.reset_picker_input();
self.current_pattern = EMPTY_STRING.to_string();
self.input.reset();
self.channel.shutdown();
self.channel = channel;
}
@ -123,92 +101,84 @@ impl Television {
Mode::Channel => {
self.channel.find(pattern);
}
Mode::Guide | Mode::SendToChannel => {
self.guide.find(pattern);
Mode::RemoteControl | Mode::SendToChannel => {
self.remote_control.find(pattern);
}
}
}
#[must_use]
pub fn get_selected_entry(&mut self) -> Option<Entry> {
self.picker_state.selected().and_then(|i| match self.mode {
Mode::Channel => {
match self.mode {
Mode::Channel => self.results_picker.selected().and_then(|i| {
self.channel.get_result(u32::try_from(i).unwrap())
}
Mode::Guide | Mode::SendToChannel => {
self.guide.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())
})
}
}
}
pub fn select_prev_entry(&mut self) {
let result_count = match self.mode {
Mode::Channel => self.channel.result_count(),
Mode::Guide | Mode::SendToChannel => self.guide.total_count(),
let (result_count, picker) = match self.mode {
Mode::Channel => {
(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 {
return;
}
let new_index = (self.picker_state.selected().unwrap_or(0) + 1)
% result_count as usize;
self.picker_state.select(Some(new_index));
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)),
));
}
picker.select_prev(
result_count as usize,
self.results_area_height as usize,
);
}
pub fn select_next_entry(&mut self) {
let result_count = match self.mode {
Mode::Channel => self.channel.result_count(),
Mode::Guide | Mode::SendToChannel => self.guide.total_count(),
let (result_count, picker) = match self.mode {
Mode::Channel => {
(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 {
return;
}
let selected = self.picker_state.selected().unwrap_or(0);
let relative_selected =
self.relative_picker_state.selected().unwrap_or(0);
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));
}
picker.select_next(
result_count as usize,
self.results_area_height as usize,
);
}
fn reset_preview_scroll(&mut self) {
self.preview_scroll = None;
}
fn reset_picker_selection(&mut self) {
match self.mode {
Mode::Channel => self.results_picker.reset_selection(),
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) {
if self.preview_scroll.is_none() {
self.preview_scroll = Some(0);
@ -228,18 +198,12 @@ impl Television {
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
// input
const DEFAULT_INPUT_FG: Color = Color::LightRed;
const DEFAULT_RESULTS_COUNT_FG: Color = Color::LightRed;
pub(crate) const DEFAULT_INPUT_FG: Color = Color::LightRed;
pub(crate) const DEFAULT_RESULTS_COUNT_FG: Color = Color::LightRed;
impl Television {
/// Register an action handler that can send actions for processing if necessary.
@ -286,17 +250,23 @@ impl Television {
| Action::GoToInputStart
| Action::GoToNextChar
| 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 {
Action::AddInputChar(_)
| Action::DeletePrevChar
| Action::DeleteNextChar => {
let new_pattern = self.input.value().to_string();
let new_pattern = input.value().to_string();
if new_pattern != self.current_pattern {
self.current_pattern.clone_from(&new_pattern);
self.find(&new_pattern);
self.reset_preview_scroll();
self.reset_results_selection();
self.reset_picker_selection();
}
}
_ => {}
@ -314,13 +284,15 @@ impl Television {
Action::ScrollPreviewUp => self.scroll_preview_up(1),
Action::ScrollPreviewHalfPageDown => self.scroll_preview_down(20),
Action::ScrollPreviewHalfPageUp => self.scroll_preview_up(20),
Action::ToggleChannelSelection => match self.mode {
Action::ToggleRemoteControl => match self.mode {
Mode::Channel => {
self.reset_screen();
self.mode = Mode::Guide;
self.mode = Mode::RemoteControl;
}
Mode::Guide => {
self.reset_screen();
Mode::RemoteControl => {
// this resets the RC picker
self.reset_picker_input();
self.remote_control.find(EMPTY_STRING);
self.reset_picker_selection();
self.mode = Mode::Channel;
}
Mode::SendToChannel => {}
@ -333,10 +305,14 @@ impl Television {
.as_ref()
.unwrap()
.send(Action::SelectAndExit)?,
Mode::Guide => {
Mode::RemoteControl => {
if let Ok(new_channel) =
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.change_channel(new_channel);
}
@ -356,7 +332,8 @@ impl Television {
Action::SendToChannel => {
self.mode = Mode::SendToChannel;
// 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();
}
_ => {}
@ -366,11 +343,11 @@ impl Television {
fn reset_screen(&mut self) {
self.reset_preview_scroll();
self.reset_results_selection();
self.reset_picker_selection();
self.reset_picker_input();
self.current_pattern = EMPTY_STRING.to_string();
self.input.reset();
self.channel.find(EMPTY_STRING);
self.guide.find(EMPTY_STRING);
self.remote_control.find(EMPTY_STRING);
}
/// Render the television on the screen.
@ -382,278 +359,43 @@ impl Television {
/// # Returns
/// * `Result<()>` - An Ok result or an error.
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(
dimensions,
&Dimensions::default(),
area,
match self.mode {
Mode::Channel => true,
Mode::Guide | Mode::SendToChannel => false,
},
!matches!(self.mode, Mode::Channel),
);
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());
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);
// help bar (metadata, keymaps, logo)
self.draw_help_bar(f, &layout)?;
self.results_area_height = u32::from(layout.results.height);
if let Some(preview_window) = layout.preview_window {
self.preview_pane_height = preview_window.height;
}
self.preview_pane_height = layout.preview_window.height;
// top left block: results
let results_block = Block::default()
.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,
);
self.draw_results_list(f, &layout)?;
// bottom left block: input
let input_block = Block::default()
.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());
self.draw_input_box(f, &layout)?;
let input_block_inner = input_block.inner(layout.input);
f.render_widget(input_block, layout.input);
// split input block into 3 parts: prompt symbol, input, result count
let total_count = match self.mode {
Mode::Channel => self.channel.total_count(),
Mode::Guide | Mode::SendToChannel => self.guide.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(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();
self.draw_preview_title_block(f, &layout, &selected_entry, &preview)?;
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,
// bottom right block: preview content
self.draw_preview_content_block(
f,
&layout,
&selected_entry,
&preview,
selected_entry
.line_number
.map(|l| u16::try_from(l).unwrap_or(0)),
);
f.render_widget(preview_block, inner);
//}
}
)?;
// remote control
if matches!(self.mode, Mode::RemoteControl) {
self.draw_remote_control(f, &layout.remote_control.unwrap())?;
}
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 keymap;
pub mod layout;
pub mod logo;
pub mod metadata;
pub mod preview;
mod remote_control;
pub mod results;
pub mod spinner;
// input
//const DEFAULT_INPUT_FG: Color = Color::Rgb(200, 200, 200);
//const DEFAULT_RESULTS_COUNT_FG: Color = Color::Rgb(150, 150, 150);

View File

@ -1,237 +1,64 @@
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::television::Television;
use crate::ui::layout::Layout;
use crate::ui::logo::build_logo_paragraph;
use ratatui::layout::Rect;
use ratatui::prelude::{Color, Style};
use ratatui::widgets::{Block, BorderType, Borders, Padding};
use ratatui::Frame;
use crate::{
action::Action,
event::Key,
television::{Mode, Television},
};
pub fn draw_logo_block(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))
.padding(Padding::horizontal(1));
const ACTION_COLOR: Color = Color::DarkGray;
const KEY_COLOR: Color = Color::LightYellow;
let logo_paragraph = build_logo_paragraph().block(logo_block);
f.render_widget(logo_paragraph, area);
}
impl Television {
pub fn build_help_table<'a>(&self) -> Result<Table<'a>> {
match self.mode {
Mode::Channel => self.build_help_table_for_channel(),
Mode::Guide => self.build_help_table_for_channel_selection(),
Mode::SendToChannel => self.build_help_table_for_channel(),
}
pub(crate) fn draw_help_bar(
&self,
f: &mut Frame,
layout: &Layout,
) -> 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>> {
let keymap = self.keymap_for_mode()?;
fn draw_metadata_block(&self, f: &mut Frame, area: Rect) {
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 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],
));
let metadata_table = self.build_metadata_table().block(metadata_block);
// 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::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,
))
f.render_widget(metadata_table, area);
}
fn build_help_table_for_channel_selection<'a>(&self) -> Result<Table<'a>> {
let keymap = self.keymap_for_mode()?;
fn draw_keymaps_block(
&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 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],
));
let keymaps_table = self.build_keymap_table()?.block(keymaps_block);
// 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::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)
f.render_widget(keymaps_table, area);
Ok(())
}
}
/// 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 backend;
@ -332,12 +346,12 @@ impl Input {
self.value.as_str()
}
/// Get the currect cursor placement.
/// Get the correct cursor placement.
pub fn cursor(&self) -> usize {
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 {
if self.cursor == 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 {
let scroll = self.visual_cursor().max(width) - width;
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)]
mod tests {
const TEXT: &str = "first second, third.";
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 results: Rect,
pub input: Rect,
pub preview_title: Option<Rect>,
pub preview_window: Option<Rect>,
pub preview_title: Rect,
pub preview_window: Rect,
pub remote_control: Option<Rect>,
}
impl Layout {
@ -35,8 +36,9 @@ impl Layout {
help_bar_right: Rect,
results: Rect,
input: Rect,
preview_title: Option<Rect>,
preview_window: Option<Rect>,
preview_title: Rect,
preview_window: Rect,
remote_control: Option<Rect>,
) -> Self {
Self {
help_bar_left,
@ -46,13 +48,14 @@ impl Layout {
input,
preview_title,
preview_window,
remote_control,
}
}
pub fn build(
dimensions: &Dimensions,
area: Rect,
with_preview: bool,
with_remote: bool,
) -> Self {
let main_block = centered_rect(dimensions.x, dimensions.y, area);
// split the main block into two vertical chunks (help bar + rest)
@ -65,20 +68,28 @@ impl Layout {
let help_bar_chunks = layout::Layout::default()
.direction(Direction::Horizontal)
.constraints([
// metadata
Constraint::Fill(1),
// keymaps
Constraint::Fill(1),
// logo
Constraint::Length(24),
])
.split(hz_chunks[0]);
if with_preview {
// split the main block into two vertical chunks
let constraints = if with_remote {
vec![
Constraint::Fill(1),
Constraint::Fill(1),
Constraint::Length(24),
]
} else {
vec![Constraint::Percentage(50), Constraint::Percentage(50)]
};
let vt_chunks = layout::Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(50),
Constraint::Percentage(50),
])
.constraints(constraints)
.split(hz_chunks[1]);
// left block: results + input field
@ -99,27 +110,15 @@ impl Layout {
help_bar_chunks[2],
left_chunks[0],
left_chunks[1],
Some(right_chunks[0]),
Some(right_chunks[1]),
)
right_chunks[0],
right_chunks[1],
if with_remote {
Some(vt_chunks[2])
} 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[1]);
Self::new(
help_bar_chunks[0],
help_bar_chunks[1],
help_bar_chunks[2],
chunks[0],
chunks[1],
None,
None,
None
},
)
}
}
}
/// helper function to create a centered rect using up certain percentage of the available rect `r`

View File

@ -24,3 +24,33 @@ pub fn build_logo_paragraph<'a>() -> Paragraph<'a> {
let logo_paragraph = Paragraph::new(lines);
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::{
Preview, PreviewContent, FILE_TOO_LARGE_MSG, PREVIEW_NOT_SUPPORTED_MSG,
};
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::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 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);
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_EMPTY: char = ' ';
@ -277,6 +368,6 @@ pub fn convert_syn_region_to_span<'a>(
fn convert_syn_color_to_ratatui_color(
color: syntect::highlighting::Color,
) -> ratatui::style::Color {
ratatui::style::Color::Rgb(color.r, color.g, color.b)
) -> Color {
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::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 color_eyre::eyre::Result;
use ratatui::layout::Alignment;
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;
// Styles
@ -13,6 +22,7 @@ const DEFAULT_RESULT_SELECTED_BG: Color = Color::Rgb(50, 50, 50);
pub fn build_results_list<'a, 'b>(
results_block: Block<'b>,
entries: &'a [Entry],
list_direction: ListDirection,
) -> List<'a>
where
'b: 'a,
@ -107,8 +117,48 @@ where
}
Line::from(spans)
}))
.direction(ListDirection::BottomToTop)
.direction(list_direction)
.highlight_style(Style::default().bg(DEFAULT_RESULT_SELECTED_BG))
.highlight_symbol("> ")
.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 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)]
pub fn cli_channel_derive(input: TokenStream) -> TokenStream {
// 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)
}
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 {
// check that the struct is an enum
@ -88,8 +110,34 @@ fn impl_cli_channel(ast: &syn::DeriveInput) -> TokenStream {
gen.into()
}
/// This macro generates the `OnAir` trait implementation for the
/// given enum.
/// This macro generates the `OnAir` trait implementation for the 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)]
pub fn tv_channel_derive(input: TokenStream) -> TokenStream {
// 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()
}
/// 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)]
pub fn unit_channel_derive(input: TokenStream) -> TokenStream {
// Construct a representation of Rust code as a syntax tree