feat(channels): add support for multi selection (#234)

Fixes #229 

<img width="1793" alt="Screenshot 2025-01-07 at 00 12 18"
src="https://github.com/user-attachments/assets/f2158c70-b3ab-4c14-8856-eb8463efef8a"
/>
This commit is contained in:
Alex Pasmantier 2025-01-07 00:13:05 +01:00 committed by GitHub
parent d20784891f
commit 2e5f65baef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 471 additions and 73 deletions

View File

@ -83,8 +83,12 @@ select_prev_page = "pageup"
# Scrolling the preview pane # Scrolling the preview pane
scroll_preview_half_page_down = "ctrl-d" scroll_preview_half_page_down = "ctrl-d"
scroll_preview_half_page_up = "ctrl-u" scroll_preview_half_page_up = "ctrl-u"
# Select an entry # Add entry to selection and move to the next entry
select_entry = "enter" 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 the selected entry to the clipboard
copy_entry_to_clipboard = "ctrl-y" copy_entry_to_clipboard = "ctrl-y"
# Toggle the remote control mode # Toggle the remote control mode

View File

@ -1,3 +1,5 @@
use std::collections::HashSet;
use crate::entry::Entry; use crate::entry::Entry;
use color_eyre::Result; use color_eyre::Result;
use television_derive::{Broadcast, ToCliChannel, ToUnitChannel}; use television_derive::{Broadcast, ToCliChannel, ToUnitChannel};
@ -65,6 +67,12 @@ pub trait OnAir: Send {
/// Get a specific result by its index. /// Get a specific result by its index.
fn get_result(&self, index: u32) -> Option<Entry>; fn get_result(&self, index: u32) -> Option<Entry>;
/// Get the currently selected entries.
fn selected_entries(&self) -> &HashSet<Entry>;
/// Toggles selection for the entry under the cursor.
fn toggle_selection(&mut self, entry: &Entry);
/// Get the number of results currently available. /// Get the number of results currently available.
fn result_count(&self) -> u32; fn result_count(&self) -> u32;

View File

@ -1,3 +1,5 @@
use std::collections::HashSet;
use crate::channels::OnAir; use crate::channels::OnAir;
use crate::entry::Entry; use crate::entry::Entry;
use crate::entry::PreviewType; use crate::entry::PreviewType;
@ -21,6 +23,7 @@ impl Alias {
pub struct Channel { pub struct Channel {
matcher: Matcher<Alias>, matcher: Matcher<Alias>,
file_icon: FileIcon, file_icon: FileIcon,
selected_entries: HashSet<Entry>,
} }
const NUM_THREADS: usize = 1; const NUM_THREADS: usize = 1;
@ -52,6 +55,7 @@ impl Channel {
Self { Self {
matcher, matcher,
file_icon: FileIcon::from(FILE_ICON_STR), file_icon: FileIcon::from(FILE_ICON_STR),
selected_entries: HashSet::new(),
} }
} }
} }
@ -115,6 +119,18 @@ impl OnAir for Channel {
}) })
} }
fn selected_entries(&self) -> &HashSet<Entry> {
&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 { fn result_count(&self) -> u32 {
self.matcher.matched_item_count self.matcher.matched_item_count
} }

View File

@ -26,6 +26,7 @@ pub struct Channel {
matcher: Matcher<String>, matcher: Matcher<String>,
entries_command: String, entries_command: String,
preview_kind: PreviewKind, preview_kind: PreviewKind,
selected_entries: HashSet<Entry>,
} }
impl Default for Channel { impl Default for Channel {
@ -93,6 +94,7 @@ impl Channel {
entries_command: entries_command.to_string(), entries_command: entries_command.to_string(),
preview_kind, preview_kind,
name: name.to_string(), name: name.to_string(),
selected_entries: HashSet::new(),
} }
} }
} }
@ -162,6 +164,18 @@ impl OnAir for Channel {
}) })
} }
fn selected_entries(&self) -> &HashSet<Entry> {
&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 { fn result_count(&self) -> u32 {
self.matcher.matched_item_count self.matcher.matched_item_count
} }

View File

@ -11,6 +11,7 @@ pub struct Channel {
crawl_handle: tokio::task::JoinHandle<()>, crawl_handle: tokio::task::JoinHandle<()>,
// PERF: cache results (to make deleting characters smoother) with // PERF: cache results (to make deleting characters smoother) with
// a shallow stack of sub-patterns as keys (e.g. "a", "ab", "abc") // a shallow stack of sub-patterns as keys (e.g. "a", "ab", "abc")
selected_entries: HashSet<Entry>,
} }
impl Channel { impl Channel {
@ -21,6 +22,7 @@ impl Channel {
Channel { Channel {
matcher, matcher,
crawl_handle, crawl_handle,
selected_entries: HashSet::new(),
} }
} }
} }
@ -104,6 +106,18 @@ impl OnAir for Channel {
}) })
} }
fn selected_entries(&self) -> &HashSet<Entry> {
&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 { fn result_count(&self) -> u32 {
self.matcher.matched_item_count self.matcher.matched_item_count
} }

View File

@ -1,3 +1,5 @@
use std::collections::HashSet;
use devicons::FileIcon; use devicons::FileIcon;
use super::OnAir; use super::OnAir;
@ -15,6 +17,7 @@ struct EnvVar {
pub struct Channel { pub struct Channel {
matcher: Matcher<EnvVar>, matcher: Matcher<EnvVar>,
file_icon: FileIcon, file_icon: FileIcon,
selected_entries: HashSet<Entry>,
} }
const NUM_THREADS: usize = 1; const NUM_THREADS: usize = 1;
@ -32,6 +35,7 @@ impl Channel {
Channel { Channel {
matcher, matcher,
file_icon: FileIcon::from(FILE_ICON_STR), file_icon: FileIcon::from(FILE_ICON_STR),
selected_entries: HashSet::new(),
} }
} }
} }
@ -95,6 +99,18 @@ impl OnAir for Channel {
}) })
} }
fn selected_entries(&self) -> &HashSet<Entry> {
&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 { fn result_count(&self) -> u32 {
self.matcher.matched_item_count self.matcher.matched_item_count
} }

View File

@ -11,6 +11,7 @@ pub struct Channel {
crawl_handle: tokio::task::JoinHandle<()>, crawl_handle: tokio::task::JoinHandle<()>,
// PERF: cache results (to make deleting characters smoother) with // PERF: cache results (to make deleting characters smoother) with
// a shallow stack of sub-patterns as keys (e.g. "a", "ab", "abc") // a shallow stack of sub-patterns as keys (e.g. "a", "ab", "abc")
selected_entries: HashSet<Entry>,
} }
impl Channel { impl Channel {
@ -21,6 +22,7 @@ impl Channel {
Channel { Channel {
matcher, matcher,
crawl_handle, crawl_handle,
selected_entries: HashSet::new(),
} }
} }
} }
@ -106,6 +108,18 @@ impl OnAir for Channel {
}) })
} }
fn selected_entries(&self) -> &HashSet<Entry> {
&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 { fn result_count(&self) -> u32 {
self.matcher.matched_item_count self.matcher.matched_item_count
} }

View File

@ -2,6 +2,7 @@ use devicons::FileIcon;
use directories::BaseDirs; use directories::BaseDirs;
use ignore::overrides::OverrideBuilder; use ignore::overrides::OverrideBuilder;
use lazy_static::lazy_static; use lazy_static::lazy_static;
use std::collections::HashSet;
use std::path::PathBuf; use std::path::PathBuf;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use tracing::debug; use tracing::debug;
@ -15,6 +16,7 @@ pub struct Channel {
matcher: Matcher<String>, matcher: Matcher<String>,
icon: FileIcon, icon: FileIcon,
crawl_handle: JoinHandle<()>, crawl_handle: JoinHandle<()>,
selected_entries: HashSet<Entry>,
} }
impl Channel { impl Channel {
@ -29,6 +31,7 @@ impl Channel {
matcher, matcher,
icon: FileIcon::from("git"), icon: FileIcon::from("git"),
crawl_handle, crawl_handle,
selected_entries: HashSet::new(),
} }
} }
} }
@ -81,6 +84,18 @@ impl OnAir for Channel {
}) })
} }
fn selected_entries(&self) -> &HashSet<Entry> {
&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 { fn result_count(&self) -> u32 {
self.matcher.matched_item_count self.matcher.matched_item_count
} }

View File

@ -1,3 +1,4 @@
use std::collections::HashSet;
use std::fmt::Display; use std::fmt::Display;
use crate::cable::{CableChannelPrototype, CableChannels}; use crate::cable::{CableChannelPrototype, CableChannels};
@ -13,6 +14,7 @@ use super::cable;
pub struct RemoteControl { pub struct RemoteControl {
matcher: Matcher<RCButton>, matcher: Matcher<RCButton>,
cable_channels: Option<CableChannels>, cable_channels: Option<CableChannels>,
selected_entries: HashSet<Entry>,
} }
#[derive(Clone)] #[derive(Clone)]
@ -59,6 +61,7 @@ impl RemoteControl {
RemoteControl { RemoteControl {
matcher, matcher,
cable_channels, cable_channels,
selected_entries: HashSet::new(),
} }
} }
@ -140,6 +143,13 @@ impl OnAir for RemoteControl {
.collect() .collect()
} }
fn selected_entries(&self) -> &HashSet<Entry> {
&self.selected_entries
}
#[allow(unused_variables)]
fn toggle_selection(&mut self, entry: &Entry) {}
fn get_result(&self, index: u32) -> Option<Entry> { fn get_result(&self, index: u32) -> Option<Entry> {
self.matcher.get_result(index).map(|item| { self.matcher.get_result(index).map(|item| {
let path = item.matched_string; let path = item.matched_string;

View File

@ -1,4 +1,5 @@
use std::{ use std::{
collections::HashSet,
io::{stdin, BufRead}, io::{stdin, BufRead},
thread::spawn, thread::spawn,
}; };
@ -12,6 +13,7 @@ use television_fuzzy::matcher::{config::Config, injector::Injector, Matcher};
pub struct Channel { pub struct Channel {
matcher: Matcher<String>, matcher: Matcher<String>,
preview_type: PreviewType, preview_type: PreviewType,
selected_entries: HashSet<Entry>,
} }
impl Channel { impl Channel {
@ -24,6 +26,7 @@ impl Channel {
Self { Self {
matcher, matcher,
preview_type: preview_type.unwrap_or_default(), preview_type: preview_type.unwrap_or_default(),
selected_entries: HashSet::new(),
} }
} }
} }
@ -90,6 +93,18 @@ impl OnAir for Channel {
}) })
} }
fn selected_entries(&self) -> &HashSet<Entry> {
&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 { fn result_count(&self) -> u32 {
self.matcher.matched_item_count self.matcher.matched_item_count
} }

View File

@ -3,6 +3,7 @@ use crate::entry::{Entry, PreviewType};
use devicons::FileIcon; use devicons::FileIcon;
use ignore::WalkState; use ignore::WalkState;
use std::{ use std::{
collections::HashSet,
fs::File, fs::File,
io::{BufRead, Read, Seek}, io::{BufRead, Read, Seek},
path::{Path, PathBuf}, path::{Path, PathBuf},
@ -36,6 +37,7 @@ impl CandidateLine {
pub struct Channel { pub struct Channel {
matcher: Matcher<CandidateLine>, matcher: Matcher<CandidateLine>,
crawl_handle: tokio::task::JoinHandle<()>, crawl_handle: tokio::task::JoinHandle<()>,
selected_entries: HashSet<Entry>,
} }
impl Channel { impl Channel {
@ -49,6 +51,7 @@ impl Channel {
Channel { Channel {
matcher, matcher,
crawl_handle, crawl_handle,
selected_entries: HashSet::new(),
} }
} }
@ -73,6 +76,7 @@ impl Channel {
Channel { Channel {
matcher, matcher,
crawl_handle, crawl_handle,
selected_entries: HashSet::new(),
} }
} }
@ -97,6 +101,7 @@ impl Channel {
Channel { Channel {
matcher, matcher,
crawl_handle: load_handle, crawl_handle: load_handle,
selected_entries: HashSet::new(),
} }
} }
} }
@ -195,6 +200,18 @@ impl OnAir for Channel {
}) })
} }
fn selected_entries(&self) -> &HashSet<Entry> {
&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 { fn result_count(&self) -> u32 {
self.matcher.matched_item_count self.matcher.matched_item_count
} }

View File

@ -1,4 +1,8 @@
use std::{fmt::Display, path::PathBuf}; use std::{
fmt::Display,
hash::{Hash, Hasher},
path::PathBuf,
};
use devicons::FileIcon; use devicons::FileIcon;
use strum::EnumString; use strum::EnumString;
@ -9,7 +13,7 @@ use strum::EnumString;
// channel convertible from any other that yields `EntryType`. // channel convertible from any other that yields `EntryType`.
// This needs pondering since it does bring another level of abstraction and // This needs pondering since it does bring another level of abstraction and
// adds a layer of complexity. // adds a layer of complexity.
#[derive(Clone, Debug, Eq, PartialEq, Hash)] #[derive(Clone, Debug, Eq)]
pub struct Entry { pub struct Entry {
/// The name of the entry. /// The name of the entry.
pub name: String, pub name: String,
@ -27,6 +31,31 @@ pub struct Entry {
pub preview_type: PreviewType, pub preview_type: PreviewType,
} }
impl Hash for Entry {
fn hash<H: Hasher>(&self, state: &mut H) {
self.name.hash(state);
if let Some(line_number) = self.line_number {
line_number.hash(state);
}
}
}
impl PartialEq<Entry> 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<Entry> 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)] #[allow(clippy::needless_return)]
pub fn merge_ranges(ranges: &[(u32, u32)]) -> Vec<(u32, u32)> { pub fn merge_ranges(ranges: &[(u32, u32)]) -> Vec<(u32, u32)> {
ranges.iter().fold( ranges.iter().fold(

View File

@ -213,6 +213,26 @@ fn impl_tv_channel(ast: &syn::DeriveInput) -> TokenStream {
} }
} }
fn selected_entries(&self) -> &HashSet<Entry> {
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 { fn result_count(&self) -> u32 {
match self { match self {
#( #(

View File

@ -28,6 +28,7 @@ pub struct ResultsColorscheme {
pub result_preview_fg: Color, pub result_preview_fg: Color,
pub result_line_number_fg: Color, pub result_line_number_fg: Color,
pub result_selected_bg: Color, pub result_selected_bg: Color,
pub result_selected_fg: Color,
pub match_foreground_color: Color, pub match_foreground_color: Color,
} }

View File

@ -55,7 +55,9 @@ pub fn draw_input_box(
Constraint::Fill(1), Constraint::Fill(1),
// result count // result count
Constraint::Length( 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 // spinner
Constraint::Length(1), Constraint::Length(1),

View File

@ -81,6 +81,7 @@ fn draw_rc_channels(
let channel_list = build_results_list( let channel_list = build_results_list(
rc_block, rc_block,
entries, entries,
None,
ListDirection::TopToBottom, ListDirection::TopToBottom,
use_nerd_font_icons, use_nerd_font_icons,
icon_color_cache, icon_color_cache,

View File

@ -8,7 +8,7 @@ use ratatui::widgets::{
Block, BorderType, Borders, List, ListDirection, ListState, Padding, Block, BorderType, Borders, List, ListDirection, ListState, Padding,
}; };
use ratatui::Frame; use ratatui::Frame;
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use std::str::FromStr; use std::str::FromStr;
use television_channels::entry::Entry; use television_channels::entry::Entry;
use television_utils::strings::{ use television_utils::strings::{
@ -16,9 +16,14 @@ use television_utils::strings::{
slice_at_char_boundaries, slice_at_char_boundaries,
}; };
const POINTER_SYMBOL: &str = "> ";
const SELECTED_SYMBOL: &str = "";
const DESLECTED_SYMBOL: &str = " ";
pub fn build_results_list<'a, 'b>( pub fn build_results_list<'a, 'b>(
results_block: Block<'b>, results_block: Block<'b>,
entries: &'a [Entry], entries: &'a [Entry],
selected_entries: Option<&HashSet<Entry>>,
list_direction: ListDirection, list_direction: ListDirection,
use_icons: bool, use_icons: bool,
icon_color_cache: &mut HashMap<String, Color>, icon_color_cache: &mut HashMap<String, Color>,
@ -29,6 +34,19 @@ where
{ {
List::new(entries.iter().map(|entry| { List::new(entries.iter().map(|entry| {
let mut spans = Vec::new(); 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 // optional icon
if let Some(icon) = entry.icon.as_ref() { if let Some(icon) = entry.icon.as_ref() {
if use_icons { if use_icons {
@ -129,7 +147,7 @@ where
.highlight_style( .highlight_style(
Style::default().bg(colorscheme.result_selected_bg).bold(), Style::default().bg(colorscheme.result_selected_bg).bold(),
) )
.highlight_symbol("> ") .highlight_symbol(POINTER_SYMBOL)
.block(results_block) .block(results_block)
} }
@ -138,6 +156,7 @@ pub fn draw_results_list(
f: &mut Frame, f: &mut Frame,
rect: Rect, rect: Rect,
entries: &[Entry], entries: &[Entry],
selected_entries: &HashSet<Entry>,
relative_picker_state: &mut ListState, relative_picker_state: &mut ListState,
input_bar_position: InputPosition, input_bar_position: InputPosition,
use_nerd_font_icons: bool, use_nerd_font_icons: bool,
@ -166,6 +185,7 @@ pub fn draw_results_list(
let results_list = build_results_list( let results_list = build_results_list(
results_block, results_block,
entries, entries,
Some(selected_entries),
match input_bar_position { match input_bar_position {
InputPosition::Bottom => ListDirection::BottomToTop, InputPosition::Bottom => ListDirection::BottomToTop,
InputPosition::Top => ListDirection::TopToBottom, InputPosition::Top => ListDirection::TopToBottom,

View File

@ -39,9 +39,16 @@ pub enum Action {
#[serde(skip)] #[serde(skip)]
ClearScreen, ClearScreen,
// results actions // 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")] #[serde(alias = "select_entry")]
SelectEntry, #[serde(alias = "confirm_selection")]
ConfirmSelection,
/// Select the entry currently under the cursor and pass the key that was pressed /// Select the entry currently under the cursor and pass the key that was pressed
/// through to be handled the parent process. /// through to be handled the parent process.
#[serde(alias = "select_passthrough")] #[serde(alias = "select_passthrough")]

View File

@ -1,3 +1,4 @@
use std::collections::HashSet;
use std::sync::Arc; use std::sync::Arc;
use color_eyre::Result; use color_eyre::Result;
@ -44,36 +45,36 @@ pub struct App {
/// The outcome of an action. /// The outcome of an action.
#[derive(Debug)] #[derive(Debug)]
pub enum ActionOutcome { pub enum ActionOutcome {
Entry(Entry), Entries(HashSet<Entry>),
Input(String), Input(String),
Passthrough(Entry, String), Passthrough(HashSet<Entry>, String),
None, None,
} }
/// The result of the application. /// The result of the application.
#[derive(Debug)] #[derive(Debug)]
pub struct AppOutput { pub struct AppOutput {
pub selected_entry: Option<Entry>, pub selected_entries: Option<HashSet<Entry>>,
pub passthrough: Option<String>, pub passthrough: Option<String>,
} }
impl From<ActionOutcome> for AppOutput { impl From<ActionOutcome> for AppOutput {
fn from(outcome: ActionOutcome) -> Self { fn from(outcome: ActionOutcome) -> Self {
match outcome { match outcome {
ActionOutcome::Entry(entry) => Self { ActionOutcome::Entries(entries) => Self {
selected_entry: Some(entry), selected_entries: Some(entries),
passthrough: None, passthrough: None,
}, },
ActionOutcome::Input(input) => Self { ActionOutcome::Input(input) => Self {
selected_entry: None, selected_entries: None,
passthrough: Some(input), passthrough: Some(input),
}, },
ActionOutcome::Passthrough(entry, key) => Self { ActionOutcome::Passthrough(entries, key) => Self {
selected_entry: Some(entry), selected_entries: Some(entries),
passthrough: Some(key), passthrough: Some(key),
}, },
ActionOutcome::None => Self { ActionOutcome::None => Self {
selected_entry: None, selected_entries: None,
passthrough: None, passthrough: None,
}, },
} }
@ -262,10 +263,13 @@ impl App {
Action::SelectAndExit => { Action::SelectAndExit => {
self.should_quit = true; self.should_quit = true;
self.render_tx.send(RenderingTask::Quit)?; self.render_tx.send(RenderingTask::Quit)?;
if let Some(entry) = if let Some(entries) = self
self.television.lock().await.get_selected_entry(None) .television
.lock()
.await
.get_selected_entries(Some(Mode::Channel))
{ {
return Ok(ActionOutcome::Entry(entry)); return Ok(ActionOutcome::Entries(entries));
} }
return Ok(ActionOutcome::Input( return Ok(ActionOutcome::Input(
self.television.lock().await.current_pattern.clone(), self.television.lock().await.current_pattern.clone(),
@ -274,11 +278,14 @@ impl App {
Action::SelectPassthrough(passthrough) => { Action::SelectPassthrough(passthrough) => {
self.should_quit = true; self.should_quit = true;
self.render_tx.send(RenderingTask::Quit)?; self.render_tx.send(RenderingTask::Quit)?;
if let Some(entry) = if let Some(entries) = self
self.television.lock().await.get_selected_entry(None) .television
.lock()
.await
.get_selected_entries(Some(Mode::Channel))
{ {
return Ok(ActionOutcome::Passthrough( return Ok(ActionOutcome::Passthrough(
entry, entries,
passthrough, passthrough,
)); ));
} }

View File

@ -102,6 +102,7 @@ pub struct Theme {
pub result_line_number_fg: Color, pub result_line_number_fg: Color,
pub result_value_fg: Color, pub result_value_fg: Color,
pub selection_bg: Color, pub selection_bg: Color,
pub selection_fg: Color,
pub match_fg: Color, pub match_fg: Color,
// preview // preview
pub preview_title_fg: Color, pub preview_title_fg: Color,
@ -170,6 +171,9 @@ struct Inner {
result_line_number_fg: String, result_line_number_fg: String,
result_value_fg: String, result_value_fg: String,
selection_bg: String, selection_bg: String,
// this is made optional for theme backwards compatibility
// and falls back to match_fg
selection_fg: Option<String>,
match_fg: String, match_fg: String,
//preview //preview
preview_title_fg: String, preview_title_fg: String,
@ -190,44 +194,129 @@ impl<'de> Deserialize<'de> for Theme {
.background .background
.map(|s| { .map(|s| {
Color::from_str(&s).ok_or_else(|| { Color::from_str(&s).ok_or_else(|| {
serde::de::Error::custom("invalid color") serde::de::Error::custom(format!(
"invalid color {}",
s
))
}) })
}) })
.transpose()?, .transpose()?,
border_fg: Color::from_str(&inner.border_fg) border_fg: Color::from_str(&inner.border_fg).ok_or_else(|| {
.ok_or_else(|| serde::de::Error::custom("invalid color"))?, serde::de::Error::custom(format!(
text_fg: Color::from_str(&inner.text_fg) "invalid color {}",
.ok_or_else(|| serde::de::Error::custom("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) dimmed_text_fg: Color::from_str(&inner.dimmed_text_fg)
.ok_or_else(|| serde::de::Error::custom("invalid color"))?, .ok_or_else(|| {
input_text_fg: Color::from_str(&inner.input_text_fg) serde::de::Error::custom(format!(
.ok_or_else(|| serde::de::Error::custom("invalid color"))?, "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) 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) 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( result_line_number_fg: Color::from_str(
&inner.result_line_number_fg, &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) result_value_fg: Color::from_str(&inner.result_value_fg)
.ok_or_else(|| serde::de::Error::custom("invalid color"))?, .ok_or_else(|| {
selection_bg: Color::from_str(&inner.selection_bg) serde::de::Error::custom(format!(
.ok_or_else(|| serde::de::Error::custom("invalid color"))?, "invalid color {}",
match_fg: Color::from_str(&inner.match_fg) &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(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) 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) 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( remote_control_mode_fg: Color::from_str(
&inner.remote_control_mode_fg, &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( send_to_channel_mode_fg: Color::from_str(
&inner.send_to_channel_mode_fg, &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<ResultsColorscheme> for &Theme {
result_preview_fg: (&self.result_value_fg).into(), result_preview_fg: (&self.result_value_fg).into(),
result_line_number_fg: (&self.result_line_number_fg).into(), result_line_number_fg: (&self.result_line_number_fg).into(),
result_selected_bg: (&self.selection_bg).into(), result_selected_bg: (&self.selection_bg).into(),
result_selected_fg: (&self.selection_fg).into(),
match_foreground_color: (&self.match_fg).into(), match_foreground_color: (&self.match_fg).into(),
} }
} }
@ -371,6 +461,7 @@ mod tests {
result_line_number_fg = "bright-white" result_line_number_fg = "bright-white"
result_value_fg = "bright-white" result_value_fg = "bright-white"
selection_bg = "bright-white" selection_bg = "bright-white"
selection_fg = "bright-white"
match_fg = "bright-white" match_fg = "bright-white"
preview_title_fg = "bright-white" preview_title_fg = "bright-white"
channel_mode_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.result_value_fg, Color::Ansi(ANSIColor::BrightWhite));
assert_eq!(theme.selection_bg, 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.match_fg, Color::Ansi(ANSIColor::BrightWhite));
assert_eq!( assert_eq!(
theme.preview_title_fg, theme.preview_title_fg,
@ -422,6 +514,7 @@ mod tests {
result_line_number_fg = "#ffffff" result_line_number_fg = "#ffffff"
result_value_fg = "bright-white" result_value_fg = "bright-white"
selection_bg = "bright-white" selection_bg = "bright-white"
selection_fg = "bright-white"
match_fg = "bright-white" match_fg = "bright-white"
preview_title_fg = "bright-white" preview_title_fg = "bright-white"
channel_mode_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.result_value_fg, Color::Ansi(ANSIColor::BrightWhite));
assert_eq!(theme.selection_bg, 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.match_fg, Color::Ansi(ANSIColor::BrightWhite));
assert_eq!( assert_eq!(
theme.preview_title_fg, theme.preview_title_fg,

View File

@ -1,5 +1,5 @@
use std::env; use std::env;
use std::io::{stdout, IsTerminal, Write}; use std::io::{stdout, BufWriter, IsTerminal, Write};
use std::path::Path; use std::path::Path;
use std::process::exit; use std::process::exit;
@ -119,12 +119,18 @@ async fn main() -> Result<()> {
stdout().flush()?; stdout().flush()?;
let output = app.run(stdout().is_terminal()).await?; let output = app.run(stdout().is_terminal()).await?;
info!("{:?}", output); info!("{:?}", output);
// lock stdout
let stdout_handle = stdout().lock();
let mut bufwriter = BufWriter::new(stdout_handle);
if let Some(passthrough) = output.passthrough { if let Some(passthrough) = output.passthrough {
writeln!(stdout(), "{passthrough}")?; writeln!(bufwriter, "{passthrough}")?;
} }
if let Some(entry) = output.selected_entry { if let Some(entries) = output.selected_entries {
writeln!(stdout(), "{}", entry.stdout_repr())?; for entry in &entries {
writeln!(bufwriter, "{}", entry.stdout_repr())?;
}
} }
bufwriter.flush()?;
exit(0); exit(0);
} }
Err(err) => { Err(err) => {

View File

@ -6,7 +6,7 @@ use crate::{cable::load_cable_channels, keymap::Keymap};
use color_eyre::Result; use color_eyre::Result;
use copypasta::{ClipboardContext, ClipboardProvider}; use copypasta::{ClipboardContext, ClipboardProvider};
use ratatui::{layout::Rect, style::Color, Frame}; use ratatui::{layout::Rect, style::Color, Frame};
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use television_channels::channels::{ use television_channels::channels::{
remote_control::{load_builtin_channels, RemoteControl}, remote_control::{load_builtin_channels, RemoteControl},
@ -148,17 +148,40 @@ impl Television {
#[must_use] #[must_use]
pub fn get_selected_entry(&mut self, mode: Option<Mode>) -> Option<Entry> { pub fn get_selected_entry(&mut self, mode: Option<Mode>) -> Option<Entry> {
match mode.unwrap_or(self.mode) { match mode.unwrap_or(self.mode) {
Mode::Channel => self.results_picker.selected().and_then(|i| { Mode::Channel => {
self.channel.get_result(u32::try_from(i).unwrap()) if let Some(i) = self.results_picker.selected() {
}), return self.channel.get_result(i.try_into().unwrap());
}
None
}
Mode::RemoteControl | Mode::SendToChannel => { Mode::RemoteControl | Mode::SendToChannel => {
self.rc_picker.selected().and_then(|i| { if let Some(i) = self.rc_picker.selected() {
self.remote_control.get_result(u32::try_from(i).unwrap()) return self
}) .remote_control
.get_result(i.try_into().unwrap());
}
None
} }
} }
} }
#[must_use]
pub fn get_selected_entries(
&mut self,
mode: Option<Mode>,
) -> Option<HashSet<Entry>> {
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) { pub fn select_prev_entry(&mut self, step: u32) {
let (result_count, picker) = match self.mode { let (result_count, picker) = match self.mode {
Mode::Channel => { Mode::Channel => {
@ -334,15 +357,30 @@ impl Television {
} }
Mode::SendToChannel => {} Mode::SendToChannel => {}
}, },
Action::SelectEntry => { Action::ToggleSelectionDown | Action::ToggleSelectionUp => {
if let Some(entry) = self.get_selected_entry(None) { if matches!(self.mode, Mode::Channel) {
match self.mode { if let Some(entry) = self.get_selected_entry(None) {
Mode::Channel => self self.channel.toggle_selection(&entry);
.action_tx 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() .as_ref()
.unwrap() .unwrap()
.send(Action::SelectAndExit)?, .send(Action::SelectAndExit)?;
Mode::RemoteControl => { }
Mode::RemoteControl => {
if let Some(entry) =
self.get_selected_entry(Some(Mode::RemoteControl))
{
let new_channel = self let new_channel = self
.remote_control .remote_control
.zap(entry.name.as_str())?; .zap(entry.name.as_str())?;
@ -353,7 +391,11 @@ impl Television {
self.mode = Mode::Channel; self.mode = Mode::Channel;
self.change_channel(new_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( let new_channel = self.channel.transition_to(
entry.name.as_str().try_into().unwrap(), entry.name.as_str().try_into().unwrap(),
); );
@ -364,18 +406,22 @@ impl Television {
self.change_channel(new_channel); self.change_channel(new_channel);
} }
} }
} else {
self.action_tx
.as_ref()
.unwrap()
.send(Action::SelectAndExit)?;
} }
} }
Action::CopyEntryToClipboard => { Action::CopyEntryToClipboard => {
if self.mode == Mode::Channel { 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(); let mut ctx = ClipboardContext::new().unwrap();
ctx.set_contents(entry.name).unwrap(); ctx.set_contents(
entries
.iter()
.map(|e| e.name.clone())
.collect::<Vec<_>>()
.join(" ")
.to_string()
.to_string(),
)
.unwrap();
} }
} }
} }
@ -464,6 +510,7 @@ impl Television {
f, f,
layout.results, layout.results,
&entries, &entries,
self.channel.selected_entries(),
&mut self.results_picker.relative_state, &mut self.results_picker.relative_state,
self.config.ui.input_bar_position, self.config.ui.input_bar_position,
self.config.ui.use_nerd_font_icons, self.config.ui.use_nerd_font_icons,
@ -596,7 +643,10 @@ impl KeyBindings {
), ),
( (
DisplayableAction::SelectEntry, DisplayableAction::SelectEntry,
serialized_keys_for_actions(self, &[Action::SelectEntry]), serialized_keys_for_actions(
self,
&[Action::ConfirmSelection],
),
), ),
( (
DisplayableAction::CopyEntryToClipboard, DisplayableAction::CopyEntryToClipboard,
@ -637,7 +687,10 @@ impl KeyBindings {
), ),
( (
DisplayableAction::SelectEntry, DisplayableAction::SelectEntry,
serialized_keys_for_actions(self, &[Action::SelectEntry]), serialized_keys_for_actions(
self,
&[Action::ConfirmSelection],
),
), ),
( (
DisplayableAction::ToggleRemoteControl, DisplayableAction::ToggleRemoteControl,
@ -660,7 +713,10 @@ impl KeyBindings {
), ),
( (
DisplayableAction::SelectEntry, DisplayableAction::SelectEntry,
serialized_keys_for_actions(self, &[Action::SelectEntry]), serialized_keys_for_actions(
self,
&[Action::ConfirmSelection],
),
), ),
( (
DisplayableAction::Cancel, DisplayableAction::Cancel,

View File

@ -10,6 +10,7 @@ result_count_fg = '#f38ba8'
result_name_fg = '#89b4fa' result_name_fg = '#89b4fa'
result_line_number_fg = '#f9e2af' result_line_number_fg = '#f9e2af'
result_value_fg = '#b4befe' result_value_fg = '#b4befe'
selection_fg = '#a6e3a1'
selection_bg = '#313244' selection_bg = '#313244'
match_fg = '#f38ba8' match_fg = '#f38ba8'
# preview # preview

View File

@ -10,6 +10,7 @@ result_count_fg = 'bright-red'
result_name_fg = 'bright-blue' result_name_fg = 'bright-blue'
result_line_number_fg = 'bright-yellow' result_line_number_fg = 'bright-yellow'
result_value_fg = 'white' result_value_fg = 'white'
selection_fg = 'bright-green'
selection_bg = 'bright-black' selection_bg = 'bright-black'
match_fg = 'bright-red' match_fg = 'bright-red'
# preview # preview

View File

@ -10,6 +10,7 @@ result_count_fg = '#FF5555'
result_name_fg = '#BD93F9' result_name_fg = '#BD93F9'
result_line_number_fg = '#F1FA8C' result_line_number_fg = '#F1FA8C'
result_value_fg = '#FF79C6' result_value_fg = '#FF79C6'
selection_fg = '#50FA7B'
selection_bg = '#44475A' selection_bg = '#44475A'
match_fg = '#FF5555' match_fg = '#FF5555'
# preview # preview

View File

@ -10,6 +10,7 @@ result_count_fg = '#cc241d'
result_name_fg = '#83a598' result_name_fg = '#83a598'
result_line_number_fg = '#fabd2f' result_line_number_fg = '#fabd2f'
result_value_fg = '#ebdbb2' result_value_fg = '#ebdbb2'
selection_fg = '#b8bb26'
selection_bg = '#32302f' selection_bg = '#32302f'
match_fg = '#fb4934' match_fg = '#fb4934'
# preview # preview

View File

@ -10,6 +10,7 @@ result_count_fg = '#af3a03'
result_name_fg = '#076678' result_name_fg = '#076678'
result_line_number_fg = '#d79921' result_line_number_fg = '#d79921'
result_value_fg = '#665c54' result_value_fg = '#665c54'
selection_fg = '#98971a'
selection_bg = '#ebdbb2' selection_bg = '#ebdbb2'
match_fg = '#af3a03' match_fg = '#af3a03'
# preview # preview

View File

@ -10,6 +10,7 @@ result_count_fg = '#f92672'
result_name_fg = '#a6e22e' result_name_fg = '#a6e22e'
result_line_number_fg = '#e5b567' result_line_number_fg = '#e5b567'
result_value_fg = '#d6d6d6' result_value_fg = '#d6d6d6'
selection_fg = '#66d9ef'
selection_bg = '#494949' selection_bg = '#494949'
match_fg = '#f92672' match_fg = '#f92672'
# preview # preview

View File

@ -10,6 +10,7 @@ result_count_fg = '#bf616a'
result_name_fg = '#81a1c1' result_name_fg = '#81a1c1'
result_line_number_fg = '#ebcb8b' result_line_number_fg = '#ebcb8b'
result_value_fg = '#d8dee9' result_value_fg = '#d8dee9'
selection_fg = '#a3be8c'
selection_bg = '#3b4252' selection_bg = '#3b4252'
match_fg = '#bf616a' match_fg = '#bf616a'
# preview # preview

View File

@ -10,6 +10,7 @@ result_count_fg = '#e06c75'
result_name_fg = '#61afef' result_name_fg = '#61afef'
result_line_number_fg = '#e5c07b' result_line_number_fg = '#e5c07b'
result_value_fg = '#abb2bf' result_value_fg = '#abb2bf'
selection_fg = '#98c379'
selection_bg = '#3e4452' selection_bg = '#3e4452'
match_fg = '#e06c75' match_fg = '#e06c75'
# preview # preview

View File

@ -10,6 +10,7 @@ result_count_fg = '#cb4b16'
result_name_fg = '#268bd2' result_name_fg = '#268bd2'
result_line_number_fg = '#b58900' result_line_number_fg = '#b58900'
result_value_fg = '#657b83' result_value_fg = '#657b83'
selection_fg = '#859900'
selection_bg = '#073642' selection_bg = '#073642'
match_fg = '#cb4b16' match_fg = '#cb4b16'
# preview # preview

View File

@ -10,6 +10,7 @@ result_count_fg = '#cb4b16'
result_name_fg = '#268bd2' result_name_fg = '#268bd2'
result_line_number_fg = '#b58900' result_line_number_fg = '#b58900'
result_value_fg = '#93a1a1' result_value_fg = '#93a1a1'
selection_fg = '#859900'
selection_bg = '#eee8d5' selection_bg = '#eee8d5'
match_fg = '#cb4b16' match_fg = '#cb4b16'
# preview # preview

View File

@ -11,6 +11,7 @@ result_count_fg = '#d2788c'
result_name_fg = '#9a9ac7' result_name_fg = '#9a9ac7'
result_line_number_fg = '#d2a374' result_line_number_fg = '#d2a374'
result_value_fg = '#646477' result_value_fg = '#646477'
selection_fg = '#8faf77'
selection_bg = '#282830' selection_bg = '#282830'
match_fg = '#d2788c' match_fg = '#d2788c'
# preview # preview

View File

@ -10,6 +10,7 @@ result_count_fg = '#f7768e'
result_name_fg = '#7aa2f7' result_name_fg = '#7aa2f7'
result_line_number_fg = '#faba4a' result_line_number_fg = '#faba4a'
result_value_fg = '#a9b1d6' result_value_fg = '#a9b1d6'
selection_fg = '#9ece6a'
selection_bg = '#414868' selection_bg = '#414868'
match_fg = '#f7768e' match_fg = '#f7768e'
# preview # preview