From 2e5f65baefd7ce10dcb6aa85fd41158f86c6dfcd Mon Sep 17 00:00:00 2001
From: Alex Pasmantier <47638216+alexpasmantier@users.noreply.github.com>
Date: Tue, 7 Jan 2025 00:13:05 +0100
Subject: [PATCH] feat(channels): add support for multi selection (#234)
Fixes #229
---
.config/config.toml | 8 +-
crates/television-channels/src/channels.rs | 8 ++
.../television-channels/src/channels/alias.rs | 16 +++
.../television-channels/src/channels/cable.rs | 14 ++
.../television-channels/src/channels/dirs.rs | 14 ++
.../television-channels/src/channels/env.rs | 16 +++
.../television-channels/src/channels/files.rs | 14 ++
.../src/channels/git_repos.rs | 15 ++
.../src/channels/remote_control.rs | 10 ++
.../television-channels/src/channels/stdin.rs | 15 ++
.../television-channels/src/channels/text.rs | 17 +++
crates/television-channels/src/entry.rs | 33 ++++-
crates/television-derive/src/lib.rs | 20 +++
crates/television-screen/src/colors.rs | 1 +
crates/television-screen/src/input.rs | 4 +-
.../television-screen/src/remote_control.rs | 1 +
crates/television-screen/src/results.rs | 24 +++-
crates/television/action.rs | 11 +-
crates/television/app.rs | 37 +++--
crates/television/config/themes.rs | 134 +++++++++++++++---
crates/television/main.rs | 14 +-
crates/television/television.rs | 106 ++++++++++----
themes/catppuccin.toml | 1 +
themes/default.toml | 1 +
themes/dracula.toml | 1 +
themes/gruvbox-dark.toml | 1 +
themes/gruvbox-light.toml | 1 +
themes/monokai.toml | 1 +
themes/nord-dark.toml | 1 +
themes/onedark.toml | 1 +
themes/solarized-dark.toml | 1 +
themes/solarized-light.toml | 1 +
themes/television.toml | 1 +
themes/tokyonight.toml | 1 +
34 files changed, 471 insertions(+), 73 deletions(-)
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