From c93ddeeadbdb88d621ea00355cec1620b6382631 Mon Sep 17 00:00:00 2001 From: alexandre pasmantier Date: Fri, 25 Jul 2025 17:39:44 +0200 Subject: [PATCH] feat(cli): registering transparent selection keys for tv Fixes #468 --- television/action.rs | 10 +++-- television/app.rs | 33 +++++++++++++++ television/cli/args.rs | 11 +++++ television/cli/mod.rs | 71 ++++++++++++++++++++++++++++++++- television/main.rs | 3 ++ television/screen/help_panel.rs | 2 +- tests/cli/cli_selection.rs | 28 +++++++++++++ tests/common/mod.rs | 6 +-- 8 files changed, 154 insertions(+), 10 deletions(-) diff --git a/television/action.rs b/television/action.rs index 6f051ae..9198aed 100644 --- a/television/action.rs +++ b/television/action.rs @@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize}; use serde_with::{OneOrMany, serde_as}; use std::fmt::Display; +use crate::event::Key; + /// The different actions that can be performed by the application. #[derive( Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash, PartialOrd, Ord, @@ -47,6 +49,8 @@ pub enum Action { ConfirmSelection, /// Select the entry currently under the cursor and exit the application. SelectAndExit, + /// Confirm selection using one of the `expect` keys. + Expect(Key), /// Select the next entry in the currently focused list. SelectNextEntry, /// Select the previous entry in the currently focused list. @@ -93,8 +97,6 @@ pub enum Action { #[serde(skip)] NoOp, // Channel actions - /// FIXME: clean this up - ToggleSendToChannel, /// Toggle between different source commands. CycleSources, /// Reload the current source command. @@ -326,6 +328,7 @@ impl Display for Action { Action::ToggleSelectionUp => write!(f, "toggle_selection_up"), Action::ConfirmSelection => write!(f, "confirm_selection"), Action::SelectAndExit => write!(f, "select_and_exit"), + Action::Expect(_) => write!(f, "expect"), Action::SelectNextEntry => write!(f, "select_next_entry"), Action::SelectPrevEntry => write!(f, "select_prev_entry"), Action::SelectNextPage => write!(f, "select_next_page"), @@ -352,7 +355,6 @@ impl Display for Action { Action::TogglePreview => write!(f, "toggle_preview"), Action::Error(_) => write!(f, "error"), Action::NoOp => write!(f, "no_op"), - Action::ToggleSendToChannel => write!(f, "toggle_send_to_channel"), Action::CycleSources => write!(f, "cycle_sources"), Action::ReloadSource => write!(f, "reload_source"), Action::SwitchToChannel(_) => write!(f, "switch_to_channel"), @@ -411,6 +413,7 @@ impl Action { Action::ToggleSelectionUp => "Toggle selection up", Action::ConfirmSelection => "Select entry", Action::SelectAndExit => "Select and exit", + Action::Expect(_) => "Expect key", // Navigation actions Action::SelectNextEntry => "Navigate down", @@ -445,7 +448,6 @@ impl Action { Action::NoOp => "No operation", // Channel actions - Action::ToggleSendToChannel => "Toggle send to channel", Action::CycleSources => "Cycle sources", Action::ReloadSource => "Reload source", Action::SwitchToChannel(_) => "Switch to channel", diff --git a/television/app.rs b/television/app.rs index 33c2205..06f6d2b 100644 --- a/television/app.rs +++ b/television/app.rs @@ -144,6 +144,7 @@ pub struct App { #[derive(Debug, PartialEq)] pub enum ActionOutcome { Entries(FxHashSet), + EntriesWithExpect(FxHashSet, Key), Input(String), None, } @@ -152,6 +153,7 @@ pub enum ActionOutcome { #[derive(Debug)] pub struct AppOutput { pub selected_entries: Option>, + pub expect_key: Option, } impl AppOutput { @@ -159,14 +161,21 @@ impl AppOutput { match action_outcome { ActionOutcome::Entries(entries) => Self { selected_entries: Some(entries), + expect_key: None, + }, + ActionOutcome::EntriesWithExpect(entries, expect_key) => Self { + selected_entries: Some(entries), + expect_key: Some(expect_key), }, ActionOutcome::Input(input) => Self { selected_entries: Some(FxHashSet::from_iter([Entry::new( input, )])), + expect_key: None, }, ActionOutcome::None => Self { selected_entries: None, + expect_key: None, }, } } @@ -603,6 +612,30 @@ impl App { self.television.current_pattern.clone(), )); } + Action::Expect(k) => { + self.should_quit = true; + if !self.render_tx.is_closed() { + self.render_tx.send(RenderingTask::Quit)?; + } + if let Some(entries) = + self.television.get_selected_entries() + { + // Add current query to history + let query = + self.television.current_pattern.clone(); + self.history.add_entry( + query, + self.television.current_channel(), + )?; + return Ok(ActionOutcome::EntriesWithExpect( + entries, k, + )); + } + + return Ok(ActionOutcome::Input( + self.television.current_pattern.clone(), + )); + } Action::ClearScreen => { self.render_tx.send(RenderingTask::ClearScreen)?; } diff --git a/television/cli/args.rs b/television/cli/args.rs index 889bfa4..7030eaf 100644 --- a/television/cli/args.rs +++ b/television/cli/args.rs @@ -121,6 +121,17 @@ pub struct Cli { #[arg(short, long, value_name = "STRING", verbatim_doc_comment)] pub keybindings: Option, + /// Keys that can be used to confirm the current selection in addition to the default ones + /// (typically `enter`). + /// + /// When this is set, confirming the selection will first output an extra line with the key + /// that was used to confirm the selection before outputting the selected entry. + /// + /// Example: `tv --expect='ctrl-q'` will output `ctr-q\n` when `ctrl-q` is + /// pressed to confirm the selection. + #[arg(long, value_name = "STRING", verbatim_doc_comment)] + pub expect: Option, + /// Input text to pass to the channel to prefill the prompt. /// /// This flag works identically in both channel mode and ad-hoc mode. diff --git a/television/cli/mod.rs b/television/cli/mod.rs index 3d3a507..f42e5b6 100644 --- a/television/cli/mod.rs +++ b/television/cli/mod.rs @@ -1,12 +1,15 @@ use crate::{ + action::{Action, Actions}, cable::{self, Cable}, channels::prototypes::{ChannelPrototype, Template}, cli::args::{Cli, Command}, config::{ DEFAULT_PREVIEW_SIZE, KeyBindings, get_config_dir, get_data_dir, + merge_bindings, ui::{BorderType, Padding}, }, errors::cli_parsing_error_exit, + event::Key, screen::layout::Orientation, utils::paths::expand_tilde, }; @@ -15,7 +18,10 @@ use clap::CommandFactory; use clap::error::ErrorKind; use colored::Colorize; use rustc_hash::FxHashMap; -use std::path::{Path, PathBuf}; +use std::{ + path::{Path, PathBuf}, + str::FromStr, +}; use tracing::debug; pub mod args; @@ -221,11 +227,27 @@ impl Default for PostProcessedCli { /// This prevents creating broken ad-hoc channels that reference non-existent commands. pub fn post_process(cli: Cli, readable_stdin: bool) -> PostProcessedCli { // Parse literal keybindings passed through the CLI - let keybindings = cli.keybindings.as_ref().map(|kb| { + let mut keybindings = cli.keybindings.as_ref().map(|kb| { parse_keybindings_literal(kb, CLI_KEYBINDINGS_DELIMITER) .unwrap_or_else(|e| cli_parsing_error_exit(&e.to_string())) }); + // if `--expect` is used, parse and add to the keybindings + if let Some(expect) = &cli.expect { + let expect_bindings = parse_expect_bindings(expect) + .unwrap_or_else(|e| cli_parsing_error_exit(&e.to_string())); + keybindings = match keybindings { + Some(kb) => { + // Merge expect bindings into existing keybindings + Some(merge_bindings(kb, &expect_bindings)) + } + None => { + // Initialize keybindings with expect bindings + Some(expect_bindings) + } + } + } + // Parse preview overrides if provided let preview_command_override = cli.preview_command.as_ref().map(|preview_cmd| { @@ -610,6 +632,36 @@ pub fn guess_channel_from_prompt( fallback } +/// Parses the `expect` keybindings from the CLI into a `KeyBindings` struct that can be +/// merged with other keybindings. +/// +/// The `expect` keybindings are expected to be in the format: +/// ```ignore +/// "ctrl-q;esc" +/// ``` +/// And will produce a `KeyBindings` struct with the following entries: +/// ```ignore +/// { +/// Key::Ctrl('q') => Actions::single(Action::Expect(Key::Ctrl('q'))), +/// Key::Esc => Actions::single(Action::Expect(Key::Esc)), +/// } +/// ``` +fn parse_expect_bindings(expect: &str) -> Result { + let mut bindings = KeyBindings::default(); + for s in expect.split(CLI_KEYBINDINGS_DELIMITER) { + let s = s.trim(); + if s.is_empty() { + continue; + } + let key = Key::from_str(s).map_err(|e| { + anyhow!("Invalid key in expect bindings: '{}'. Error: {}", s, e) + })?; + let action = Action::Expect(key); + bindings.insert(key, Actions::single(action)); + } + Ok(bindings) +} + const VERSION_MESSAGE: &str = env!("CARGO_PKG_VERSION"); pub fn version() -> String { @@ -796,4 +848,19 @@ mod tests { validate_adhoc_mode_constraints(&cli, true); } + + #[test] + fn test_parse_expect_keybindings() { + let expect = "ctrl-q;esc"; + let bindings = parse_expect_bindings(expect).unwrap(); + + let mut expected = KeyBindings::default(); + expected.insert( + Key::Ctrl('q'), + Actions::single(Action::Expect(Key::Ctrl('q'))), + ); + expected.insert(Key::Esc, Actions::single(Action::Expect(Key::Esc))); + + assert_eq!(bindings, expected); + } } diff --git a/television/main.rs b/television/main.rs index 000067f..843865d 100644 --- a/television/main.rs +++ b/television/main.rs @@ -113,6 +113,9 @@ async fn main() -> Result<()> { info!("App output: {:?}", output); let stdout_handle = stdout().lock(); let mut bufwriter = BufWriter::new(stdout_handle); + if let Some(key) = output.expect_key { + writeln!(bufwriter, "{}", key)?; + } if let Some(entries) = output.selected_entries { for entry in &entries { writeln!( diff --git a/television/screen/help_panel.rs b/television/screen/help_panel.rs index b429a00..5b523bb 100644 --- a/television/screen/help_panel.rs +++ b/television/screen/help_panel.rs @@ -111,7 +111,7 @@ fn is_action_relevant_for_mode(action: &Action, mode: Mode) -> bool { | Action::WatchTimer | Action::SelectEntryAtPosition(_, _) | Action::MouseClickAt(_, _) - | Action::ToggleSendToChannel + | Action::Expect(_) | Action::SelectAndExit => false, } } diff --git a/tests/cli/cli_selection.rs b/tests/cli/cli_selection.rs index b37d9ba..710ce30 100644 --- a/tests/cli/cli_selection.rs +++ b/tests/cli/cli_selection.rs @@ -158,3 +158,31 @@ fn test_watch_and_take_1_fast_conflict_errors() { // CLI should exit with error message tester.assert_raw_output_contains("cannot be used with"); } + +/// Tests that --expect works as intended. +#[test] +fn test_expect_with_selection() { + let mut tester = PtyTester::new(); + + // This should auto-select "UNIQUE16CHARID" and exit with it + let cmd = tv_local_config_and_cable_with_args(&[ + "files", + "--expect", + "ctrl-c", + "--input", + "Cargo.toml", + ]); + let mut child = tester.spawn_command_tui(cmd); + + tester.send(&ctrl('c')); + + let out = tester.read_raw_output(); + + assert!( + out.contains("Ctrl-c\r\nCargo.toml"), + "Expected output to contain 'Ctrl-c\\r\\nCargo.toml' but got: '{:?}'", + out + ); + + PtyTester::assert_exit_ok(&mut child, DEFAULT_DELAY * 2); +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index f2d8df5..f30e1c5 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -172,7 +172,7 @@ impl PtyTester { match child.try_wait() { Ok(Some(_)) => { panic!( - "Child process exited prematurely with output:\n{}", + "Child process exited prematurely with output:\n{:?}", self.read_raw_output() ); } @@ -287,7 +287,7 @@ impl PtyTester { let frame = self.get_tui_frame(); assert!( !frame.contains(expected), - "Expected output to not contain\n'{}'\nbut got:\n{}", + "Expected output to not contain\n'{}'\nbut got:\n'{}'", expected, frame ); @@ -297,7 +297,7 @@ impl PtyTester { let output = self.read_raw_output(); assert!( output.contains(expected), - "Expected output to contain '{}', but got:\n{}", + "Expected output to contain '{}', but got:\n{:?}", expected, output );