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 serde_with::{OneOrMany, serde_as};
|
||||||
use std::fmt::Display;
|
use std::fmt::Display;
|
||||||
|
|
||||||
|
use crate::event::Key;
|
||||||
|
|
||||||
/// The different actions that can be performed by the application.
|
/// The different actions that can be performed by the application.
|
||||||
#[derive(
|
#[derive(
|
||||||
Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash, PartialOrd, Ord,
|
Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash, PartialOrd, Ord,
|
||||||
@ -47,6 +49,8 @@ pub enum Action {
|
|||||||
ConfirmSelection,
|
ConfirmSelection,
|
||||||
/// Select the entry currently under the cursor and exit the application.
|
/// Select the entry currently under the cursor and exit the application.
|
||||||
SelectAndExit,
|
SelectAndExit,
|
||||||
|
/// Confirm selection using one of the `expect` keys.
|
||||||
|
Expect(Key),
|
||||||
/// Select the next entry in the currently focused list.
|
/// Select the next entry in the currently focused list.
|
||||||
SelectNextEntry,
|
SelectNextEntry,
|
||||||
/// Select the previous entry in the currently focused list.
|
/// Select the previous entry in the currently focused list.
|
||||||
@ -93,8 +97,6 @@ pub enum Action {
|
|||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
NoOp,
|
NoOp,
|
||||||
// Channel actions
|
// Channel actions
|
||||||
/// FIXME: clean this up
|
|
||||||
ToggleSendToChannel,
|
|
||||||
/// Toggle between different source commands.
|
/// Toggle between different source commands.
|
||||||
CycleSources,
|
CycleSources,
|
||||||
/// Reload the current source command.
|
/// Reload the current source command.
|
||||||
@ -326,6 +328,7 @@ impl Display for Action {
|
|||||||
Action::ToggleSelectionUp => write!(f, "toggle_selection_up"),
|
Action::ToggleSelectionUp => write!(f, "toggle_selection_up"),
|
||||||
Action::ConfirmSelection => write!(f, "confirm_selection"),
|
Action::ConfirmSelection => write!(f, "confirm_selection"),
|
||||||
Action::SelectAndExit => write!(f, "select_and_exit"),
|
Action::SelectAndExit => write!(f, "select_and_exit"),
|
||||||
|
Action::Expect(_) => write!(f, "expect"),
|
||||||
Action::SelectNextEntry => write!(f, "select_next_entry"),
|
Action::SelectNextEntry => write!(f, "select_next_entry"),
|
||||||
Action::SelectPrevEntry => write!(f, "select_prev_entry"),
|
Action::SelectPrevEntry => write!(f, "select_prev_entry"),
|
||||||
Action::SelectNextPage => write!(f, "select_next_page"),
|
Action::SelectNextPage => write!(f, "select_next_page"),
|
||||||
@ -352,7 +355,6 @@ impl Display for Action {
|
|||||||
Action::TogglePreview => write!(f, "toggle_preview"),
|
Action::TogglePreview => write!(f, "toggle_preview"),
|
||||||
Action::Error(_) => write!(f, "error"),
|
Action::Error(_) => write!(f, "error"),
|
||||||
Action::NoOp => write!(f, "no_op"),
|
Action::NoOp => write!(f, "no_op"),
|
||||||
Action::ToggleSendToChannel => write!(f, "toggle_send_to_channel"),
|
|
||||||
Action::CycleSources => write!(f, "cycle_sources"),
|
Action::CycleSources => write!(f, "cycle_sources"),
|
||||||
Action::ReloadSource => write!(f, "reload_source"),
|
Action::ReloadSource => write!(f, "reload_source"),
|
||||||
Action::SwitchToChannel(_) => write!(f, "switch_to_channel"),
|
Action::SwitchToChannel(_) => write!(f, "switch_to_channel"),
|
||||||
@ -411,6 +413,7 @@ impl Action {
|
|||||||
Action::ToggleSelectionUp => "Toggle selection up",
|
Action::ToggleSelectionUp => "Toggle selection up",
|
||||||
Action::ConfirmSelection => "Select entry",
|
Action::ConfirmSelection => "Select entry",
|
||||||
Action::SelectAndExit => "Select and exit",
|
Action::SelectAndExit => "Select and exit",
|
||||||
|
Action::Expect(_) => "Expect key",
|
||||||
|
|
||||||
// Navigation actions
|
// Navigation actions
|
||||||
Action::SelectNextEntry => "Navigate down",
|
Action::SelectNextEntry => "Navigate down",
|
||||||
@ -445,7 +448,6 @@ impl Action {
|
|||||||
Action::NoOp => "No operation",
|
Action::NoOp => "No operation",
|
||||||
|
|
||||||
// Channel actions
|
// Channel actions
|
||||||
Action::ToggleSendToChannel => "Toggle send to channel",
|
|
||||||
Action::CycleSources => "Cycle sources",
|
Action::CycleSources => "Cycle sources",
|
||||||
Action::ReloadSource => "Reload source",
|
Action::ReloadSource => "Reload source",
|
||||||
Action::SwitchToChannel(_) => "Switch to channel",
|
Action::SwitchToChannel(_) => "Switch to channel",
|
||||||
|
@ -144,6 +144,7 @@ pub struct App {
|
|||||||
#[derive(Debug, PartialEq)]
|
#[derive(Debug, PartialEq)]
|
||||||
pub enum ActionOutcome {
|
pub enum ActionOutcome {
|
||||||
Entries(FxHashSet<Entry>),
|
Entries(FxHashSet<Entry>),
|
||||||
|
EntriesWithExpect(FxHashSet<Entry>, Key),
|
||||||
Input(String),
|
Input(String),
|
||||||
None,
|
None,
|
||||||
}
|
}
|
||||||
@ -152,6 +153,7 @@ pub enum ActionOutcome {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct AppOutput {
|
pub struct AppOutput {
|
||||||
pub selected_entries: Option<FxHashSet<Entry>>,
|
pub selected_entries: Option<FxHashSet<Entry>>,
|
||||||
|
pub expect_key: Option<Key>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppOutput {
|
impl AppOutput {
|
||||||
@ -159,14 +161,21 @@ impl AppOutput {
|
|||||||
match action_outcome {
|
match action_outcome {
|
||||||
ActionOutcome::Entries(entries) => Self {
|
ActionOutcome::Entries(entries) => Self {
|
||||||
selected_entries: Some(entries),
|
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 {
|
ActionOutcome::Input(input) => Self {
|
||||||
selected_entries: Some(FxHashSet::from_iter([Entry::new(
|
selected_entries: Some(FxHashSet::from_iter([Entry::new(
|
||||||
input,
|
input,
|
||||||
)])),
|
)])),
|
||||||
|
expect_key: None,
|
||||||
},
|
},
|
||||||
ActionOutcome::None => Self {
|
ActionOutcome::None => Self {
|
||||||
selected_entries: None,
|
selected_entries: None,
|
||||||
|
expect_key: None,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -603,6 +612,30 @@ impl App {
|
|||||||
self.television.current_pattern.clone(),
|
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 => {
|
Action::ClearScreen => {
|
||||||
self.render_tx.send(RenderingTask::ClearScreen)?;
|
self.render_tx.send(RenderingTask::ClearScreen)?;
|
||||||
}
|
}
|
||||||
|
@ -121,6 +121,17 @@ pub struct Cli {
|
|||||||
#[arg(short, long, value_name = "STRING", verbatim_doc_comment)]
|
#[arg(short, long, value_name = "STRING", verbatim_doc_comment)]
|
||||||
pub keybindings: Option<String>,
|
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.
|
/// Input text to pass to the channel to prefill the prompt.
|
||||||
///
|
///
|
||||||
/// This flag works identically in both channel mode and ad-hoc mode.
|
/// This flag works identically in both channel mode and ad-hoc mode.
|
||||||
|
@ -1,12 +1,15 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
|
action::{Action, Actions},
|
||||||
cable::{self, Cable},
|
cable::{self, Cable},
|
||||||
channels::prototypes::{ChannelPrototype, Template},
|
channels::prototypes::{ChannelPrototype, Template},
|
||||||
cli::args::{Cli, Command},
|
cli::args::{Cli, Command},
|
||||||
config::{
|
config::{
|
||||||
DEFAULT_PREVIEW_SIZE, KeyBindings, get_config_dir, get_data_dir,
|
DEFAULT_PREVIEW_SIZE, KeyBindings, get_config_dir, get_data_dir,
|
||||||
|
merge_bindings,
|
||||||
ui::{BorderType, Padding},
|
ui::{BorderType, Padding},
|
||||||
},
|
},
|
||||||
errors::cli_parsing_error_exit,
|
errors::cli_parsing_error_exit,
|
||||||
|
event::Key,
|
||||||
screen::layout::Orientation,
|
screen::layout::Orientation,
|
||||||
utils::paths::expand_tilde,
|
utils::paths::expand_tilde,
|
||||||
};
|
};
|
||||||
@ -15,7 +18,10 @@ use clap::CommandFactory;
|
|||||||
use clap::error::ErrorKind;
|
use clap::error::ErrorKind;
|
||||||
use colored::Colorize;
|
use colored::Colorize;
|
||||||
use rustc_hash::FxHashMap;
|
use rustc_hash::FxHashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::{
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
str::FromStr,
|
||||||
|
};
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
pub mod args;
|
pub mod args;
|
||||||
@ -221,11 +227,27 @@ impl Default for PostProcessedCli {
|
|||||||
/// This prevents creating broken ad-hoc channels that reference non-existent commands.
|
/// This prevents creating broken ad-hoc channels that reference non-existent commands.
|
||||||
pub fn post_process(cli: Cli, readable_stdin: bool) -> PostProcessedCli {
|
pub fn post_process(cli: Cli, readable_stdin: bool) -> PostProcessedCli {
|
||||||
// Parse literal keybindings passed through the CLI
|
// 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)
|
parse_keybindings_literal(kb, CLI_KEYBINDINGS_DELIMITER)
|
||||||
.unwrap_or_else(|e| cli_parsing_error_exit(&e.to_string()))
|
.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
|
// Parse preview overrides if provided
|
||||||
let preview_command_override =
|
let preview_command_override =
|
||||||
cli.preview_command.as_ref().map(|preview_cmd| {
|
cli.preview_command.as_ref().map(|preview_cmd| {
|
||||||
@ -610,6 +632,36 @@ pub fn guess_channel_from_prompt(
|
|||||||
fallback
|
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");
|
const VERSION_MESSAGE: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
pub fn version() -> String {
|
pub fn version() -> String {
|
||||||
@ -796,4 +848,19 @@ mod tests {
|
|||||||
|
|
||||||
validate_adhoc_mode_constraints(&cli, true);
|
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);
|
info!("App output: {:?}", output);
|
||||||
let stdout_handle = stdout().lock();
|
let stdout_handle = stdout().lock();
|
||||||
let mut bufwriter = BufWriter::new(stdout_handle);
|
let mut bufwriter = BufWriter::new(stdout_handle);
|
||||||
|
if let Some(key) = output.expect_key {
|
||||||
|
writeln!(bufwriter, "{}", key)?;
|
||||||
|
}
|
||||||
if let Some(entries) = output.selected_entries {
|
if let Some(entries) = output.selected_entries {
|
||||||
for entry in &entries {
|
for entry in &entries {
|
||||||
writeln!(
|
writeln!(
|
||||||
|
@ -111,7 +111,7 @@ fn is_action_relevant_for_mode(action: &Action, mode: Mode) -> bool {
|
|||||||
| Action::WatchTimer
|
| Action::WatchTimer
|
||||||
| Action::SelectEntryAtPosition(_, _)
|
| Action::SelectEntryAtPosition(_, _)
|
||||||
| Action::MouseClickAt(_, _)
|
| Action::MouseClickAt(_, _)
|
||||||
| Action::ToggleSendToChannel
|
| Action::Expect(_)
|
||||||
| Action::SelectAndExit => false,
|
| Action::SelectAndExit => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -158,3 +158,31 @@ fn test_watch_and_take_1_fast_conflict_errors() {
|
|||||||
// CLI should exit with error message
|
// CLI should exit with error message
|
||||||
tester.assert_raw_output_contains("cannot be used with");
|
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() {
|
match child.try_wait() {
|
||||||
Ok(Some(_)) => {
|
Ok(Some(_)) => {
|
||||||
panic!(
|
panic!(
|
||||||
"Child process exited prematurely with output:\n{}",
|
"Child process exited prematurely with output:\n{:?}",
|
||||||
self.read_raw_output()
|
self.read_raw_output()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -287,7 +287,7 @@ impl PtyTester {
|
|||||||
let frame = self.get_tui_frame();
|
let frame = self.get_tui_frame();
|
||||||
assert!(
|
assert!(
|
||||||
!frame.contains(expected),
|
!frame.contains(expected),
|
||||||
"Expected output to not contain\n'{}'\nbut got:\n{}",
|
"Expected output to not contain\n'{}'\nbut got:\n'{}'",
|
||||||
expected,
|
expected,
|
||||||
frame
|
frame
|
||||||
);
|
);
|
||||||
@ -297,7 +297,7 @@ impl PtyTester {
|
|||||||
let output = self.read_raw_output();
|
let output = self.read_raw_output();
|
||||||
assert!(
|
assert!(
|
||||||
output.contains(expected),
|
output.contains(expected),
|
||||||
"Expected output to contain '{}', but got:\n{}",
|
"Expected output to contain '{}', but got:\n{:?}",
|
||||||
expected,
|
expected,
|
||||||
output
|
output
|
||||||
);
|
);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user