diff --git a/Cargo.lock b/Cargo.lock index 28017902c5..21183ad7aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3704,6 +3704,7 @@ dependencies = [ "rand 0.8.5", "rand_chacha 0.3.1", "rayon", + "reedline", "rmp", "roxmltree", "rstest", diff --git a/crates/nu-command/Cargo.toml b/crates/nu-command/Cargo.toml index babdce20de..51e8743e9c 100644 --- a/crates/nu-command/Cargo.toml +++ b/crates/nu-command/Cargo.toml @@ -85,6 +85,7 @@ quick-xml = { workspace = true } rand = { workspace = true, optional = true } getrandom = { workspace = true, optional = true } rayon = { workspace = true } +reedline = { workspace = true } roxmltree = { workspace = true } rusqlite = { workspace = true, features = [ "bundled", diff --git a/crates/nu-command/src/platform/input/input_.rs b/crates/nu-command/src/platform/input/input_.rs index 5e088098d2..eccdfae92e 100644 --- a/crates/nu-command/src/platform/input/input_.rs +++ b/crates/nu-command/src/platform/input/input_.rs @@ -1,19 +1,16 @@ -use crossterm::{ - cursor, - event::{Event, KeyCode, KeyEventKind, KeyModifiers}, - execute, - style::Print, - terminal::{self, ClearType}, -}; -use itertools::Itertools; +use crate::platform::input::legacy_input::LegacyInput; +use crate::platform::input::reedline_prompt::ReedlinePrompt; use nu_engine::command_prelude::*; use nu_protocol::shell_error::io::IoError; +use reedline::{Reedline, Signal}; -use std::{io::Write, time::Duration}; +use std::io::Write; #[derive(Clone)] pub struct Input; +impl LegacyInput for Input {} + impl Command for Input { fn name(&self) -> &str { "input" @@ -61,11 +58,11 @@ impl Command for Input { call: &Call, _input: PipelineData, ) -> Result { - let prompt: Option = call.opt(engine_state, stack, 0)?; + let prompt_str: Option = call.opt(engine_state, stack, 0)?; let bytes_until: Option = call.get_flag(engine_state, stack, "bytes-until-any")?; let suppress_output = call.has_flag(engine_state, stack, "suppress-output")?; - let numchar: Option> = call.get_flag(engine_state, stack, "numchar")?; - let numchar: Spanned = numchar.unwrap_or(Spanned { + let numchar_flag: Option> = call.get_flag(engine_state, stack, "numchar")?; + let numchar: Spanned = numchar_flag.unwrap_or(Spanned { item: i64::MAX, span: call.head, }); @@ -81,8 +78,15 @@ impl Command for Input { }); } + // Those 2 options are not supported by reedline, default to the legacy + // implementation + if suppress_output || bytes_until.is_some() || numchar_flag.is_some() { + return self.legacy_input(engine_state, stack, call, _input); + } + let default_val: Option = call.get_flag(engine_state, stack, "default")?; - if let Some(prompt) = &prompt { + + if let Some(prompt) = &prompt_str { match &default_val { None => print!("{prompt}"), Some(val) => print!("{prompt} (default: {val})"), @@ -91,82 +95,35 @@ impl Command for Input { } let mut buf = String::new(); - - crossterm::terminal::enable_raw_mode().map_err(&from_io_error)?; - // clear terminal events - while crossterm::event::poll(Duration::from_secs(0)).map_err(&from_io_error)? { - // If there's an event, read it to remove it from the queue - let _ = crossterm::event::read().map_err(&from_io_error)?; - } + let prompt = ReedlinePrompt { + left_prompt: prompt_str.unwrap_or("".to_string()), + indicator: "".to_string(), // TODO: Add support for custom prompt indicators + // for now, and backwards compat, we just use the empty + // string + }; + let mut line_editor = Reedline::create(); + // TODO handle options loop { if i64::try_from(buf.len()).unwrap_or(0) >= numchar.item { break; } - match crossterm::event::read() { - Ok(Event::Key(k)) => match k.kind { - KeyEventKind::Press | KeyEventKind::Repeat => { - match k.code { - // TODO: maintain keycode parity with existing command - KeyCode::Char(c) => { - if k.modifiers == KeyModifiers::ALT - || k.modifiers == KeyModifiers::CONTROL - { - if k.modifiers == KeyModifiers::CONTROL && c == 'c' { - crossterm::terminal::disable_raw_mode() - .map_err(&from_io_error)?; - return Err(IoError::new( - std::io::ErrorKind::Interrupted, - call.head, - None, - ) - .into()); - } - continue; - } - - if let Some(bytes_until) = bytes_until.as_ref() { - if bytes_until.bytes().contains(&(c as u8)) { - break; - } - } - buf.push(c); - } - KeyCode::Backspace => { - let _ = buf.pop(); - } - KeyCode::Enter => { - break; - } - _ => continue, - } - } - _ => continue, - }, + match line_editor.read_line(&prompt) { + Ok(Signal::Success(buffer)) => { + buf.push_str(&buffer); + break; + } + Ok(Signal::CtrlC) => { + return Err( + IoError::new(std::io::ErrorKind::Interrupted, call.head, None).into(), + ); + } Ok(_) => continue, Err(event_error) => { crossterm::terminal::disable_raw_mode().map_err(&from_io_error)?; return Err(from_io_error(event_error).into()); } } - if !suppress_output { - // clear the current line and print the current buffer - execute!( - std::io::stdout(), - terminal::Clear(ClearType::CurrentLine), - cursor::MoveToColumn(0), - ) - .map_err(|err| IoError::new(err.kind(), call.head, None))?; - if let Some(prompt) = &prompt { - execute!(std::io::stdout(), Print(prompt.to_string())) - .map_err(&from_io_error)?; - } - execute!(std::io::stdout(), Print(buf.to_string())).map_err(&from_io_error)?; - } - } - crossterm::terminal::disable_raw_mode().map_err(&from_io_error)?; - if !suppress_output { - std::io::stdout().write_all(b"\n").map_err(&from_io_error)?; } match default_val { Some(val) if buf.is_empty() => Ok(Value::string(val, call.head).into_pipeline_data()), diff --git a/crates/nu-command/src/platform/input/legacy_input.rs b/crates/nu-command/src/platform/input/legacy_input.rs new file mode 100644 index 0000000000..3753331532 --- /dev/null +++ b/crates/nu-command/src/platform/input/legacy_input.rs @@ -0,0 +1,134 @@ +use crossterm::{ + cursor, + event::{Event, KeyCode, KeyEventKind, KeyModifiers}, + execute, + style::Print, + terminal::{self, ClearType}, +}; +use itertools::Itertools; +use nu_engine::command_prelude::*; +use nu_protocol::shell_error::io::IoError; + +use std::{io::Write, time::Duration}; + +pub trait LegacyInput { + fn legacy_input( + &self, + engine_state: &EngineState, + stack: &mut Stack, + call: &Call, + _input: PipelineData, + ) -> Result { + let prompt: Option = call.opt(engine_state, stack, 0)?; + let bytes_until: Option = call.get_flag(engine_state, stack, "bytes-until-any")?; + let suppress_output = call.has_flag(engine_state, stack, "suppress-output")?; + let numchar: Option> = call.get_flag(engine_state, stack, "numchar")?; + let numchar: Spanned = numchar.unwrap_or(Spanned { + item: i64::MAX, + span: call.head, + }); + + let from_io_error = IoError::factory(call.head, None); + + if numchar.item < 1 { + return Err(ShellError::UnsupportedInput { + msg: "Number of characters to read has to be positive".to_string(), + input: "value originated from here".to_string(), + msg_span: call.head, + input_span: numchar.span, + }); + } + + let default_val: Option = call.get_flag(engine_state, stack, "default")?; + if let Some(prompt) = &prompt { + match &default_val { + None => print!("{prompt}"), + Some(val) => print!("{prompt} (default: {val})"), + } + let _ = std::io::stdout().flush(); + } + + let mut buf = String::new(); + + crossterm::terminal::enable_raw_mode().map_err(&from_io_error)?; + // clear terminal events + while crossterm::event::poll(Duration::from_secs(0)).map_err(&from_io_error)? { + // If there's an event, read it to remove it from the queue + let _ = crossterm::event::read().map_err(&from_io_error)?; + } + + loop { + if i64::try_from(buf.len()).unwrap_or(0) >= numchar.item { + break; + } + match crossterm::event::read() { + Ok(Event::Key(k)) => match k.kind { + KeyEventKind::Press | KeyEventKind::Repeat => { + match k.code { + // TODO: maintain keycode parity with existing command + KeyCode::Char(c) => { + if k.modifiers == KeyModifiers::ALT + || k.modifiers == KeyModifiers::CONTROL + { + if k.modifiers == KeyModifiers::CONTROL && c == 'c' { + crossterm::terminal::disable_raw_mode() + .map_err(&from_io_error)?; + return Err(IoError::new( + std::io::ErrorKind::Interrupted, + call.head, + None, + ) + .into()); + } + continue; + } + + if let Some(bytes_until) = bytes_until.as_ref() { + if bytes_until.bytes().contains(&(c as u8)) { + break; + } + } + buf.push(c); + } + KeyCode::Backspace => { + let _ = buf.pop(); + } + KeyCode::Enter => { + break; + } + _ => continue, + } + } + _ => continue, + }, + Ok(_) => continue, + Err(event_error) => { + crossterm::terminal::disable_raw_mode().map_err(&from_io_error)?; + return Err(from_io_error(event_error).into()); + } + } + if !suppress_output { + // clear the current line and print the current buffer + execute!( + std::io::stdout(), + terminal::Clear(ClearType::CurrentLine), + cursor::MoveToColumn(0), + ) + .map_err(|err| IoError::new(err.kind(), call.head, None))?; + if let Some(prompt) = &prompt { + execute!(std::io::stdout(), Print(prompt.to_string())) + .map_err(&from_io_error)?; + } + execute!(std::io::stdout(), Print(buf.to_string())).map_err(&from_io_error)?; + } + } + crossterm::terminal::disable_raw_mode().map_err(&from_io_error)?; + if !suppress_output { + std::io::stdout().write_all(b"\n").map_err(&from_io_error)?; + } + match default_val { + Some(val) if buf.is_empty() => Ok(Value::string(val, call.head).into_pipeline_data()), + _ => Ok(Value::string(buf, call.head).into_pipeline_data()), + } + } +} diff --git a/crates/nu-command/src/platform/input/mod.rs b/crates/nu-command/src/platform/input/mod.rs index 2dc3f01eb1..c8f7d9239d 100644 --- a/crates/nu-command/src/platform/input/mod.rs +++ b/crates/nu-command/src/platform/input/mod.rs @@ -1,6 +1,8 @@ mod input_; mod input_listen; +mod legacy_input; mod list; +mod reedline_prompt; pub use input_::Input; pub use input_listen::InputListen; diff --git a/crates/nu-command/src/platform/input/reedline_prompt.rs b/crates/nu-command/src/platform/input/reedline_prompt.rs new file mode 100644 index 0000000000..b2e08f766a --- /dev/null +++ b/crates/nu-command/src/platform/input/reedline_prompt.rs @@ -0,0 +1,61 @@ +use reedline::{ + Prompt, PromptEditMode, PromptHistorySearch, PromptHistorySearchStatus, PromptViMode, +}; + +use std::borrow::Cow; + +/// The default prompt indicator +pub static DEFAULT_VI_INSERT_PROMPT_INDICATOR: &str = ": "; +pub static DEFAULT_VI_NORMAL_PROMPT_INDICATOR: &str = "〉"; +pub static DEFAULT_MULTILINE_INDICATOR: &str = "::: "; + +/// Simple [`Prompt`] displaying a configurable left and a right prompt. +/// For more fine-tuned configuration, implement the [`Prompt`] trait. +/// For the default configuration, use [`DefaultPrompt::default()`] +#[derive(Clone)] +pub struct ReedlinePrompt { + /// What segment should be rendered in the left (main) prompt + pub left_prompt: String, + pub indicator: String, +} + +impl Prompt for ReedlinePrompt { + fn render_prompt_left(&self) -> Cow { + Cow::Borrowed(&self.left_prompt) + } + + fn render_prompt_right(&self) -> Cow { + Cow::Borrowed("") + } + + fn render_prompt_indicator(&self, edit_mode: PromptEditMode) -> Cow { + match edit_mode { + PromptEditMode::Default | PromptEditMode::Emacs => self.indicator.as_str().into(), + PromptEditMode::Vi(vi_mode) => match vi_mode { + PromptViMode::Normal => DEFAULT_VI_NORMAL_PROMPT_INDICATOR.into(), + PromptViMode::Insert => DEFAULT_VI_INSERT_PROMPT_INDICATOR.into(), + }, + PromptEditMode::Custom(str) => format!("({str})").into(), + } + } + + fn render_prompt_multiline_indicator(&self) -> Cow { + Cow::Borrowed(DEFAULT_MULTILINE_INDICATOR) + } + + fn render_prompt_history_search_indicator( + &self, + history_search: PromptHistorySearch, + ) -> Cow { + let prefix = match history_search.status { + PromptHistorySearchStatus::Passing => "", + PromptHistorySearchStatus::Failing => "failing ", + }; + // NOTE: magic strings, given there is logic on how these compose I am not sure if it + // is worth extracting in to static constant + Cow::Owned(format!( + "({}reverse-search: {}) ", + prefix, history_search.term + )) + } +}