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
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

View File

@ -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<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.
fn result_count(&self) -> u32;

View File

@ -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<Alias>,
file_icon: FileIcon,
selected_entries: HashSet<Entry>,
}
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<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 {
self.matcher.matched_item_count
}

View File

@ -26,6 +26,7 @@ pub struct Channel {
matcher: Matcher<String>,
entries_command: String,
preview_kind: PreviewKind,
selected_entries: HashSet<Entry>,
}
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<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 {
self.matcher.matched_item_count
}

View File

@ -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<Entry>,
}
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<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 {
self.matcher.matched_item_count
}

View File

@ -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<EnvVar>,
file_icon: FileIcon,
selected_entries: HashSet<Entry>,
}
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<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 {
self.matcher.matched_item_count
}

View File

@ -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<Entry>,
}
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<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 {
self.matcher.matched_item_count
}

View File

@ -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<String>,
icon: FileIcon,
crawl_handle: JoinHandle<()>,
selected_entries: HashSet<Entry>,
}
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<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 {
self.matcher.matched_item_count
}

View File

@ -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<RCButton>,
cable_channels: Option<CableChannels>,
selected_entries: HashSet<Entry>,
}
#[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<Entry> {
&self.selected_entries
}
#[allow(unused_variables)]
fn toggle_selection(&mut self, entry: &Entry) {}
fn get_result(&self, index: u32) -> Option<Entry> {
self.matcher.get_result(index).map(|item| {
let path = item.matched_string;

View File

@ -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<String>,
preview_type: PreviewType,
selected_entries: HashSet<Entry>,
}
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<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 {
self.matcher.matched_item_count
}

View File

@ -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<CandidateLine>,
crawl_handle: tokio::task::JoinHandle<()>,
selected_entries: HashSet<Entry>,
}
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<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 {
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 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<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)]
pub fn merge_ranges(ranges: &[(u32, u32)]) -> Vec<(u32, u32)> {
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 {
match self {
#(

View File

@ -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,
}

View File

@ -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),

View File

@ -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,

View File

@ -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<Entry>>,
list_direction: ListDirection,
use_icons: bool,
icon_color_cache: &mut HashMap<String, Color>,
@ -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<Entry>,
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,

View File

@ -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")]

View File

@ -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<Entry>),
Input(String),
Passthrough(Entry, String),
Passthrough(HashSet<Entry>, String),
None,
}
/// The result of the application.
#[derive(Debug)]
pub struct AppOutput {
pub selected_entry: Option<Entry>,
pub selected_entries: Option<HashSet<Entry>>,
pub passthrough: Option<String>,
}
impl From<ActionOutcome> 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,
));
}

View File

@ -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<String>,
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<ResultsColorscheme> 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,

View File

@ -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) => {

View File

@ -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<Mode>) -> Option<Entry> {
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<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) {
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::<Vec<_>>()
.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,

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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