mirror of
https://github.com/alexpasmantier/television.git
synced 2025-07-29 14:21:43 +00:00
parent
94e1061b67
commit
c93ddeeadb
@ -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",
|
||||
|
@ -144,6 +144,7 @@ pub struct App {
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum ActionOutcome {
|
||||
Entries(FxHashSet<Entry>),
|
||||
EntriesWithExpect(FxHashSet<Entry>, Key),
|
||||
Input(String),
|
||||
None,
|
||||
}
|
||||
@ -152,6 +153,7 @@ pub enum ActionOutcome {
|
||||
#[derive(Debug)]
|
||||
pub struct AppOutput {
|
||||
pub selected_entries: Option<FxHashSet<Entry>>,
|
||||
pub expect_key: Option<Key>,
|
||||
}
|
||||
|
||||
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)?;
|
||||
}
|
||||
|
@ -121,6 +121,17 @@ pub struct Cli {
|
||||
#[arg(short, long, value_name = "STRING", verbatim_doc_comment)]
|
||||
pub keybindings: Option<String>,
|
||||
|
||||
/// 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<selected_entry>` when `ctrl-q` is
|
||||
/// pressed to confirm the selection.
|
||||
#[arg(long, value_name = "STRING", verbatim_doc_comment)]
|
||||
pub expect: Option<String>,
|
||||
|
||||
/// Input text to pass to the channel to prefill the prompt.
|
||||
///
|
||||
/// This flag works identically in both channel mode and ad-hoc mode.
|
||||
|
@ -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<KeyBindings> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -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!(
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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
|
||||
);
|
||||
|
Loading…
x
Reference in New Issue
Block a user