From 5807cda45d0f9935617c92e2b47a6d54712f93bc Mon Sep 17 00:00:00 2001 From: Alexandre Pasmantier <47638216+alexpasmantier@users.noreply.github.com> Date: Mon, 18 Nov 2024 23:48:48 +0100 Subject: [PATCH] feat(cli): allow passing passthrough keybindings via stdout for the parent process to deal with (#39) --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/television-channels/src/entry.rs | 3 - crates/television/action.rs | 3 + crates/television/app.rs | 101 +++++++++++++++++++++--- crates/television/cli.rs | 32 ++++++++ crates/television/config.rs | 1 + crates/television/main.rs | 17 ++-- crates/television/ui/input/backend.rs | 4 - 9 files changed, 138 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 99f230c..65e214d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2963,7 +2963,7 @@ dependencies = [ [[package]] name = "television" -version = "0.4.23" +version = "0.5.0" dependencies = [ "anyhow", "better-panic", diff --git a/Cargo.toml b/Cargo.toml index 7d26e9a..5b93943 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "television" -version = "0.4.23" +version = "0.5.0" edition = "2021" description = "The revolution will be televised." license = "MIT" diff --git a/crates/television-channels/src/entry.rs b/crates/television-channels/src/entry.rs index a2b7689..14c3ea8 100644 --- a/crates/television-channels/src/entry.rs +++ b/crates/television-channels/src/entry.rs @@ -108,9 +108,6 @@ impl Entry { if let Some(line_number) = self.line_number { repr.push_str(&format!(":{line_number}")); } - if let Some(preview) = &self.value { - repr.push_str(&format!("\n{preview}")); - } repr } } diff --git a/crates/television/action.rs b/crates/television/action.rs index baddb66..76dbe04 100644 --- a/crates/television/action.rs +++ b/crates/television/action.rs @@ -31,6 +31,9 @@ pub enum Action { // results actions /// Select the entry currently under the cursor. SelectEntry, + /// Select the entry currently under the cursor and pass the key that was pressed + /// through to be handled the parent process. + SelectPassthrough(String), /// Select the entry currently under the cursor and exit the application. SelectAndExit, /// Select the next entry in the currently focused list. diff --git a/crates/television/app.rs b/crates/television/app.rs index a0f1f63..b4665f7 100644 --- a/crates/television/app.rs +++ b/crates/television/app.rs @@ -6,7 +6,7 @@ use derive_deref::Deref; use tokio::sync::{mpsc, Mutex}; use tracing::{debug, info}; -use crate::config::KeyBindings; +use crate::config::{parse_key, KeyBindings}; use crate::television::{Mode, Television}; use crate::{ action::Action, @@ -17,7 +17,7 @@ use crate::{ use television_channels::channels::TelevisionChannel; use television_channels::entry::Entry; -#[derive(Deref, Default)] +#[derive(Deref, Default, Debug)] pub struct Keymap(pub HashMap>); impl From<&KeyBindings> for Keymap { @@ -34,6 +34,22 @@ impl From<&KeyBindings> for Keymap { } } +impl Keymap { + pub fn with_mode_mappings( + mut self, + mode: Mode, + mappings: Vec<(Key, Action)>, + ) -> Result { + let mode_keymap = self.0.get_mut(&mode).ok_or_else(|| { + color_eyre::eyre::eyre!("Mode {:?} not found", mode) + })?; + for (key, action) in mappings { + mode_keymap.insert(key, action); + } + Ok(self) + } +} + /// The main application struct that holds the state of the application. pub struct App { /// The configuration of the application. @@ -61,11 +77,46 @@ pub struct App { render_tx: mpsc::UnboundedSender, } +/// The outcome of an action. +#[derive(Debug)] +pub enum ActionOutcome { + Entry(Entry), + Passthrough(Entry, String), + None, +} + +/// The result of the application. +#[derive(Debug)] +pub struct AppOutput { + pub selected_entry: Option, + pub passthrough: Option, +} + +impl From for AppOutput { + fn from(outcome: ActionOutcome) -> Self { + match outcome { + ActionOutcome::Entry(entry) => Self { + selected_entry: Some(entry), + passthrough: None, + }, + ActionOutcome::Passthrough(entry, key) => Self { + selected_entry: Some(entry), + passthrough: Some(key), + }, + ActionOutcome::None => Self { + selected_entry: None, + passthrough: None, + }, + } + } +} + impl App { pub fn new( channel: TelevisionChannel, tick_rate: f64, frame_rate: f64, + passthrough_keybindings: Vec, ) -> Result { let (action_tx, action_rx) = mpsc::unbounded_channel(); let (render_tx, _) = mpsc::unbounded_channel(); @@ -73,7 +124,17 @@ impl App { let (event_abort_tx, _) = mpsc::unbounded_channel(); let television = Arc::new(Mutex::new(Television::new(channel))); let config = Config::new()?; - let keymap = Keymap::from(&config.keybindings); + let keymap = Keymap::from(&config.keybindings).with_mode_mappings( + Mode::Channel, + passthrough_keybindings + .iter() + .flat_map(|s| match parse_key(s) { + Ok(key) => Ok((key, Action::SelectPassthrough(s.clone()))), + Err(e) => Err(e), + }) + .collect(), + )?; + debug!("{:?}", keymap); Ok(Self { config, @@ -105,7 +166,7 @@ impl App { /// /// # Errors /// If an error occurs during the execution of the application. - pub async fn run(&mut self, is_output_tty: bool) -> Result> { + pub async fn run(&mut self, is_output_tty: bool) -> Result { info!("Starting backend event loop"); let event_loop = EventLoop::new(self.tick_rate, true); self.event_rx = event_loop.rx; @@ -141,7 +202,7 @@ impl App { action_tx.send(action)?; } - let maybe_selected = self.handle_actions().await?; + let action_outcome = self.handle_actions().await?; if self.should_quit { // send a termination signal to the event loop @@ -150,7 +211,7 @@ impl App { // wait for the rendering task to finish rendering_task.await??; - return Ok(maybe_selected); + return Ok(AppOutput::from(action_outcome)); } } } @@ -211,7 +272,7 @@ impl App { /// /// # Errors /// If an error occurs during the execution of the application. - async fn handle_actions(&mut self) -> Result> { + async fn handle_actions(&mut self) -> Result { while let Ok(action) = self.action_rx.try_recv() { if action != Action::Tick && action != Action::Render { debug!("{action:?}"); @@ -232,11 +293,25 @@ impl App { Action::SelectAndExit => { self.should_quit = true; self.render_tx.send(RenderingTask::Quit)?; - return Ok(self - .television - .lock() - .await - .get_selected_entry(Some(Mode::Channel))); + if let Some(entry) = + self.television.lock().await.get_selected_entry(None) + { + return Ok(ActionOutcome::Entry(entry)); + } + return Ok(ActionOutcome::None); + } + 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) + { + return Ok(ActionOutcome::Passthrough( + entry, + passthrough, + )); + } + return Ok(ActionOutcome::None); } Action::ClearScreen => { self.render_tx.send(RenderingTask::ClearScreen)?; @@ -256,6 +331,6 @@ impl App { self.action_tx.send(action)?; }; } - Ok(None) + Ok(ActionOutcome::None) } } diff --git a/crates/television/cli.rs b/crates/television/cli.rs index f450ac7..0aa4579 100644 --- a/crates/television/cli.rs +++ b/crates/television/cli.rs @@ -17,6 +17,38 @@ pub struct Cli { /// Frame rate, i.e. number of frames per second #[arg(short, long, value_name = "FLOAT", default_value_t = 60.0)] pub frame_rate: f64, + + /// Passthrough keybindings (comma separated, e.g. "q,ctrl-w,ctrl-t") These keybindings will + /// trigger selection of the current entry and be passed through to stdout along with the entry + /// to be handled by the parent process. + #[arg(short, long, value_name = "STRING")] + pub passthrough_keybindings: Option, +} + +#[derive(Debug)] +pub struct PostProcessedCli { + pub channel: CliTvChannel, + pub tick_rate: f64, + pub frame_rate: f64, + pub passthrough_keybindings: Vec, +} + +impl From for PostProcessedCli { + fn from(cli: Cli) -> Self { + let passthrough_keybindings = cli + .passthrough_keybindings + .unwrap_or_default() + .split(',') + .map(std::string::ToString::to_string) + .collect(); + + Self { + channel: cli.channel, + tick_rate: cli.tick_rate, + frame_rate: cli.frame_rate, + passthrough_keybindings, + } + } } const VERSION_MESSAGE: &str = concat!( diff --git a/crates/television/config.rs b/crates/television/config.rs index 416cee8..77be70c 100644 --- a/crates/television/config.rs +++ b/crates/television/config.rs @@ -3,6 +3,7 @@ use std::{env, path::PathBuf}; use color_eyre::{eyre::Context, Result}; use directories::ProjectDirs; +pub use keybindings::parse_key; pub use keybindings::KeyBindings; use lazy_static::lazy_static; use previewers::PreviewersConfig; diff --git a/crates/television/main.rs b/crates/television/main.rs index 2eb14a8..e133c50 100644 --- a/crates/television/main.rs +++ b/crates/television/main.rs @@ -1,6 +1,7 @@ use std::io::{stdout, IsTerminal, Write}; use clap::Parser; +use cli::PostProcessedCli; use color_eyre::Result; use television_channels::channels::TelevisionChannel; use tracing::{debug, info}; @@ -28,7 +29,9 @@ async fn main() -> Result<()> { errors::init()?; logging::init()?; - let args = Cli::parse(); + let args: PostProcessedCli = Cli::parse().into(); + + debug!("{:?}", args); match App::new( { @@ -42,12 +45,16 @@ async fn main() -> Result<()> { }, args.tick_rate, args.frame_rate, + args.passthrough_keybindings, ) { Ok(mut app) => { - if let Some(entry) = app.run(stdout().is_terminal()).await? { - // print entry to stdout - stdout().flush()?; - info!("{:?}", entry); + stdout().flush()?; + let output = app.run(stdout().is_terminal()).await?; + info!("{:?}", output); + if let Some(passthrough) = output.passthrough { + writeln!(stdout(), "{passthrough}")?; + } + if let Some(entry) = output.selected_entry { writeln!(stdout(), "{}", entry.stdout_repr())?; } Ok(()) diff --git a/crates/television/ui/input/backend.rs b/crates/television/ui/input/backend.rs index c17bf62..d17e169 100644 --- a/crates/television/ui/input/backend.rs +++ b/crates/television/ui/input/backend.rs @@ -19,14 +19,10 @@ pub fn to_input_request(evt: &CrosstermEvent) -> Option { (Delete, KeyModifiers::NONE) => Some(DeleteNextChar), (Tab, KeyModifiers::NONE) => None, (Left, KeyModifiers::NONE) => Some(GoToPrevChar), - //(Left, KeyModifiers::CONTROL) => Some(GoToPrevWord), (Right, KeyModifiers::NONE) => Some(GoToNextChar), - //(Right, KeyModifiers::CONTROL) => Some(GoToNextWord), - //(Char('u'), KeyModifiers::CONTROL) => Some(DeleteLine), (Char('w'), KeyModifiers::CONTROL) | (Backspace, KeyModifiers::ALT) => Some(DeletePrevWord), (Delete, KeyModifiers::CONTROL) => Some(DeleteNextWord), - //(Char('k'), KeyModifiers::CONTROL) => Some(DeleteTillEnd), (Char('a'), KeyModifiers::CONTROL) | (Home, KeyModifiers::NONE) => Some(GoToStart), (Char('e'), KeyModifiers::CONTROL) | (End, KeyModifiers::NONE) => {