diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43f6764b..4b8ba02d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,7 @@ jobs: run: cargo fmt --all -- --check - name: Clippy - run: cargo clippy ${{ matrix.flags }} --all -- -D warnings + run: cargo clippy ${{ matrix.flags }} --all-targets --all -- -D warnings - name: Tests diff --git a/.typos.toml b/.typos.toml index 93327348..4035ad9d 100644 --- a/.typos.toml +++ b/.typos.toml @@ -6,3 +6,5 @@ extend-exclude = ["src/core_editor/line_buffer.rs"] iterm = "iterm" # For testing completion of the word build bui = "bui" +# for sqlite backed history +wheres = "wheres" diff --git a/Cargo.lock b/Cargo.lock index 8e85d810..f535d0ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,6 +97,7 @@ dependencies = [ "android-tzdata", "iana-time-zone", "num-traits", + "serde", "winapi", ] @@ -349,9 +350,9 @@ dependencies = [ [[package]] name = "itertools" -version = "0.10.5" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +checksum = "25db6b064527c5d482d0423354fcd07a89a2dfe07b67892e62411946db7f07b0" dependencies = [ "either", ] @@ -567,7 +568,7 @@ dependencies = [ [[package]] name = "reedline" -version = "0.25.0" +version = "0.28.0" dependencies = [ "chrono", "clipboard", diff --git a/Cargo.toml b/Cargo.toml index 7fc3fbc3..5c11a93c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,19 +6,22 @@ license = "MIT" name = "reedline" repository = "https://github.com/nushell/reedline" rust-version = "1.62.1" -version = "0.25.0" +version = "0.28.0" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [lib] doctest = true [dependencies] -chrono = { version = "0.4.19", default-features = false, features = ["clock"] } +chrono = { version = "0.4.19", default-features = false, features = [ + "clock", + "serde", +] } clipboard = { version = "0.5.0", optional = true } crossbeam = { version = "0.8.2", optional = true } crossterm = { version = "0.27.0", features = ["serde"] } fd-lock = "3.0.3" -itertools = "0.10.3" +itertools = "0.12.0" nu-ansi-term = "0.49.0" rusqlite = { version = "0.29.0", optional = true } serde = { version = "1.0", features = ["derive"] } @@ -42,3 +45,16 @@ external_printer = ["crossbeam"] sqlite = ["rusqlite/bundled", "serde_json"] sqlite-dynlib = ["rusqlite", "serde_json"] system_clipboard = ["clipboard"] + +[[example]] +name = "cwd_aware_hinter" +required-features = ["sqlite"] + +[[example]] +name = "external_printer" +required-features = ["external_printer"] + +[package.metadata.docs.rs] +# Whether to pass `--all-features` to Cargo (default: false) +all-features = false +features = ["bashisms", "external_printer", "sqlite"] diff --git a/examples/cwd_aware_hinter.rs b/examples/cwd_aware_hinter.rs index f1886a52..b8b9145d 100644 --- a/examples/cwd_aware_hinter.rs +++ b/examples/cwd_aware_hinter.rs @@ -58,7 +58,7 @@ fn main() -> io::Result<()> { let mut line_editor = Reedline::create() .with_hinter(Box::new( - CwdAwareHinter::default().with_style(Style::new().italic().fg(Color::Yellow)), + CwdAwareHinter::default().with_style(Style::new().bold().italic().fg(Color::Yellow)), )) .with_history(history); diff --git a/examples/demo.rs b/examples/demo.rs index 96bf4959..2e7a402e 100644 --- a/examples/demo.rs +++ b/examples/demo.rs @@ -3,8 +3,7 @@ use std::process::Command; use { crossterm::{ cursor::SetCursorStyle, - event::{DisableBracketedPaste, KeyCode, KeyModifiers}, - execute, + event::{KeyCode, KeyModifiers}, }, nu_ansi_term::{Color, Style}, reedline::{ @@ -13,7 +12,6 @@ use { EditCommand, EditMode, Emacs, ExampleHighlighter, Keybindings, ListMenu, Reedline, ReedlineEvent, ReedlineMenu, Signal, Vi, }, - std::io::stdout, }; use reedline::CursorConfig; @@ -89,17 +87,14 @@ fn main() -> std::io::Result<()> { .with_quick_completions(true) .with_partial_completions(true) .with_cursor_config(cursor_config) + .use_bracketed_paste(true) + .use_kitty_keyboard_enhancement(true) .with_highlighter(Box::new(ExampleHighlighter::new(commands))) .with_hinter(Box::new( DefaultHinter::default().with_style(Style::new().fg(Color::DarkGray)), )) .with_validator(Box::new(DefaultValidator)) .with_ansi_colors(true); - let res = line_editor.enable_bracketed_paste(); - let bracketed_paste_enabled = res.is_ok(); - if !bracketed_paste_enabled { - println!("Warn: failed to enable bracketed paste mode: {res:?}"); - } // Adding default menus for the compiled reedline line_editor = line_editor @@ -226,9 +221,6 @@ fn main() -> std::io::Result<()> { } } - if bracketed_paste_enabled { - let _ = execute!(stdout(), DisableBracketedPaste); - } println!(); Ok(()) } diff --git a/examples/external_printer.rs b/examples/external_printer.rs index 350843a9..633a1238 100644 --- a/examples/external_printer.rs +++ b/examples/external_printer.rs @@ -2,7 +2,6 @@ // to run: // cargo run --example external_printer --features=external_printer -#[cfg(feature = "external_printer")] use { reedline::ExternalPrinter, reedline::{DefaultPrompt, Reedline, Signal}, @@ -11,7 +10,6 @@ use { std::time::Duration, }; -#[cfg(feature = "external_printer")] fn main() { let printer = ExternalPrinter::default(); // make a clone to use it in a different thread @@ -59,8 +57,3 @@ fn main() { break; } } - -#[cfg(not(feature = "external_printer"))] -fn main() { - println!("Please enable the feature: ‘external_printer‘") -} diff --git a/src/completion/base.rs b/src/completion/base.rs index 3de63c8d..fdf73696 100644 --- a/src/completion/base.rs +++ b/src/completion/base.rs @@ -24,7 +24,8 @@ impl Span { } } -/// A trait that defines how to convert a line and position to a list of potential completions in that position. +/// A trait that defines how to convert some text and a position to a list of potential completions in that position. +/// The text could be a part of the whole line, and the position is the index of the end of the text in the original line. pub trait Completer: Send { /// the action that will take the line and position and convert it to a vector of completions, which include the /// span to replace and the contents of that replacement diff --git a/src/completion/default.rs b/src/completion/default.rs index 11062d7b..4a3dd871 100644 --- a/src/completion/default.rs +++ b/src/completion/default.rs @@ -71,8 +71,11 @@ impl Completer for DefaultCompleter { fn complete(&mut self, line: &str, pos: usize) -> Vec { let mut span_line_whitespaces = 0; let mut completions = vec![]; + // Trimming in case someone passes in text containing stuff after the cursor, if + // `only_buffer_difference` is false + let line = if line.len() > pos { &line[..pos] } else { line }; if !line.is_empty() { - let mut split = line[0..pos].split(' ').rev(); + let mut split = line.split(' ').rev(); let mut span_line: String = String::new(); for _ in 0..split.clone().count() { if let Some(s) = split.next() { diff --git a/src/completion/history.rs b/src/completion/history.rs index ea6aedb6..324e3a17 100644 --- a/src/completion/history.rs +++ b/src/completion/history.rs @@ -52,8 +52,8 @@ impl<'menu> HistoryCompleter<'menu> { fn create_suggestion(&self, line: &str, pos: usize, value: &str) -> Suggestion { let span = Span { - start: pos, - end: pos + line.len(), + start: pos - line.len(), + end: pos, }; Suggestion { diff --git a/src/edit_mode/vi/command.rs b/src/edit_mode/vi/command.rs index e86f9f31..7159acb9 100644 --- a/src/edit_mode/vi/command.rs +++ b/src/edit_mode/vi/command.rs @@ -210,12 +210,10 @@ impl Command { ReedlineOption::Edit(EditCommand::MoveToStart), ReedlineOption::Edit(EditCommand::ClearToLineEnd), ]), - Motion::NextWord => { - Some(vec![ReedlineOption::Edit(EditCommand::CutWordRightToNext)]) + Motion::NextWord => Some(vec![ReedlineOption::Edit(EditCommand::CutWordRight)]), + Motion::NextBigWord => { + Some(vec![ReedlineOption::Edit(EditCommand::CutBigWordRight)]) } - Motion::NextBigWord => Some(vec![ReedlineOption::Edit( - EditCommand::CutBigWordRightToNext, - )]), Motion::NextWordEnd => { Some(vec![ReedlineOption::Edit(EditCommand::CutWordRight)]) } diff --git a/src/edit_mode/vi/parser.rs b/src/edit_mode/vi/parser.rs index f243e53b..e04bd5a6 100644 --- a/src/edit_mode/vi/parser.rs +++ b/src/edit_mode/vi/parser.rs @@ -67,7 +67,7 @@ impl ParsedViSequence { /// /// ### Note: /// - /// https://github.com/vim/vim/blob/140f6d0eda7921f2f0b057ec38ed501240903fc3/runtime/doc/motion.txt#L64-L70 + /// fn total_multiplier(&self) -> usize { self.multiplier.unwrap_or(1) * self.count.unwrap_or(1) } diff --git a/src/engine.rs b/src/engine.rs index c87afac8..30bb43bc 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1,7 +1,5 @@ use std::path::PathBuf; -use crossterm::event::{DisableBracketedPaste, EnableBracketedPaste}; -use crossterm::execute; use itertools::Itertools; use crate::{enums::ReedlineRawEvent, CursorConfig}; @@ -31,6 +29,7 @@ use { painting::{Painter, PromptLines}, prompt::{PromptEditMode, PromptHistorySearchStatus}, result::{ReedlineError, ReedlineErrorVariants}, + terminal_extensions::{bracketed_paste::BracketedPasteGuard, kitty::KittyProtocolGuard}, utils::text_manipulation, EditCommand, ExampleHighlighter, Highlighter, LineBuffer, Menu, MenuEvent, Prompt, PromptHistorySearch, ReedlineMenu, Signal, UndoBehavior, ValidationResult, Validator, @@ -144,11 +143,11 @@ pub struct Reedline { // Use different cursors depending on the current edit mode cursor_shapes: Option, - // Indicate if global terminal have enabled BracketedPaste - bracket_paste_enabled: bool, + // Manage bracketed paste mode + bracketed_paste: BracketedPasteGuard, - // Use kitty protocol to handle escape code input or not - use_kitty_protocol: bool, + // Manage optional kitty protocol + kitty_protocol: KittyProtocolGuard, #[cfg(feature = "external_printer")] external_printer: Option>, @@ -172,12 +171,6 @@ impl Drop for Reedline { // Ensures that the terminal is in a good state if we panic semigracefully // Calling `disable_raw_mode()` twice is fine with Linux let _ignore = terminal::disable_raw_mode(); - if self.bracket_paste_enabled { - let _ = execute!(io::stdout(), DisableBracketedPaste); - } - if self.use_kitty_protocol { - let _ = execute!(io::stdout(), event::PopKeyboardEnhancementFlags); - } } } @@ -223,8 +216,8 @@ impl Reedline { menus: Vec::new(), buffer_editor: None, cursor_shapes: None, - bracket_paste_enabled: false, - use_kitty_protocol: false, + bracketed_paste: BracketedPasteGuard::default(), + kitty_protocol: KittyProtocolGuard::default(), #[cfg(feature = "external_printer")] external_printer: None, } @@ -240,41 +233,31 @@ impl Reedline { Some(HistorySessionId::new(nanos)) } - /// Enable BracketedPaste feature. - pub fn enable_bracketed_paste(&mut self) -> Result<()> { - let res = execute!(io::stdout(), EnableBracketedPaste); - if res.is_ok() { - self.bracket_paste_enabled = true; - } - res - } - - /// Disable BracketedPaste feature. - pub fn disable_bracketed_paste(&mut self) -> Result<()> { - let res = execute!(io::stdout(), DisableBracketedPaste); - if res.is_ok() { - self.bracket_paste_enabled = false; - } - res - } - - /// Return terminal support on keyboard enhancement - pub fn can_use_kitty_protocol(&mut self) -> bool { - if let Ok(b) = crossterm::terminal::supports_keyboard_enhancement() { - b - } else { - false - } - } - - /// Enable keyboard enhancement to disambiguate escape code - pub fn enable_kitty_protocol(&mut self) { - self.use_kitty_protocol = true; + /// Toggle whether reedline enables bracketed paste to reed copied content + /// + /// This currently alters the behavior for multiline pastes as pasting of regular text will + /// execute after every complete new line as determined by the [`Validator`]. With enabled + /// bracketed paste all lines will appear in the buffer and can then be submitted with a + /// separate enter. + /// + /// At this point most terminals should support it or ignore the setting of the necessary + /// flags. For full compatibility, keep it disabled. + pub fn use_bracketed_paste(mut self, enable: bool) -> Self { + self.bracketed_paste.set(enable); + self } - /// Disable keyboard enhancement to disambiguate escape code - pub fn disable_kitty_protocol(&mut self) { - self.use_kitty_protocol = false; + /// Toggle whether reedline uses the kitty keyboard enhancement protocol + /// + /// This allows us to disambiguate more events than the traditional standard + /// Only available with a few terminal emulators. + /// You can check for that with [`crate::kitty_protocol_available`] + /// `Reedline` will perform this check internally + /// + /// Read more: + pub fn use_kitty_keyboard_enhancement(mut self, enable: bool) -> Self { + self.kitty_protocol.set(enable); + self } /// Return the previously generated history session id @@ -627,16 +610,13 @@ impl Reedline { /// and the `Ok` variant wraps a [`Signal`] which handles user inputs. pub fn read_line(&mut self, prompt: &dyn Prompt) -> Result { terminal::enable_raw_mode()?; + self.bracketed_paste.enter(); + self.kitty_protocol.enter(); let result = self.read_line_helper(prompt); - #[cfg(not(target_os = "windows"))] - self.disable_bracketed_paste()?; - - if self.use_kitty_protocol { - let _ = execute!(io::stdout(), event::PopKeyboardEnhancementFlags); - } - + self.bracketed_paste.exit(); + self.kitty_protocol.exit(); terminal::disable_raw_mode()?; result } @@ -682,31 +662,6 @@ impl Reedline { let mut crossterm_events: Vec = vec![]; let mut reedline_events: Vec = vec![]; - if self.use_kitty_protocol { - if let Ok(true) = crossterm::terminal::supports_keyboard_enhancement() { - // enable kitty protocol - // - // Note that, currently, only the following support this protocol: - // * [kitty terminal](https://sw.kovidgoyal.net/kitty/) - // * [foot terminal](https://codeberg.org/dnkl/foot/issues/319) - // * [WezTerm terminal](https://wezfurlong.org/wezterm/config/lua/config/enable_kitty_keyboard.html) - // * [notcurses library](https://github.com/dankamongmen/notcurses/issues/2131) - // * [neovim text editor](https://github.com/neovim/neovim/pull/18181) - // * [kakoune text editor](https://github.com/mawww/kakoune/issues/4103) - // * [dte text editor](https://gitlab.com/craigbarnes/dte/-/issues/138) - // - // Refer to https://sw.kovidgoyal.net/kitty/keyboard-protocol/ if you're curious. - let _ = execute!( - io::stdout(), - event::PushKeyboardEnhancementFlags( - event::KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES - ) - ); - } else { - // TODO: Log or warning - } - } - loop { let mut paste_enter_state = false; @@ -725,77 +680,74 @@ impl Reedline { } } - if event::poll(Duration::from_millis(100))? { - let mut latest_resize = None; - - // There could be multiple events queued up! - // pasting text, resizes, blocking this thread (e.g. during debugging) - // We should be able to handle all of them as quickly as possible without causing unnecessary output steps. - while event::poll(Duration::from_millis(POLL_WAIT))? { - match event::read()? { - Event::Resize(x, y) => { - latest_resize = Some((x, y)); - } - enter @ Event::Key(KeyEvent { - code: KeyCode::Enter, - modifiers: KeyModifiers::NONE, - .. - }) => { - let enter = ReedlineRawEvent::convert_from(enter); - match enter { - Some(enter) => { - crossterm_events.push(enter); - // Break early to check if the input is complete and - // can be send to the hosting application. If - // multiple complete entries are submitted, events - // are still in the crossterm queue for us to - // process. - paste_enter_state = crossterm_events.len() > EVENTS_THRESHOLD; - break; - } - None => continue, - } + let mut latest_resize = None; + loop { + match event::read()? { + Event::Resize(x, y) => { + latest_resize = Some((x, y)); + } + enter @ Event::Key(KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + }) => { + let enter = ReedlineRawEvent::convert_from(enter); + if let Some(enter) = enter { + crossterm_events.push(enter); + // Break early to check if the input is complete and + // can be send to the hosting application. If + // multiple complete entries are submitted, events + // are still in the crossterm queue for us to + // process. + paste_enter_state = crossterm_events.len() > EVENTS_THRESHOLD; + break; } - x => { - let raw_event = ReedlineRawEvent::convert_from(x); - match raw_event { - Some(evt) => crossterm_events.push(evt), - None => continue, - } + } + x => { + let raw_event = ReedlineRawEvent::convert_from(x); + if let Some(evt) = raw_event { + crossterm_events.push(evt); } } } - if let Some((x, y)) = latest_resize { - reedline_events.push(ReedlineEvent::Resize(x, y)); + // There could be multiple events queued up! + // pasting text, resizes, blocking this thread (e.g. during debugging) + // We should be able to handle all of them as quickly as possible without causing unnecessary output steps. + if !event::poll(Duration::from_millis(POLL_WAIT))? { + break; } + } - // Accelerate pasted text by fusing `EditCommand`s - // - // (Text should only be `EditCommand::InsertChar`s) - let mut last_edit_commands = None; - for event in crossterm_events.drain(..) { - match (&mut last_edit_commands, self.edit_mode.parse_event(event)) { - (None, ReedlineEvent::Edit(ec)) => { - last_edit_commands = Some(ec); - } - (None, other_event) => { - reedline_events.push(other_event); - } - (Some(ref mut last_ecs), ReedlineEvent::Edit(ec)) => { - last_ecs.extend(ec); - } - (ref mut a @ Some(_), other_event) => { - reedline_events.push(ReedlineEvent::Edit(a.take().unwrap())); + if let Some((x, y)) = latest_resize { + reedline_events.push(ReedlineEvent::Resize(x, y)); + } - reedline_events.push(other_event); - } + // Accelerate pasted text by fusing `EditCommand`s + // + // (Text should only be `EditCommand::InsertChar`s) + let mut last_edit_commands = None; + for event in crossterm_events.drain(..) { + match (&mut last_edit_commands, self.edit_mode.parse_event(event)) { + (None, ReedlineEvent::Edit(ec)) => { + last_edit_commands = Some(ec); + } + (None, other_event) => { + reedline_events.push(other_event); + } + (Some(ref mut last_ecs), ReedlineEvent::Edit(ec)) => { + last_ecs.extend(ec); + } + (ref mut a @ Some(_), other_event) => { + reedline_events.push(ReedlineEvent::Edit(a.take().unwrap())); + + reedline_events.push(other_event); } } - if let Some(ec) = last_edit_commands { - reedline_events.push(ReedlineEvent::Edit(ec)); - } - }; + } + if let Some(ec) = last_edit_commands { + reedline_events.push(ReedlineEvent::Edit(ec)); + } for event in reedline_events.drain(..) { match self.handle_event(prompt, event)? { @@ -1586,7 +1538,7 @@ impl Reedline { self.get_history_session_id(), ))) .unwrap_or_else(|_| Vec::new()) - .get(0) + .first() .and_then(|history| history.command_line.split_whitespace().next_back()) .map(|token| (parsed.remainder.len(), indicator.len(), token.to_string())), }); @@ -1745,6 +1697,9 @@ impl Reedline { } /// Adds an external printer + /// + /// ## Required feature: + /// `external_printer` #[cfg(feature = "external_printer")] pub fn with_external_printer(mut self, printer: ExternalPrinter) -> Self { self.external_printer = Some(printer); diff --git a/src/external_printer.rs b/src/external_printer.rs index c2cdf707..f4c71e7c 100644 --- a/src/external_printer.rs +++ b/src/external_printer.rs @@ -17,6 +17,9 @@ pub const EXTERNAL_PRINTER_DEFAULT_CAPACITY: usize = 20; /// An ExternalPrinter allows to print messages of text while editing a line. /// The message is printed as a new line, the line-edit will continue below the /// output. +/// +/// ## Required feature: +/// `external_printer` #[cfg(feature = "external_printer")] #[derive(Debug, Clone)] pub struct ExternalPrinter diff --git a/src/hinter/cwd_aware.rs b/src/hinter/cwd_aware.rs index b30fc1b3..67ab8697 100644 --- a/src/hinter/cwd_aware.rs +++ b/src/hinter/cwd_aware.rs @@ -1,4 +1,5 @@ use crate::{ + hinter::get_first_token, history::SearchQuery, result::{ReedlineError, ReedlineErrorVariants::HistoryFeatureUnsupported}, Hinter, History, @@ -23,7 +24,7 @@ impl Hinter for CwdAwareHinter { use_ansi_coloring: bool, ) -> String { self.current_hint = if line.chars().count() >= self.min_chars { - history + let with_cwd = history .search(SearchQuery::last_with_prefix_and_cwd( line.to_string(), history.session(), @@ -38,15 +39,29 @@ impl Hinter for CwdAwareHinter { Err(err) } }) - .expect("todo: error handling") - .first() - .map_or_else(String::new, |entry| { - entry - .command_line - .get(line.len()..) - .unwrap_or_default() - .to_string() - }) + .unwrap_or_default(); + if !with_cwd.is_empty() { + with_cwd[0] + .command_line + .get(line.len()..) + .unwrap_or_default() + .to_string() + } else { + history + .search(SearchQuery::last_with_prefix( + line.to_string(), + history.session(), + )) + .unwrap_or_default() + .first() + .map_or_else(String::new, |entry| { + entry + .command_line + .get(line.len()..) + .unwrap_or_default() + .to_string() + }) + } } else { String::new() }; @@ -63,21 +78,7 @@ impl Hinter for CwdAwareHinter { } fn next_hint_token(&self) -> String { - let mut reached_content = false; - let result: String = self - .current_hint - .chars() - .take_while(|c| match (c.is_whitespace(), reached_content) { - (true, true) => false, - (true, false) => true, - (false, true) => true, - (false, false) => { - reached_content = true; - true - } - }) - .collect(); - result + get_first_token(&self.current_hint) } } diff --git a/src/hinter/default.rs b/src/hinter/default.rs index 583fd0c4..08ae57e8 100644 --- a/src/hinter/default.rs +++ b/src/hinter/default.rs @@ -1,4 +1,4 @@ -use crate::{history::SearchQuery, Hinter, History}; +use crate::{hinter::get_first_token, history::SearchQuery, Hinter, History}; use nu_ansi_term::{Color, Style}; /// A hinter that uses the completions or the history to show a hint to the user @@ -47,21 +47,7 @@ impl Hinter for DefaultHinter { } fn next_hint_token(&self) -> String { - let mut reached_content = false; - let result: String = self - .current_hint - .chars() - .take_while(|c| match (c.is_whitespace(), reached_content) { - (true, true) => false, - (true, false) => true, - (false, true) => true, - (false, false) => { - reached_content = true; - true - } - }) - .collect(); - result + get_first_token(&self.current_hint) } } diff --git a/src/hinter/mod.rs b/src/hinter/mod.rs index d0a86eca..cf6f4701 100644 --- a/src/hinter/mod.rs +++ b/src/hinter/mod.rs @@ -3,6 +3,30 @@ mod default; pub use cwd_aware::CwdAwareHinter; pub use default::DefaultHinter; +use unicode_segmentation::UnicodeSegmentation; + +pub fn is_whitespace_str(s: &str) -> bool { + s.chars().all(char::is_whitespace) +} + +pub fn get_first_token(string: &str) -> String { + let mut reached_content = false; + let result = string + .split_word_bounds() + .take_while(|word| match (is_whitespace_str(word), reached_content) { + (_, true) => false, + (true, false) => true, + (false, false) => { + reached_content = true; + true + } + }) + .collect::>() + .join("") + .to_string(); + result +} + use crate::History; /// A trait that's responsible for returning the hint for the current line and position /// Hints are often shown in-line as part of the buffer, showing the user text they can accept or ignore diff --git a/src/history/item.rs b/src/history/item.rs index 355104b9..d94af11b 100644 --- a/src/history/item.rs +++ b/src/history/item.rs @@ -5,10 +5,11 @@ use serde::{de::DeserializeOwned, Deserialize, Serialize}; use std::{fmt::Display, time::Duration}; /// Unique ID for the [`HistoryItem`]. More recent items have higher ids than older ones. -#[derive(Debug, Copy, Clone, PartialEq, Eq)] -pub struct HistoryItemId(pub(crate) i64); +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct HistoryItemId(pub i64); impl HistoryItemId { - pub(crate) const fn new(i: i64) -> HistoryItemId { + /// Create a new `HistoryItemId` value + pub const fn new(i: i64) -> HistoryItemId { HistoryItemId(i) } } @@ -20,7 +21,7 @@ impl Display for HistoryItemId { } /// Unique ID for the session in which reedline was run to disambiguate different sessions -#[derive(Debug, Copy, Clone, PartialEq, Eq)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct HistorySessionId(pub(crate) i64); impl HistorySessionId { pub(crate) const fn new(i: i64) -> HistorySessionId { @@ -77,7 +78,7 @@ impl<'de> Deserialize<'de> for IgnoreAllExtraInfo { impl HistoryItemExtraInfo for IgnoreAllExtraInfo {} /// Represents one run command with some optional additional context -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct HistoryItem { /// primary key, unique across one history pub id: Option, @@ -97,6 +98,10 @@ pub struct HistoryItem { /// the exit status of the command pub exit_status: Option, /// arbitrary additional information that might be interesting + /// NOTE: this attribute is required because of + /// + /// (see for the fix) + #[serde(deserialize_with = "Option::::deserialize")] pub more_info: Option, } diff --git a/src/history/sqlite_backed.rs b/src/history/sqlite_backed.rs index 7ab56e96..ec3b021a 100644 --- a/src/history/sqlite_backed.rs +++ b/src/history/sqlite_backed.rs @@ -14,6 +14,9 @@ const SQLITE_APPLICATION_ID: i32 = 1151497937; /// A history that stores the values to an SQLite database. /// In addition to storing the command, the history can store an additional arbitrary HistoryEntryContext, /// to add information such as a timestamp, running directory, result... +/// +/// ## Required feature: +/// `sqlite` or `sqlite-dynlib` pub struct SqliteBackedHistory { db: rusqlite::Connection, session: Option, diff --git a/src/lib.rs b/src/lib.rs index 83fb2a9e..24eeaae1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -239,7 +239,7 @@ mod engine; pub use engine::Reedline; mod result; -pub(crate) use result::Result; +pub use result::{ReedlineError, ReedlineErrorVariants, Result}; mod history; #[cfg(any(feature = "sqlite", feature = "sqlite-dynlib"))] @@ -280,6 +280,9 @@ pub use menu::{ menu_functions, ColumnarMenu, ListMenu, Menu, MenuEvent, MenuTextStyle, ReedlineMenu, }; +mod terminal_extensions; +pub use terminal_extensions::kitty_protocol_available; + mod utils; mod external_printer; diff --git a/src/menu/columnar_menu.rs b/src/menu/columnar_menu.rs index ec391777..980c706e 100644 --- a/src/menu/columnar_menu.rs +++ b/src/menu/columnar_menu.rs @@ -526,13 +526,16 @@ impl Menu for ColumnarMenu { /// Updates menu values fn update_values(&mut self, editor: &mut Editor, completer: &mut dyn Completer) { - if self.only_buffer_difference { + self.values = if self.only_buffer_difference { if let Some(old_string) = &self.input { let (start, input) = string_difference(editor.get_buffer(), old_string); if !input.is_empty() { - self.values = completer.complete(input, start); - self.reset_position(); + completer.complete(input, start + input.len()) + } else { + completer.complete("", editor.insertion_point()) } + } else { + completer.complete("", editor.insertion_point()) } } else { // If there is a new line character in the line buffer, the completer @@ -541,9 +544,13 @@ impl Menu for ColumnarMenu { // Also, by replacing the new line character with a space, the insert // position is maintain in the line buffer. let trimmed_buffer = editor.get_buffer().replace('\n', " "); - self.values = completer.complete(trimmed_buffer.as_str(), editor.insertion_point()); - self.reset_position(); - } + completer.complete( + &trimmed_buffer[..editor.insertion_point()], + editor.insertion_point(), + ) + }; + + self.reset_position(); } /// The working details for the menu changes based on the size of the lines diff --git a/src/menu/list_menu.rs b/src/menu/list_menu.rs index 5b10e68d..d90e3672 100644 --- a/src/menu/list_menu.rs +++ b/src/menu/list_menu.rs @@ -395,20 +395,23 @@ impl Menu for ListMenu { /// Collecting the value from the completer to be shown in the menu fn update_values(&mut self, editor: &mut Editor, completer: &mut dyn Completer) { let line_buffer = editor.line_buffer(); - let (start, input) = if self.only_buffer_difference { + let (pos, input) = if self.only_buffer_difference { match &self.input { Some(old_string) => { let (start, input) = string_difference(line_buffer.get_buffer(), old_string); if input.is_empty() { (line_buffer.insertion_point(), "") } else { - (start, input) + (start + input.len(), input) } } None => (line_buffer.insertion_point(), ""), } } else { - (line_buffer.insertion_point(), line_buffer.get_buffer()) + ( + line_buffer.insertion_point(), + &line_buffer.get_buffer()[..line_buffer.insertion_point()], + ) }; let parsed = parse_selection_char(input, SELECTION_CHAR); @@ -421,7 +424,7 @@ impl Menu for ListMenu { } self.values = if parsed.remainder.is_empty() { - self.query_size = Some(completer.total_completions(parsed.remainder, start)); + self.query_size = Some(completer.total_completions(parsed.remainder, pos)); let skip = self.pages.iter().take(self.page).sum::().size; let take = self @@ -430,10 +433,10 @@ impl Menu for ListMenu { .map(|page| page.size) .unwrap_or(self.page_size); - completer.partial_complete(input, start, skip, take) + completer.partial_complete(input, pos, skip, take) } else { self.query_size = None; - completer.complete(input, start) + completer.complete(input, pos) } } diff --git a/src/menu/menu_functions.rs b/src/menu/menu_functions.rs index 8d15d696..f3400f4e 100644 --- a/src/menu/menu_functions.rs +++ b/src/menu/menu_functions.rs @@ -209,7 +209,7 @@ pub fn string_difference<'a>(new_string: &'a str, old_string: &str) -> (usize, & false } } else { - *c == old_chars[old_char_index].1 + old_char_index == new_char_index && *c == old_chars[old_char_index].1 }; if equal { @@ -479,6 +479,15 @@ mod tests { assert_eq!(res, (6, "she")); } + #[test] + fn string_difference_with_repeat() { + let new_string = "ee"; + let old_string = "e"; + + let res = string_difference(new_string, old_string); + assert_eq!(res, (1, "e")); + } + #[test] fn find_common_string_with_ansi() { use crate::Span; diff --git a/src/painting/painter.rs b/src/painting/painter.rs index e6dc79a8..1c6769be 100644 --- a/src/painting/painter.rs +++ b/src/painting/painter.rs @@ -82,7 +82,7 @@ impl Painter { /// Returns the available lines from the prompt down pub fn remaining_lines(&self) -> u16 { - self.screen_height() - self.prompt_start_row + self.screen_height().saturating_sub(self.prompt_start_row) } /// Sets the prompt origin position and screen size for a new line editor @@ -151,8 +151,17 @@ impl Painter { // Marking the painter state as larger buffer to avoid animations self.large_buffer = required_lines >= screen_height; + // This might not be terribly performant. Testing it out + let is_reset = || match cursor::position() { + // when output something without newline, the cursor position is at current line. + // but the prompt_start_row is next line. + // in this case we don't want to reset, need to `add 1` to handle for such case. + Ok(position) => position.1 + 1 < self.prompt_start_row, + Err(_) => false, + }; + // Moving the start position of the cursor based on the size of the required lines - if self.large_buffer { + if self.large_buffer || is_reset() { self.prompt_start_row = 0; } else if required_lines >= remaining_lines { let extra = required_lines.saturating_sub(remaining_lines); @@ -380,6 +389,12 @@ impl Painter { if let Some(menu) = menu { // TODO: Also solve the difficult problem of displaying (parts of) // the content after the cursor with the completion menu + // This only shows the rest of the line the cursor is on + if let Some(newline) = lines.after_cursor.find('\n') { + self.stdout.queue(Print(&lines.after_cursor[0..newline]))?; + } else { + self.stdout.queue(Print(&lines.after_cursor))?; + } self.print_menu(menu, lines, use_ansi_coloring)?; } else { // Selecting lines for the hint @@ -400,31 +415,22 @@ impl Painter { /// Updates prompt origin and offset to handle a screen resize event pub(crate) fn handle_resize(&mut self, width: u16, height: u16) { - let prev_terminal_size = self.terminal_size; - let prev_prompt_row = self.prompt_start_row; - self.terminal_size = (width, height); - if prev_prompt_row < height - && height <= prev_terminal_size.1 - && width == prev_terminal_size.0 - { - // The terminal got smaller in height but the start of the prompt is still visible - // The width didn't change - return; + // `cursor::position() is blocking and can timeout. + // The question is whether we can afford it. If not, perhaps we should use it in some scenarios but not others + // The problem is trying to calculate this internally doesn't seem to be reliable because terminals might + // have additional text in their buffer that messes with the offset on scroll. + // It seems like it _should_ be ok because it only happens on resize. + + // Known bug: on iterm2 and kitty, clearing the screen via CMD-K doesn't reset + // the position. Might need to special-case this. + // + // I assume this is a bug with the position() call but haven't figured that + // out yet. + if let Ok(position) = cursor::position() { + self.prompt_start_row = position.1; } - - // Either: - // - The terminal got larger in height - // - Note: if the terminal doesn't have sufficient history, this will leave a trail - // of previous prompts currently. - // - Note: if the the prompt contains multiple lines, this will leave a trail of - // previous prompts currently. - // - The terminal got smaller in height and the whole prompt is no longer visible - // - Note: if the the prompt contains multiple lines, this will leave a trail of - // previous prompts currently. - // - The width changed - self.prompt_start_row = height.saturating_sub(1); } /// Writes `line` to the terminal with a following carriage return and newline diff --git a/src/painting/prompt_lines.rs b/src/painting/prompt_lines.rs index 30c1ca61..aa9c35de 100644 --- a/src/painting/prompt_lines.rs +++ b/src/painting/prompt_lines.rs @@ -7,6 +7,7 @@ use crate::{ use std::borrow::Cow; /// Aggregate of prompt and input string used by `Painter` +#[derive(Debug)] pub(crate) struct PromptLines<'prompt> { pub(crate) prompt_str_left: Cow<'prompt, str>, pub(crate) prompt_str_right: Cow<'prompt, str>, diff --git a/src/result.rs b/src/result.rs index 51703816..e4c7344b 100644 --- a/src/result.rs +++ b/src/result.rs @@ -3,25 +3,35 @@ use thiserror::Error; /// non-public (for now) #[derive(Error, Debug)] -pub(crate) enum ReedlineErrorVariants { +pub enum ReedlineErrorVariants { // todo: we should probably be more specific here #[cfg(any(feature = "sqlite", feature = "sqlite-dynlib"))] + /// Error within history database #[error("error within history database: {0}")] HistoryDatabaseError(String), + + /// Error within history #[error("error within history: {0}")] OtherHistoryError(&'static str), + + /// History does not support a feature #[error("the history {history} does not support feature {feature}")] HistoryFeatureUnsupported { + /// Custom display name for the history history: &'static str, + + /// Unsupported feature feature: &'static str, }, + + /// I/O error #[error("I/O error: {0}")] IOError(std::io::Error), } /// separate struct to not expose anything to the public (for now) #[derive(Debug)] -pub struct ReedlineError(pub(crate) ReedlineErrorVariants); +pub struct ReedlineError(pub ReedlineErrorVariants); impl Display for ReedlineError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -30,5 +40,5 @@ impl Display for ReedlineError { } impl std::error::Error for ReedlineError {} -// for now don't expose the above error type to the public +/// Standard [`std::result::Result`], with [`ReedlineError`] as the error variant pub type Result = std::result::Result; diff --git a/src/terminal_extensions/bracketed_paste.rs b/src/terminal_extensions/bracketed_paste.rs new file mode 100644 index 00000000..81eea476 --- /dev/null +++ b/src/terminal_extensions/bracketed_paste.rs @@ -0,0 +1,36 @@ +use crossterm::{event, execute}; + +/// Helper managing proper setup and teardown of bracketed paste mode +/// +/// +#[derive(Default)] +pub(crate) struct BracketedPasteGuard { + enabled: bool, + active: bool, +} + +impl BracketedPasteGuard { + pub fn set(&mut self, enable: bool) { + self.enabled = enable; + } + pub fn enter(&mut self) { + if self.enabled && !self.active { + let _ = execute!(std::io::stdout(), event::EnableBracketedPaste); + self.active = true; + } + } + pub fn exit(&mut self) { + if self.active { + let _ = execute!(std::io::stdout(), event::DisableBracketedPaste); + self.active = false; + } + } +} + +impl Drop for BracketedPasteGuard { + fn drop(&mut self) { + if self.active { + let _ = execute!(std::io::stdout(), event::DisableBracketedPaste); + } + } +} diff --git a/src/terminal_extensions/kitty.rs b/src/terminal_extensions/kitty.rs new file mode 100644 index 00000000..65fb6e34 --- /dev/null +++ b/src/terminal_extensions/kitty.rs @@ -0,0 +1,51 @@ +use crossterm::{event, execute}; + +/// Helper managing proper setup and teardown of the kitty keyboard enhancement protocol +/// +/// Note that, currently, only the following support this protocol: +/// * [kitty terminal](https://sw.kovidgoyal.net/kitty/) +/// * [foot terminal](https://codeberg.org/dnkl/foot/issues/319) +/// * [WezTerm terminal](https://wezfurlong.org/wezterm/config/lua/config/enable_kitty_keyboard.html) +/// * [notcurses library](https://github.com/dankamongmen/notcurses/issues/2131) +/// * [neovim text editor](https://github.com/neovim/neovim/pull/18181) +/// * [kakoune text editor](https://github.com/mawww/kakoune/issues/4103) +/// * [dte text editor](https://gitlab.com/craigbarnes/dte/-/issues/138) +/// +/// Refer to if you're curious. +#[derive(Default)] +pub(crate) struct KittyProtocolGuard { + enabled: bool, + active: bool, +} + +impl KittyProtocolGuard { + pub fn set(&mut self, enable: bool) { + self.enabled = enable && super::kitty_protocol_available(); + } + pub fn enter(&mut self) { + if self.enabled && !self.active { + let _ = execute!( + std::io::stdout(), + event::PushKeyboardEnhancementFlags( + event::KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES + ) + ); + + self.active = true; + } + } + pub fn exit(&mut self) { + if self.active { + let _ = execute!(std::io::stdout(), event::PopKeyboardEnhancementFlags); + self.active = false; + } + } +} + +impl Drop for KittyProtocolGuard { + fn drop(&mut self) { + if self.active { + let _ = execute!(std::io::stdout(), event::PopKeyboardEnhancementFlags); + } + } +} diff --git a/src/terminal_extensions/mod.rs b/src/terminal_extensions/mod.rs new file mode 100644 index 00000000..b6f2dbeb --- /dev/null +++ b/src/terminal_extensions/mod.rs @@ -0,0 +1,11 @@ +pub(crate) mod bracketed_paste; +pub(crate) mod kitty; + +/// Return if the terminal supports the kitty keyboard enhancement protocol +/// +/// Read more: +/// +/// SIDE EFFECT: Touches the terminal file descriptors +pub fn kitty_protocol_available() -> bool { + crossterm::terminal::supports_keyboard_enhancement().unwrap_or_default() +}