diff --git a/.config/config.toml b/.config/config.toml index 74ea754..2038a5e 100644 --- a/.config/config.toml +++ b/.config/config.toml @@ -83,8 +83,12 @@ select_prev_page = "pageup" # Scrolling the preview pane scroll_preview_half_page_down = "ctrl-d" scroll_preview_half_page_up = "ctrl-u" -# Select an entry -select_entry = "enter" +# Add entry to selection and move to the next entry +toggle_selection_down = "tab" +# Add entry to selection and move to the previous entry +toggle_selection_up = "backtab" +# Confirm selection +confirm_selection = "enter" # Copy the selected entry to the clipboard copy_entry_to_clipboard = "ctrl-y" # Toggle the remote control mode diff --git a/crates/television-channels/src/channels.rs b/crates/television-channels/src/channels.rs index f0b2361..c89f342 100644 --- a/crates/television-channels/src/channels.rs +++ b/crates/television-channels/src/channels.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; + use crate::entry::Entry; use color_eyre::Result; use television_derive::{Broadcast, ToCliChannel, ToUnitChannel}; @@ -65,6 +67,12 @@ pub trait OnAir: Send { /// Get a specific result by its index. fn get_result(&self, index: u32) -> Option; + /// Get the currently selected entries. + fn selected_entries(&self) -> &HashSet; + + /// Toggles selection for the entry under the cursor. + fn toggle_selection(&mut self, entry: &Entry); + /// Get the number of results currently available. fn result_count(&self) -> u32; diff --git a/crates/television-channels/src/channels/alias.rs b/crates/television-channels/src/channels/alias.rs index 2cd6249..a50e8eb 100644 --- a/crates/television-channels/src/channels/alias.rs +++ b/crates/television-channels/src/channels/alias.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; + use crate::channels::OnAir; use crate::entry::Entry; use crate::entry::PreviewType; @@ -21,6 +23,7 @@ impl Alias { pub struct Channel { matcher: Matcher, file_icon: FileIcon, + selected_entries: HashSet, } const NUM_THREADS: usize = 1; @@ -52,6 +55,7 @@ impl Channel { Self { matcher, file_icon: FileIcon::from(FILE_ICON_STR), + selected_entries: HashSet::new(), } } } @@ -115,6 +119,18 @@ impl OnAir for Channel { }) } + fn selected_entries(&self) -> &HashSet { + &self.selected_entries + } + + fn toggle_selection(&mut self, entry: &Entry) { + if self.selected_entries.contains(entry) { + self.selected_entries.remove(entry); + } else { + self.selected_entries.insert(entry.clone()); + } + } + fn result_count(&self) -> u32 { self.matcher.matched_item_count } diff --git a/crates/television-channels/src/channels/cable.rs b/crates/television-channels/src/channels/cable.rs index f664e7b..c373f61 100644 --- a/crates/television-channels/src/channels/cable.rs +++ b/crates/television-channels/src/channels/cable.rs @@ -26,6 +26,7 @@ pub struct Channel { matcher: Matcher, entries_command: String, preview_kind: PreviewKind, + selected_entries: HashSet, } impl Default for Channel { @@ -93,6 +94,7 @@ impl Channel { entries_command: entries_command.to_string(), preview_kind, name: name.to_string(), + selected_entries: HashSet::new(), } } } @@ -162,6 +164,18 @@ impl OnAir for Channel { }) } + fn selected_entries(&self) -> &HashSet { + &self.selected_entries + } + + fn toggle_selection(&mut self, entry: &Entry) { + if self.selected_entries.contains(entry) { + self.selected_entries.remove(entry); + } else { + self.selected_entries.insert(entry.clone()); + } + } + fn result_count(&self) -> u32 { self.matcher.matched_item_count } diff --git a/crates/television-channels/src/channels/dirs.rs b/crates/television-channels/src/channels/dirs.rs index 6ad2cb6..b5df262 100644 --- a/crates/television-channels/src/channels/dirs.rs +++ b/crates/television-channels/src/channels/dirs.rs @@ -11,6 +11,7 @@ pub struct Channel { crawl_handle: tokio::task::JoinHandle<()>, // PERF: cache results (to make deleting characters smoother) with // a shallow stack of sub-patterns as keys (e.g. "a", "ab", "abc") + selected_entries: HashSet, } impl Channel { @@ -21,6 +22,7 @@ impl Channel { Channel { matcher, crawl_handle, + selected_entries: HashSet::new(), } } } @@ -104,6 +106,18 @@ impl OnAir for Channel { }) } + fn selected_entries(&self) -> &HashSet { + &self.selected_entries + } + + fn toggle_selection(&mut self, entry: &Entry) { + if self.selected_entries.contains(entry) { + self.selected_entries.remove(entry); + } else { + self.selected_entries.insert(entry.clone()); + } + } + fn result_count(&self) -> u32 { self.matcher.matched_item_count } diff --git a/crates/television-channels/src/channels/env.rs b/crates/television-channels/src/channels/env.rs index 3f5f925..2b8031a 100644 --- a/crates/television-channels/src/channels/env.rs +++ b/crates/television-channels/src/channels/env.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; + use devicons::FileIcon; use super::OnAir; @@ -15,6 +17,7 @@ struct EnvVar { pub struct Channel { matcher: Matcher, file_icon: FileIcon, + selected_entries: HashSet, } const NUM_THREADS: usize = 1; @@ -32,6 +35,7 @@ impl Channel { Channel { matcher, file_icon: FileIcon::from(FILE_ICON_STR), + selected_entries: HashSet::new(), } } } @@ -95,6 +99,18 @@ impl OnAir for Channel { }) } + fn selected_entries(&self) -> &HashSet { + &self.selected_entries + } + + fn toggle_selection(&mut self, entry: &Entry) { + if self.selected_entries.contains(entry) { + self.selected_entries.remove(entry); + } else { + self.selected_entries.insert(entry.clone()); + } + } + fn result_count(&self) -> u32 { self.matcher.matched_item_count } diff --git a/crates/television-channels/src/channels/files.rs b/crates/television-channels/src/channels/files.rs index c52f91f..e1b200b 100644 --- a/crates/television-channels/src/channels/files.rs +++ b/crates/television-channels/src/channels/files.rs @@ -11,6 +11,7 @@ pub struct Channel { crawl_handle: tokio::task::JoinHandle<()>, // PERF: cache results (to make deleting characters smoother) with // a shallow stack of sub-patterns as keys (e.g. "a", "ab", "abc") + selected_entries: HashSet, } impl Channel { @@ -21,6 +22,7 @@ impl Channel { Channel { matcher, crawl_handle, + selected_entries: HashSet::new(), } } } @@ -106,6 +108,18 @@ impl OnAir for Channel { }) } + fn selected_entries(&self) -> &HashSet { + &self.selected_entries + } + + fn toggle_selection(&mut self, entry: &Entry) { + if self.selected_entries.contains(entry) { + self.selected_entries.remove(entry); + } else { + self.selected_entries.insert(entry.clone()); + } + } + fn result_count(&self) -> u32 { self.matcher.matched_item_count } diff --git a/crates/television-channels/src/channels/git_repos.rs b/crates/television-channels/src/channels/git_repos.rs index 1afa2f6..c49cdb0 100644 --- a/crates/television-channels/src/channels/git_repos.rs +++ b/crates/television-channels/src/channels/git_repos.rs @@ -2,6 +2,7 @@ use devicons::FileIcon; use directories::BaseDirs; use ignore::overrides::OverrideBuilder; use lazy_static::lazy_static; +use std::collections::HashSet; use std::path::PathBuf; use tokio::task::JoinHandle; use tracing::debug; @@ -15,6 +16,7 @@ pub struct Channel { matcher: Matcher, icon: FileIcon, crawl_handle: JoinHandle<()>, + selected_entries: HashSet, } impl Channel { @@ -29,6 +31,7 @@ impl Channel { matcher, icon: FileIcon::from("git"), crawl_handle, + selected_entries: HashSet::new(), } } } @@ -81,6 +84,18 @@ impl OnAir for Channel { }) } + fn selected_entries(&self) -> &HashSet { + &self.selected_entries + } + + fn toggle_selection(&mut self, entry: &Entry) { + if self.selected_entries.contains(entry) { + self.selected_entries.remove(entry); + } else { + self.selected_entries.insert(entry.clone()); + } + } + fn result_count(&self) -> u32 { self.matcher.matched_item_count } diff --git a/crates/television-channels/src/channels/remote_control.rs b/crates/television-channels/src/channels/remote_control.rs index 4579744..f474f7b 100644 --- a/crates/television-channels/src/channels/remote_control.rs +++ b/crates/television-channels/src/channels/remote_control.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::fmt::Display; use crate::cable::{CableChannelPrototype, CableChannels}; @@ -13,6 +14,7 @@ use super::cable; pub struct RemoteControl { matcher: Matcher, cable_channels: Option, + selected_entries: HashSet, } #[derive(Clone)] @@ -59,6 +61,7 @@ impl RemoteControl { RemoteControl { matcher, cable_channels, + selected_entries: HashSet::new(), } } @@ -140,6 +143,13 @@ impl OnAir for RemoteControl { .collect() } + fn selected_entries(&self) -> &HashSet { + &self.selected_entries + } + + #[allow(unused_variables)] + fn toggle_selection(&mut self, entry: &Entry) {} + fn get_result(&self, index: u32) -> Option { self.matcher.get_result(index).map(|item| { let path = item.matched_string; diff --git a/crates/television-channels/src/channels/stdin.rs b/crates/television-channels/src/channels/stdin.rs index c0020d0..9cef99c 100644 --- a/crates/television-channels/src/channels/stdin.rs +++ b/crates/television-channels/src/channels/stdin.rs @@ -1,4 +1,5 @@ use std::{ + collections::HashSet, io::{stdin, BufRead}, thread::spawn, }; @@ -12,6 +13,7 @@ use television_fuzzy::matcher::{config::Config, injector::Injector, Matcher}; pub struct Channel { matcher: Matcher, preview_type: PreviewType, + selected_entries: HashSet, } impl Channel { @@ -24,6 +26,7 @@ impl Channel { Self { matcher, preview_type: preview_type.unwrap_or_default(), + selected_entries: HashSet::new(), } } } @@ -90,6 +93,18 @@ impl OnAir for Channel { }) } + fn selected_entries(&self) -> &HashSet { + &self.selected_entries + } + + fn toggle_selection(&mut self, entry: &Entry) { + if self.selected_entries.contains(entry) { + self.selected_entries.remove(entry); + } else { + self.selected_entries.insert(entry.clone()); + } + } + fn result_count(&self) -> u32 { self.matcher.matched_item_count } diff --git a/crates/television-channels/src/channels/text.rs b/crates/television-channels/src/channels/text.rs index 7fa7952..0d034a6 100644 --- a/crates/television-channels/src/channels/text.rs +++ b/crates/television-channels/src/channels/text.rs @@ -3,6 +3,7 @@ use crate::entry::{Entry, PreviewType}; use devicons::FileIcon; use ignore::WalkState; use std::{ + collections::HashSet, fs::File, io::{BufRead, Read, Seek}, path::{Path, PathBuf}, @@ -36,6 +37,7 @@ impl CandidateLine { pub struct Channel { matcher: Matcher, crawl_handle: tokio::task::JoinHandle<()>, + selected_entries: HashSet, } impl Channel { @@ -49,6 +51,7 @@ impl Channel { Channel { matcher, crawl_handle, + selected_entries: HashSet::new(), } } @@ -73,6 +76,7 @@ impl Channel { Channel { matcher, crawl_handle, + selected_entries: HashSet::new(), } } @@ -97,6 +101,7 @@ impl Channel { Channel { matcher, crawl_handle: load_handle, + selected_entries: HashSet::new(), } } } @@ -195,6 +200,18 @@ impl OnAir for Channel { }) } + fn selected_entries(&self) -> &HashSet { + &self.selected_entries + } + + fn toggle_selection(&mut self, entry: &Entry) { + if self.selected_entries.contains(entry) { + self.selected_entries.remove(entry); + } else { + self.selected_entries.insert(entry.clone()); + } + } + fn result_count(&self) -> u32 { self.matcher.matched_item_count } diff --git a/crates/television-channels/src/entry.rs b/crates/television-channels/src/entry.rs index d072d29..1405098 100644 --- a/crates/television-channels/src/entry.rs +++ b/crates/television-channels/src/entry.rs @@ -1,4 +1,8 @@ -use std::{fmt::Display, path::PathBuf}; +use std::{ + fmt::Display, + hash::{Hash, Hasher}, + path::PathBuf, +}; use devicons::FileIcon; use strum::EnumString; @@ -9,7 +13,7 @@ use strum::EnumString; // channel convertible from any other that yields `EntryType`. // This needs pondering since it does bring another level of abstraction and // adds a layer of complexity. -#[derive(Clone, Debug, Eq, PartialEq, Hash)] +#[derive(Clone, Debug, Eq)] pub struct Entry { /// The name of the entry. pub name: String, @@ -27,6 +31,31 @@ pub struct Entry { pub preview_type: PreviewType, } +impl Hash for Entry { + fn hash(&self, state: &mut H) { + self.name.hash(state); + if let Some(line_number) = self.line_number { + line_number.hash(state); + } + } +} + +impl PartialEq for &Entry { + fn eq(&self, other: &Entry) -> bool { + self.name == other.name + && (self.line_number.is_none() && other.line_number.is_none() + || self.line_number == other.line_number) + } +} + +impl PartialEq for Entry { + fn eq(&self, other: &Entry) -> bool { + self.name == other.name + && (self.line_number.is_none() && other.line_number.is_none() + || self.line_number == other.line_number) + } +} + #[allow(clippy::needless_return)] pub fn merge_ranges(ranges: &[(u32, u32)]) -> Vec<(u32, u32)> { ranges.iter().fold( diff --git a/crates/television-derive/src/lib.rs b/crates/television-derive/src/lib.rs index 196775b..0526cc5 100644 --- a/crates/television-derive/src/lib.rs +++ b/crates/television-derive/src/lib.rs @@ -213,6 +213,26 @@ fn impl_tv_channel(ast: &syn::DeriveInput) -> TokenStream { } } + fn selected_entries(&self) -> &HashSet { + match self { + #( + #enum_name::#variant_names(ref channel) => { + channel.selected_entries() + } + )* + } + } + + fn toggle_selection(&mut self, entry: &Entry) { + match self { + #( + #enum_name::#variant_names(ref mut channel) => { + channel.toggle_selection(entry) + } + )* + } + } + fn result_count(&self) -> u32 { match self { #( diff --git a/crates/television-screen/src/colors.rs b/crates/television-screen/src/colors.rs index bcdf6ba..0314368 100644 --- a/crates/television-screen/src/colors.rs +++ b/crates/television-screen/src/colors.rs @@ -28,6 +28,7 @@ pub struct ResultsColorscheme { pub result_preview_fg: Color, pub result_line_number_fg: Color, pub result_selected_bg: Color, + pub result_selected_fg: Color, pub match_foreground_color: Color, } diff --git a/crates/television-screen/src/input.rs b/crates/television-screen/src/input.rs index f2234d0..9c0f433 100644 --- a/crates/television-screen/src/input.rs +++ b/crates/television-screen/src/input.rs @@ -55,7 +55,9 @@ pub fn draw_input_box( Constraint::Fill(1), // result count Constraint::Length( - 3 * (u16::try_from((total_count).ilog10()).unwrap() + 1) + 3, + 3 * (u16::try_from((total_count.max(1)).ilog10()).unwrap() + + 1) + + 3, ), // spinner Constraint::Length(1), diff --git a/crates/television-screen/src/remote_control.rs b/crates/television-screen/src/remote_control.rs index 06fd79e..c16d6c1 100644 --- a/crates/television-screen/src/remote_control.rs +++ b/crates/television-screen/src/remote_control.rs @@ -81,6 +81,7 @@ fn draw_rc_channels( let channel_list = build_results_list( rc_block, entries, + None, ListDirection::TopToBottom, use_nerd_font_icons, icon_color_cache, diff --git a/crates/television-screen/src/results.rs b/crates/television-screen/src/results.rs index 6112a42..9d8ecb9 100644 --- a/crates/television-screen/src/results.rs +++ b/crates/television-screen/src/results.rs @@ -8,7 +8,7 @@ use ratatui::widgets::{ Block, BorderType, Borders, List, ListDirection, ListState, Padding, }; use ratatui::Frame; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::str::FromStr; use television_channels::entry::Entry; use television_utils::strings::{ @@ -16,9 +16,14 @@ use television_utils::strings::{ slice_at_char_boundaries, }; +const POINTER_SYMBOL: &str = "> "; +const SELECTED_SYMBOL: &str = "● "; +const DESLECTED_SYMBOL: &str = " "; + pub fn build_results_list<'a, 'b>( results_block: Block<'b>, entries: &'a [Entry], + selected_entries: Option<&HashSet>, list_direction: ListDirection, use_icons: bool, icon_color_cache: &mut HashMap, @@ -29,6 +34,19 @@ where { List::new(entries.iter().map(|entry| { let mut spans = Vec::new(); + // optional selection symbol + if let Some(selected_entries) = selected_entries { + if !selected_entries.is_empty() { + spans.push(if selected_entries.contains(entry) { + Span::styled( + SELECTED_SYMBOL, + Style::default().fg(colorscheme.result_selected_fg), + ) + } else { + Span::from(DESLECTED_SYMBOL) + }); + } + } // optional icon if let Some(icon) = entry.icon.as_ref() { if use_icons { @@ -129,7 +147,7 @@ where .highlight_style( Style::default().bg(colorscheme.result_selected_bg).bold(), ) - .highlight_symbol("> ") + .highlight_symbol(POINTER_SYMBOL) .block(results_block) } @@ -138,6 +156,7 @@ pub fn draw_results_list( f: &mut Frame, rect: Rect, entries: &[Entry], + selected_entries: &HashSet, relative_picker_state: &mut ListState, input_bar_position: InputPosition, use_nerd_font_icons: bool, @@ -166,6 +185,7 @@ pub fn draw_results_list( let results_list = build_results_list( results_block, entries, + Some(selected_entries), match input_bar_position { InputPosition::Bottom => ListDirection::BottomToTop, InputPosition::Top => ListDirection::TopToBottom, diff --git a/crates/television/action.rs b/crates/television/action.rs index 11e78f1..812ed34 100644 --- a/crates/television/action.rs +++ b/crates/television/action.rs @@ -39,9 +39,16 @@ pub enum Action { #[serde(skip)] ClearScreen, // results actions - /// Select the entry currently under the cursor. + /// Add entry under cursor to the list of selected entries and move the cursor down. + #[serde(alias = "toggle_selection_down")] + ToggleSelectionDown, + /// Add entry under cursor to the list of selected entries and move the cursor up. + #[serde(alias = "toggle_selection_up")] + ToggleSelectionUp, + /// Confirm current selection (multi select or entry under cursor). #[serde(alias = "select_entry")] - SelectEntry, + #[serde(alias = "confirm_selection")] + ConfirmSelection, /// Select the entry currently under the cursor and pass the key that was pressed /// through to be handled the parent process. #[serde(alias = "select_passthrough")] diff --git a/crates/television/app.rs b/crates/television/app.rs index ef9a9f7..17d233c 100644 --- a/crates/television/app.rs +++ b/crates/television/app.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::sync::Arc; use color_eyre::Result; @@ -44,36 +45,36 @@ pub struct App { /// The outcome of an action. #[derive(Debug)] pub enum ActionOutcome { - Entry(Entry), + Entries(HashSet), Input(String), - Passthrough(Entry, String), + Passthrough(HashSet, String), None, } /// The result of the application. #[derive(Debug)] pub struct AppOutput { - pub selected_entry: Option, + pub selected_entries: Option>, pub passthrough: Option, } impl From for AppOutput { fn from(outcome: ActionOutcome) -> Self { match outcome { - ActionOutcome::Entry(entry) => Self { - selected_entry: Some(entry), + ActionOutcome::Entries(entries) => Self { + selected_entries: Some(entries), passthrough: None, }, ActionOutcome::Input(input) => Self { - selected_entry: None, + selected_entries: None, passthrough: Some(input), }, - ActionOutcome::Passthrough(entry, key) => Self { - selected_entry: Some(entry), + ActionOutcome::Passthrough(entries, key) => Self { + selected_entries: Some(entries), passthrough: Some(key), }, ActionOutcome::None => Self { - selected_entry: None, + selected_entries: None, passthrough: None, }, } @@ -262,10 +263,13 @@ impl App { Action::SelectAndExit => { self.should_quit = true; self.render_tx.send(RenderingTask::Quit)?; - if let Some(entry) = - self.television.lock().await.get_selected_entry(None) + if let Some(entries) = self + .television + .lock() + .await + .get_selected_entries(Some(Mode::Channel)) { - return Ok(ActionOutcome::Entry(entry)); + return Ok(ActionOutcome::Entries(entries)); } return Ok(ActionOutcome::Input( self.television.lock().await.current_pattern.clone(), @@ -274,11 +278,14 @@ impl App { Action::SelectPassthrough(passthrough) => { self.should_quit = true; self.render_tx.send(RenderingTask::Quit)?; - if let Some(entry) = - self.television.lock().await.get_selected_entry(None) + if let Some(entries) = self + .television + .lock() + .await + .get_selected_entries(Some(Mode::Channel)) { return Ok(ActionOutcome::Passthrough( - entry, + entries, passthrough, )); } diff --git a/crates/television/config/themes.rs b/crates/television/config/themes.rs index 7f52f04..dca43ae 100644 --- a/crates/television/config/themes.rs +++ b/crates/television/config/themes.rs @@ -102,6 +102,7 @@ pub struct Theme { pub result_line_number_fg: Color, pub result_value_fg: Color, pub selection_bg: Color, + pub selection_fg: Color, pub match_fg: Color, // preview pub preview_title_fg: Color, @@ -170,6 +171,9 @@ struct Inner { result_line_number_fg: String, result_value_fg: String, selection_bg: String, + // this is made optional for theme backwards compatibility + // and falls back to match_fg + selection_fg: Option, match_fg: String, //preview preview_title_fg: String, @@ -190,44 +194,129 @@ impl<'de> Deserialize<'de> for Theme { .background .map(|s| { Color::from_str(&s).ok_or_else(|| { - serde::de::Error::custom("invalid color") + serde::de::Error::custom(format!( + "invalid color {}", + s + )) }) }) .transpose()?, - border_fg: Color::from_str(&inner.border_fg) - .ok_or_else(|| serde::de::Error::custom("invalid color"))?, - text_fg: Color::from_str(&inner.text_fg) - .ok_or_else(|| serde::de::Error::custom("invalid color"))?, + border_fg: Color::from_str(&inner.border_fg).ok_or_else(|| { + serde::de::Error::custom(format!( + "invalid color {}", + &inner.border_fg + )) + })?, + text_fg: Color::from_str(&inner.text_fg).ok_or_else(|| { + serde::de::Error::custom(format!( + "invalid color {}", + &inner.text_fg + )) + })?, dimmed_text_fg: Color::from_str(&inner.dimmed_text_fg) - .ok_or_else(|| serde::de::Error::custom("invalid color"))?, - input_text_fg: Color::from_str(&inner.input_text_fg) - .ok_or_else(|| serde::de::Error::custom("invalid color"))?, + .ok_or_else(|| { + serde::de::Error::custom(format!( + "invalid color {}", + &inner.dimmed_text_fg + )) + })?, + input_text_fg: Color::from_str(&inner.input_text_fg).ok_or_else( + || { + serde::de::Error::custom(format!( + "invalid color {}", + &inner.input_text_fg + )) + }, + )?, result_count_fg: Color::from_str(&inner.result_count_fg) - .ok_or_else(|| serde::de::Error::custom("invalid color"))?, + .ok_or_else(|| { + serde::de::Error::custom(format!( + "invalid color {}", + &inner.result_count_fg + )) + })?, result_name_fg: Color::from_str(&inner.result_name_fg) - .ok_or_else(|| serde::de::Error::custom("invalid color"))?, + .ok_or_else(|| { + serde::de::Error::custom(format!( + "invalid color {}", + &inner.result_name_fg + )) + })?, result_line_number_fg: Color::from_str( &inner.result_line_number_fg, ) - .ok_or_else(|| serde::de::Error::custom("invalid color"))?, + .ok_or_else(|| { + serde::de::Error::custom(format!( + "invalid color {}", + &inner.result_line_number_fg + )) + })?, result_value_fg: Color::from_str(&inner.result_value_fg) - .ok_or_else(|| serde::de::Error::custom("invalid color"))?, - selection_bg: Color::from_str(&inner.selection_bg) - .ok_or_else(|| serde::de::Error::custom("invalid color"))?, - match_fg: Color::from_str(&inner.match_fg) - .ok_or_else(|| serde::de::Error::custom("invalid color"))?, + .ok_or_else(|| { + serde::de::Error::custom(format!( + "invalid color {}", + &inner.result_value_fg + )) + })?, + selection_bg: Color::from_str(&inner.selection_bg).ok_or_else( + || { + serde::de::Error::custom(format!( + "invalid color {}", + &inner.selection_bg + )) + }, + )?, + // this is optional for theme backwards compatibility and falls back to match_fg + selection_fg: match inner.selection_fg { + Some(s) => Color::from_str(&s).ok_or_else(|| { + serde::de::Error::custom(format!("invalid color {}", &s)) + })?, + None => Color::from_str(&inner.match_fg).ok_or_else(|| { + serde::de::Error::custom(format!( + "invalid color {}", + &inner.match_fg + )) + })?, + }, + + match_fg: Color::from_str(&inner.match_fg).ok_or_else(|| { + serde::de::Error::custom(format!( + "invalid color {}", + &inner.match_fg + )) + })?, preview_title_fg: Color::from_str(&inner.preview_title_fg) - .ok_or_else(|| serde::de::Error::custom("invalid color"))?, + .ok_or_else(|| { + serde::de::Error::custom(format!( + "invalid color {}", + &inner.preview_title_fg + )) + })?, channel_mode_fg: Color::from_str(&inner.channel_mode_fg) - .ok_or_else(|| serde::de::Error::custom("invalid color"))?, + .ok_or_else(|| { + serde::de::Error::custom(format!( + "invalid color {}", + &inner.channel_mode_fg + )) + })?, remote_control_mode_fg: Color::from_str( &inner.remote_control_mode_fg, ) - .ok_or_else(|| serde::de::Error::custom("invalid color"))?, + .ok_or_else(|| { + serde::de::Error::custom(format!( + "invalid color {}", + &inner.remote_control_mode_fg + )) + })?, send_to_channel_mode_fg: Color::from_str( &inner.send_to_channel_mode_fg, ) - .ok_or_else(|| serde::de::Error::custom("invalid color"))?, + .ok_or_else(|| { + serde::de::Error::custom(format!( + "invalid color {}", + &inner.send_to_channel_mode_fg + )) + })?, }) } } @@ -315,6 +404,7 @@ impl Into for &Theme { result_preview_fg: (&self.result_value_fg).into(), result_line_number_fg: (&self.result_line_number_fg).into(), result_selected_bg: (&self.selection_bg).into(), + result_selected_fg: (&self.selection_fg).into(), match_foreground_color: (&self.match_fg).into(), } } @@ -371,6 +461,7 @@ mod tests { result_line_number_fg = "bright-white" result_value_fg = "bright-white" selection_bg = "bright-white" + selection_fg = "bright-white" match_fg = "bright-white" preview_title_fg = "bright-white" channel_mode_fg = "bright-white" @@ -394,6 +485,7 @@ mod tests { ); assert_eq!(theme.result_value_fg, Color::Ansi(ANSIColor::BrightWhite)); assert_eq!(theme.selection_bg, Color::Ansi(ANSIColor::BrightWhite)); + assert_eq!(theme.selection_fg, Color::Ansi(ANSIColor::BrightWhite)); assert_eq!(theme.match_fg, Color::Ansi(ANSIColor::BrightWhite)); assert_eq!( theme.preview_title_fg, @@ -422,6 +514,7 @@ mod tests { result_line_number_fg = "#ffffff" result_value_fg = "bright-white" selection_bg = "bright-white" + selection_fg = "bright-white" match_fg = "bright-white" preview_title_fg = "bright-white" channel_mode_fg = "bright-white" @@ -445,6 +538,7 @@ mod tests { ); assert_eq!(theme.result_value_fg, Color::Ansi(ANSIColor::BrightWhite)); assert_eq!(theme.selection_bg, Color::Ansi(ANSIColor::BrightWhite)); + assert_eq!(theme.selection_fg, Color::Ansi(ANSIColor::BrightWhite)); assert_eq!(theme.match_fg, Color::Ansi(ANSIColor::BrightWhite)); assert_eq!( theme.preview_title_fg, diff --git a/crates/television/main.rs b/crates/television/main.rs index 1dc996d..a900d45 100644 --- a/crates/television/main.rs +++ b/crates/television/main.rs @@ -1,5 +1,5 @@ use std::env; -use std::io::{stdout, IsTerminal, Write}; +use std::io::{stdout, BufWriter, IsTerminal, Write}; use std::path::Path; use std::process::exit; @@ -119,12 +119,18 @@ async fn main() -> Result<()> { stdout().flush()?; let output = app.run(stdout().is_terminal()).await?; info!("{:?}", output); + // lock stdout + let stdout_handle = stdout().lock(); + let mut bufwriter = BufWriter::new(stdout_handle); if let Some(passthrough) = output.passthrough { - writeln!(stdout(), "{passthrough}")?; + writeln!(bufwriter, "{passthrough}")?; } - if let Some(entry) = output.selected_entry { - writeln!(stdout(), "{}", entry.stdout_repr())?; + if let Some(entries) = output.selected_entries { + for entry in &entries { + writeln!(bufwriter, "{}", entry.stdout_repr())?; + } } + bufwriter.flush()?; exit(0); } Err(err) => { diff --git a/crates/television/television.rs b/crates/television/television.rs index ec55ce2..f161df8 100644 --- a/crates/television/television.rs +++ b/crates/television/television.rs @@ -6,7 +6,7 @@ use crate::{cable::load_cable_channels, keymap::Keymap}; use color_eyre::Result; use copypasta::{ClipboardContext, ClipboardProvider}; use ratatui::{layout::Rect, style::Color, Frame}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; use television_channels::channels::{ remote_control::{load_builtin_channels, RemoteControl}, @@ -148,17 +148,40 @@ impl Television { #[must_use] pub fn get_selected_entry(&mut self, mode: Option) -> Option { match mode.unwrap_or(self.mode) { - Mode::Channel => self.results_picker.selected().and_then(|i| { - self.channel.get_result(u32::try_from(i).unwrap()) - }), + Mode::Channel => { + if let Some(i) = self.results_picker.selected() { + return self.channel.get_result(i.try_into().unwrap()); + } + None + } Mode::RemoteControl | Mode::SendToChannel => { - self.rc_picker.selected().and_then(|i| { - self.remote_control.get_result(u32::try_from(i).unwrap()) - }) + if let Some(i) = self.rc_picker.selected() { + return self + .remote_control + .get_result(i.try_into().unwrap()); + } + None } } } + #[must_use] + pub fn get_selected_entries( + &mut self, + mode: Option, + ) -> Option> { + if self.channel.selected_entries().is_empty() + || matches!(mode, Some(Mode::RemoteControl)) + { + return self.get_selected_entry(mode).map(|e| { + let mut set = HashSet::new(); + set.insert(e); + set + }); + } + Some(self.channel.selected_entries().clone()) + } + pub fn select_prev_entry(&mut self, step: u32) { let (result_count, picker) = match self.mode { Mode::Channel => { @@ -334,15 +357,30 @@ impl Television { } Mode::SendToChannel => {} }, - Action::SelectEntry => { - if let Some(entry) = self.get_selected_entry(None) { - match self.mode { - Mode::Channel => self - .action_tx + Action::ToggleSelectionDown | Action::ToggleSelectionUp => { + if matches!(self.mode, Mode::Channel) { + if let Some(entry) = self.get_selected_entry(None) { + self.channel.toggle_selection(&entry); + if matches!(action, Action::ToggleSelectionDown) { + self.select_next_entry(1); + } else { + self.select_prev_entry(1); + } + } + } + } + Action::ConfirmSelection => { + match self.mode { + Mode::Channel => { + self.action_tx .as_ref() .unwrap() - .send(Action::SelectAndExit)?, - Mode::RemoteControl => { + .send(Action::SelectAndExit)?; + } + Mode::RemoteControl => { + if let Some(entry) = + self.get_selected_entry(Some(Mode::RemoteControl)) + { let new_channel = self .remote_control .zap(entry.name.as_str())?; @@ -353,7 +391,11 @@ impl Television { self.mode = Mode::Channel; self.change_channel(new_channel); } - Mode::SendToChannel => { + } + Mode::SendToChannel => { + if let Some(entry) = + self.get_selected_entry(Some(Mode::RemoteControl)) + { let new_channel = self.channel.transition_to( entry.name.as_str().try_into().unwrap(), ); @@ -364,18 +406,22 @@ impl Television { self.change_channel(new_channel); } } - } else { - self.action_tx - .as_ref() - .unwrap() - .send(Action::SelectAndExit)?; } } Action::CopyEntryToClipboard => { if self.mode == Mode::Channel { - if let Some(entry) = self.get_selected_entry(None) { + if let Some(entries) = self.get_selected_entries(None) { let mut ctx = ClipboardContext::new().unwrap(); - ctx.set_contents(entry.name).unwrap(); + ctx.set_contents( + entries + .iter() + .map(|e| e.name.clone()) + .collect::>() + .join(" ") + .to_string() + .to_string(), + ) + .unwrap(); } } } @@ -464,6 +510,7 @@ impl Television { f, layout.results, &entries, + self.channel.selected_entries(), &mut self.results_picker.relative_state, self.config.ui.input_bar_position, self.config.ui.use_nerd_font_icons, @@ -596,7 +643,10 @@ impl KeyBindings { ), ( DisplayableAction::SelectEntry, - serialized_keys_for_actions(self, &[Action::SelectEntry]), + serialized_keys_for_actions( + self, + &[Action::ConfirmSelection], + ), ), ( DisplayableAction::CopyEntryToClipboard, @@ -637,7 +687,10 @@ impl KeyBindings { ), ( DisplayableAction::SelectEntry, - serialized_keys_for_actions(self, &[Action::SelectEntry]), + serialized_keys_for_actions( + self, + &[Action::ConfirmSelection], + ), ), ( DisplayableAction::ToggleRemoteControl, @@ -660,7 +713,10 @@ impl KeyBindings { ), ( DisplayableAction::SelectEntry, - serialized_keys_for_actions(self, &[Action::SelectEntry]), + serialized_keys_for_actions( + self, + &[Action::ConfirmSelection], + ), ), ( DisplayableAction::Cancel, diff --git a/themes/catppuccin.toml b/themes/catppuccin.toml index da86dca..6172ccd 100644 --- a/themes/catppuccin.toml +++ b/themes/catppuccin.toml @@ -10,6 +10,7 @@ result_count_fg = '#f38ba8' result_name_fg = '#89b4fa' result_line_number_fg = '#f9e2af' result_value_fg = '#b4befe' +selection_fg = '#a6e3a1' selection_bg = '#313244' match_fg = '#f38ba8' # preview diff --git a/themes/default.toml b/themes/default.toml index edae0d0..5bfbdd2 100644 --- a/themes/default.toml +++ b/themes/default.toml @@ -10,6 +10,7 @@ result_count_fg = 'bright-red' result_name_fg = 'bright-blue' result_line_number_fg = 'bright-yellow' result_value_fg = 'white' +selection_fg = 'bright-green' selection_bg = 'bright-black' match_fg = 'bright-red' # preview diff --git a/themes/dracula.toml b/themes/dracula.toml index ed30dbc..76ea1a2 100644 --- a/themes/dracula.toml +++ b/themes/dracula.toml @@ -10,6 +10,7 @@ result_count_fg = '#FF5555' result_name_fg = '#BD93F9' result_line_number_fg = '#F1FA8C' result_value_fg = '#FF79C6' +selection_fg = '#50FA7B' selection_bg = '#44475A' match_fg = '#FF5555' # preview diff --git a/themes/gruvbox-dark.toml b/themes/gruvbox-dark.toml index 7898e04..961d78a 100644 --- a/themes/gruvbox-dark.toml +++ b/themes/gruvbox-dark.toml @@ -10,6 +10,7 @@ result_count_fg = '#cc241d' result_name_fg = '#83a598' result_line_number_fg = '#fabd2f' result_value_fg = '#ebdbb2' +selection_fg = '#b8bb26' selection_bg = '#32302f' match_fg = '#fb4934' # preview diff --git a/themes/gruvbox-light.toml b/themes/gruvbox-light.toml index 2056eee..1e4872c 100644 --- a/themes/gruvbox-light.toml +++ b/themes/gruvbox-light.toml @@ -10,6 +10,7 @@ result_count_fg = '#af3a03' result_name_fg = '#076678' result_line_number_fg = '#d79921' result_value_fg = '#665c54' +selection_fg = '#98971a' selection_bg = '#ebdbb2' match_fg = '#af3a03' # preview diff --git a/themes/monokai.toml b/themes/monokai.toml index f7252fe..780d0ef 100644 --- a/themes/monokai.toml +++ b/themes/monokai.toml @@ -10,6 +10,7 @@ result_count_fg = '#f92672' result_name_fg = '#a6e22e' result_line_number_fg = '#e5b567' result_value_fg = '#d6d6d6' +selection_fg = '#66d9ef' selection_bg = '#494949' match_fg = '#f92672' # preview diff --git a/themes/nord-dark.toml b/themes/nord-dark.toml index 5dc5764..87ca0d2 100644 --- a/themes/nord-dark.toml +++ b/themes/nord-dark.toml @@ -10,6 +10,7 @@ result_count_fg = '#bf616a' result_name_fg = '#81a1c1' result_line_number_fg = '#ebcb8b' result_value_fg = '#d8dee9' +selection_fg = '#a3be8c' selection_bg = '#3b4252' match_fg = '#bf616a' # preview diff --git a/themes/onedark.toml b/themes/onedark.toml index a3dd15e..d24198e 100644 --- a/themes/onedark.toml +++ b/themes/onedark.toml @@ -10,6 +10,7 @@ result_count_fg = '#e06c75' result_name_fg = '#61afef' result_line_number_fg = '#e5c07b' result_value_fg = '#abb2bf' +selection_fg = '#98c379' selection_bg = '#3e4452' match_fg = '#e06c75' # preview diff --git a/themes/solarized-dark.toml b/themes/solarized-dark.toml index a2fac07..619b8e0 100644 --- a/themes/solarized-dark.toml +++ b/themes/solarized-dark.toml @@ -10,6 +10,7 @@ result_count_fg = '#cb4b16' result_name_fg = '#268bd2' result_line_number_fg = '#b58900' result_value_fg = '#657b83' +selection_fg = '#859900' selection_bg = '#073642' match_fg = '#cb4b16' # preview diff --git a/themes/solarized-light.toml b/themes/solarized-light.toml index 2afe6c7..ce50b52 100644 --- a/themes/solarized-light.toml +++ b/themes/solarized-light.toml @@ -10,6 +10,7 @@ result_count_fg = '#cb4b16' result_name_fg = '#268bd2' result_line_number_fg = '#b58900' result_value_fg = '#93a1a1' +selection_fg = '#859900' selection_bg = '#eee8d5' match_fg = '#cb4b16' # preview diff --git a/themes/television.toml b/themes/television.toml index 99c0794..e522371 100644 --- a/themes/television.toml +++ b/themes/television.toml @@ -11,6 +11,7 @@ result_count_fg = '#d2788c' result_name_fg = '#9a9ac7' result_line_number_fg = '#d2a374' result_value_fg = '#646477' +selection_fg = '#8faf77' selection_bg = '#282830' match_fg = '#d2788c' # preview diff --git a/themes/tokyonight.toml b/themes/tokyonight.toml index 92caeb7..5e35251 100644 --- a/themes/tokyonight.toml +++ b/themes/tokyonight.toml @@ -10,6 +10,7 @@ result_count_fg = '#f7768e' result_name_fg = '#7aa2f7' result_line_number_fg = '#faba4a' result_value_fg = '#a9b1d6' +selection_fg = '#9ece6a' selection_bg = '#414868' match_fg = '#f7768e' # preview