From 278e29870f81350abbcdb3e7820713eafc9faac3 Mon Sep 17 00:00:00 2001 From: Luis Alberto Santos Date: Sun, 14 May 2023 17:42:54 +0200 Subject: [PATCH] refactor and improvements --- .gitignore | 1 + .vscode/tasks.json | 3 + Cargo.lock | 2 +- Cargo.toml | 3 +- intelli-shell.fish | 38 +-- intelli-shell.sh | 77 ++--- src/common.rs | 498 ------------------------------ src/common/misc.rs | 137 ++++++++ src/common/mod.rs | 7 + src/common/process.rs | 173 +++++++++++ src/common/widget/command.rs | 19 ++ src/common/widget/label.rs | 83 +++++ src/common/widget/list.rs | 209 +++++++++++++ src/common/widget/mod.rs | 137 ++++++++ src/common/widget/text.rs | 384 +++++++++++++++++++++++ src/debug.rs | 37 +++ src/lib.rs | 5 +- src/main.rs | 60 ++-- src/model/command.rs | 4 + src/{widgets => process}/fetch.rs | 24 +- src/process/label.rs | 274 ++++++++++++++++ src/{widgets => process}/mod.rs | 0 src/process/save.rs | 152 +++++++++ src/process/search.rs | 216 +++++++++++++ src/widgets/label.rs | 325 ------------------- src/widgets/save.rs | 164 ---------- src/widgets/search.rs | 257 --------------- 27 files changed, 1933 insertions(+), 1356 deletions(-) delete mode 100644 src/common.rs create mode 100644 src/common/misc.rs create mode 100644 src/common/mod.rs create mode 100644 src/common/process.rs create mode 100644 src/common/widget/command.rs create mode 100644 src/common/widget/label.rs create mode 100644 src/common/widget/list.rs create mode 100644 src/common/widget/mod.rs create mode 100644 src/common/widget/text.rs create mode 100644 src/debug.rs rename src/{widgets => process}/fetch.rs (60%) create mode 100644 src/process/label.rs rename src/{widgets => process}/mod.rs (100%) create mode 100644 src/process/save.rs create mode 100644 src/process/search.rs delete mode 100644 src/widgets/label.rs delete mode 100644 src/widgets/save.rs delete mode 100644 src/widgets/search.rs diff --git a/.gitignore b/.gitignore index 26ed3a0..fccc91c 100644 --- a/.gitignore +++ b/.gitignore @@ -108,3 +108,4 @@ target ### Local ### .env .vscode/launch.json +debug.log diff --git a/.vscode/tasks.json b/.vscode/tasks.json index d11f408..380f03c 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -6,6 +6,9 @@ { "type": "cargo", "command": "build", + "args": [ + "--features=debug" + ], "problemMatcher": [ "$rustc" ], diff --git a/Cargo.lock b/Cargo.lock index caff093..12b007f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -418,7 +418,7 @@ dependencies = [ [[package]] name = "intelli-shell" -version = "0.2.1" +version = "0.2.2" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index d216917..fc6ed59 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "intelli-shell" description = "Like IntelliSense, but for shells" -version = "0.2.1" +version = "0.2.2" edition = "2021" license = "Apache-2.0" readme = "README.md" @@ -17,6 +17,7 @@ required-features = [] [features] default = ["tldr"] tldr = ["dep:git2", "dep:tempfile"] +debug = [] [dependencies] anyhow = "1" diff --git a/intelli-shell.fish b/intelli-shell.fish index 28a56f4..161e2bd 100755 --- a/intelli-shell.fish +++ b/intelli-shell.fish @@ -2,46 +2,38 @@ function intelli-shell --description 'IntelliShell' $INTELLI_HOME/bin/intelli-shell $argv; end -function _intelli_search - set LINE (commandline) +function _intelli_exec # Temp file for output set TMP_FILE (mktemp -t intelli-shell.XXXXXXXX) + set TMP_FILE_MSG (mktemp -t intelli-shell.XXXXXXXX) # Exec command - intelli-shell --inline --inline-extra-line --file-output="$TMP_FILE" search "$LINE" + intelli-shell --inline --inline-extra-line --file-output="$TMP_FILE" $argv 2> $TMP_FILE_MSG # Capture output set INTELLI_OUTPUT (cat "$TMP_FILE" | string collect) + set INTELLI_MESSAGE (cat "$TMP_FILE_MSG" | string collect) rm -f $TMP_FILE + rm -f $TMP_FILE_MSG + if test -n "$INTELLI_MESSAGE" + echo $INTELLI_MESSAGE + end # Replace line commandline -f repaint commandline -r "$INTELLI_OUTPUT" end +function _intelli_search + set LINE (commandline) + _intelli_exec search "$LINE" +end + function _intelli_save set LINE (commandline) - # Temp file for output - set TMP_FILE (mktemp -t intelli-shell.XXXXXXXX) - # Exec command - intelli-shell --inline --inline-extra-line --file-output="$TMP_FILE" save "$LINE" - # Capture output - set INTELLI_OUTPUT (cat "$TMP_FILE" | string collect) - rm -f $TMP_FILE - # Replace line - commandline -f repaint - commandline -r "$INTELLI_OUTPUT" + _intelli_exec save "$LINE" end function _intelli_label set LINE (commandline) - # Temp file for output - set TMP_FILE (mktemp -t intelli-shell.XXXXXXXX) - # Exec command - intelli-shell --inline --inline-extra-line --file-output="$TMP_FILE" label "$LINE" - # Capture output - set INTELLI_OUTPUT (cat "$TMP_FILE" | string collect) - rm -f $TMP_FILE - # Replace line - commandline -f repaint - commandline -r "$INTELLI_OUTPUT" + _intelli_exec label "$LINE" end function fish_user_key_bindings diff --git a/intelli-shell.sh b/intelli-shell.sh index dbb1f32..cfaba50 100755 --- a/intelli-shell.sh +++ b/intelli-shell.sh @@ -7,46 +7,38 @@ if [[ -n "$ZSH_VERSION" ]]; then # zshell # https://zsh.sourceforge.io/Guide/zshguide04.html - function _intelli_search { + function _intelli_exec { + ps1_lines=$(echo "$PS1" | wc -l) # Temp file for output tmp_file=$(mktemp -t intelli-shell.XXXXXXXX) + tmp_file_msg=$(mktemp -t intelli-shell.XXXXXXXX) # Exec command - intelli-shell --inline --inline-extra-line --file-output="$tmp_file" search "$BUFFER" + intelli-shell --inline --inline-extra-line --file-output="$tmp_file" "$@" 2> $tmp_file_msg # Capture output INTELLI_OUTPUT=$(<$tmp_file) + INTELLI_MESSAGE=$(<$tmp_file_msg) rm -f $tmp_file + rm -f $tmp_file_msg + if [[ -n "$INTELLI_MESSAGE" ]]; then + echo "$INTELLI_MESSAGE" + [[ "$ps1_lines" -gt 1 ]] && echo "" + fi # Rewrite line zle reset-prompt BUFFER="$INTELLI_OUTPUT" zle end-of-line } + function _intelli_search { + _intelli_exec search "$BUFFER" + } + function _intelli_save { - # Temp file for output - tmp_file=$(mktemp -t intelli-shell.XXXXXXXX) - # Exec command - intelli-shell --inline --inline-extra-line --file-output="$tmp_file" save "$BUFFER" - # Capture output - INTELLI_OUTPUT=$(<$tmp_file) - rm -f $tmp_file - # Rewrite line - zle reset-prompt - BUFFER="$INTELLI_OUTPUT" - zle end-of-line + _intelli_exec save "$BUFFER" } function _intelli_label { - # Temp file for output - tmp_file=$(mktemp -t intelli-shell.XXXXXXXX) - # Exec command - intelli-shell --inline --inline-extra-line --file-output="$tmp_file" label "$BUFFER" - # Capture output - INTELLI_OUTPUT=$(<$tmp_file) - rm -f $tmp_file - # Rewrite line - zle reset-prompt - BUFFER="$INTELLI_OUTPUT" - zle end-of-line + _intelli_exec label "$BUFFER" } if [[ "${INTELLI_SKIP_ESC_BIND:-0}" == "0" ]]; then bindkey "\e" kill-whole-line; fi @@ -61,43 +53,36 @@ elif [[ -n "$BASH" ]]; then # bash # https://www.gnu.org/software/bash/manual/html_node/Bash-Builtins.html#index-bind - function _intelli_search { + function _intelli_exec { + ps1_lines=$(echo "$PS1" | wc -l) # Temp file for output tmp_file=$(mktemp -t intelli-shell.XXXXXXXX) + tmp_file_msg=$(mktemp -t intelli-shell.XXXXXXXX) # Exec command - intelli-shell --inline --file-output="$tmp_file" search "$READLINE_LINE" + intelli-shell --inline --file-output="$tmp_file" "$@" 2> $tmp_file_msg # Capture output INTELLI_OUTPUT=$(<$tmp_file) + INTELLI_MESSAGE=$(<$tmp_file_msg) rm -f $tmp_file + rm -f $tmp_file_msg + if [[ -n "$INTELLI_MESSAGE" ]]; then + echo "$INTELLI_MESSAGE" + fi # Rewrite line READLINE_LINE="$INTELLI_OUTPUT" READLINE_POINT=${#READLINE_LINE} } + function _intelli_search { + _intelli_exec search "$READLINE_LINE" + } + function _intelli_save { - # Temp file for output - tmp_file=$(mktemp -t intelli-shell.XXXXXXXX) - # Exec command - intelli-shell --inline --file-output="$tmp_file" save "$READLINE_LINE" - # Capture output - INTELLI_OUTPUT=$(<$tmp_file) - rm -f $tmp_file - # Rewrite line - READLINE_LINE="$INTELLI_OUTPUT" - READLINE_POINT=${#READLINE_LINE} + _intelli_exec save "$READLINE_LINE" } function _intelli_label { - # Temp file for output - tmp_file=$(mktemp -t intelli-shell.XXXXXXXX) - # Exec command - intelli-shell --inline --file-output="$tmp_file" label "$READLINE_LINE" - # Capture output - INTELLI_OUTPUT=$(<$tmp_file) - rm -f $tmp_file - # Rewrite line - READLINE_LINE="$INTELLI_OUTPUT" - READLINE_POINT=${#READLINE_LINE} + _intelli_exec label "$READLINE_LINE" } if [[ "${INTELLI_SKIP_ESC_BIND:-0}" == "0" ]]; then bind '"\e": kill-whole-line'; fi diff --git a/src/common.rs b/src/common.rs deleted file mode 100644 index 84b5367..0000000 --- a/src/common.rs +++ /dev/null @@ -1,498 +0,0 @@ -use std::fmt::Display; - -use anyhow::Result; -use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; -use itertools::Itertools; -use regex::{CaptureMatches, Captures, Regex}; -use tui::{backend::Backend, layout::Rect, text::Text, widgets::ListState, Frame, Terminal}; -use unicode_segmentation::UnicodeSegmentation; -use unidecode::unidecode; - -use crate::theme::Theme; - -/// Applies [unidecode] to the given string and then converts it to lower case -pub fn flatten_str(s: impl AsRef) -> String { - unidecode(s.as_ref()).to_lowercase() -} - -pub struct WidgetOutput { - pub message: Option, - pub output: Option, -} -impl WidgetOutput { - pub fn new(message: impl Into, output: impl Into) -> Self { - Self { - message: Some(message.into()), - output: Some(output.into()), - } - } - - pub fn empty() -> Self { - Self { - message: None, - output: None, - } - } - - pub fn message(message: impl Into) -> Self { - Self { - message: Some(message.into()), - output: None, - } - } - - pub fn output(output: impl Into) -> Self { - Self { - output: Some(output.into()), - message: None, - } - } -} - -/// Trait to display Widgets on the shell -pub trait Widget { - /// Minimum height needed to render the widget - fn min_height(&self) -> usize; - - /// Peeks into the result to check wether the UI should be shown ([None]) or we can give a straight result - /// ([Some]) - fn peek(&mut self) -> Result> { - Ok(None) - } - - /// Render `self` in the given area from the frame - fn render(&mut self, frame: &mut Frame, area: Rect, inline: bool, theme: Theme); - - /// Process raw user input event and return [Some] to end user interaction or [None] to keep waiting for user input - fn process_raw_event(&mut self, event: Event) -> Result>; - - /// Run this widget `render` and `process_event` until we've got an output - fn show(mut self, terminal: &mut Terminal, inline: bool, theme: Theme, mut area: F) -> Result - where - B: Backend, - F: FnMut(&Frame) -> Rect, - Self: Sized, - { - loop { - // Draw UI - terminal.draw(|f| { - let area = area(f); - self.render(f, area, inline, theme); - })?; - - let event = event::read()?; - if let Event::Key(k) = &event { - // Ignore release & repeat events, we're only counting Press - if k.kind != KeyEventKind::Press { - continue; - } - // Exit on Ctrl+C - if let KeyCode::Char(c) = k.code { - if c == 'c' && k.modifiers.contains(KeyModifiers::CONTROL) { - return Ok(WidgetOutput::empty()); - } - } - } - - // Process event by widget - if let Some(res) = self.process_raw_event(event)? { - return Ok(res); - } - } - } -} - -/// Trait to implement input event capturing widgets -pub trait InputWidget: Widget { - /// Process user input event and return [Some] to end user interaction or [None] to keep waiting for user input - fn process_event(&mut self, event: Event) -> Result> { - match event { - Event::Paste(content) => self.insert_text(content)?, - Event::Key(key) => { - let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL); - match key.code { - // `ctrl + d` - Delete - KeyCode::Char(c) if has_ctrl && c == 'd' => self.delete_current()?, - // `ctrl + u` | `ctrl + e` | F2 - Edit / Update - KeyCode::F(f) if f == 2 => { - // TODO edit - delegate to widget? - } - KeyCode::Char(c) if has_ctrl && (c == 'e' || c == 'u') => { - // TODO edit - } - // Selection - KeyCode::Char(c) if has_ctrl && c == 'k' => self.prev(), - KeyCode::Char(c) if has_ctrl && c == 'j' => self.next(), - KeyCode::Up => self.move_up(), - KeyCode::Down => self.move_down(), - KeyCode::Right => self.move_right(), - KeyCode::Left => self.move_left(), - // Text edit - KeyCode::Char(c) => self.insert_char(c)?, - KeyCode::Backspace => self.delete_char(true)?, - KeyCode::Delete => self.delete_char(false)?, - // Control flow - KeyCode::Enter | KeyCode::Tab => return self.accept_current(), - KeyCode::Esc => return self.exit().map(Some), - _ => (), - } - } - _ => (), - }; - - // Keep waiting for input - Ok(None) - } - - /// Moves the selection up - fn move_up(&mut self); - /// Moves the selection down - fn move_down(&mut self); - /// Moves the selection left - fn move_left(&mut self); - /// Moves the selection right - fn move_right(&mut self); - - /// Moves the selection to the previous item - fn prev(&mut self); - /// Moves the selection to the next item - fn next(&mut self); - - /// Inserts the given text into the currently selected input, if any - fn insert_text(&mut self, text: String) -> Result<()>; - /// Inserts the given char into the currently selected input, if any - fn insert_char(&mut self, c: char) -> Result<()>; - /// Removes a character from the currently selected input, if any - fn delete_char(&mut self, backspace: bool) -> Result<()>; - - /// Deleted the currently selected item, if any - fn delete_current(&mut self) -> Result<()>; - /// Accepts the currently selected item, if any - fn accept_current(&mut self) -> Result>; - /// Exits with the current state - fn exit(&mut self) -> Result; -} - -#[derive(Clone, Default)] -pub struct EditableText { - text: String, - offset: usize, -} -impl Display for EditableText { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.text) - } -} -impl EditableText { - pub fn from_str(text: impl Into) -> Self { - let text = text.into(); - Self { - offset: text.len(), - text, - } - } - - pub fn as_str(&self) -> &str { - &self.text - } - - pub fn offset(&self) -> usize { - self.offset - } - - /// Moves internal cursor left - pub fn move_left(&mut self) { - if self.offset > 0 { - self.offset -= 1; - } - } - - /// Moves internal cursor right - pub fn move_right(&mut self) { - if self.offset < self.text.len_chars() { - self.offset += 1; - } - } - - /// Inserts the given text at the internal cursor - pub fn insert_text(&mut self, text: impl Into) { - let text = text.into(); - let len = text.len_chars(); - self.text.insert_safe_str(self.offset, text); - self.offset += len; - } - - /// Inserts the given char at the internal cursor - pub fn insert_char(&mut self, c: char) { - self.text.insert_safe(self.offset, c); - self.offset += 1; - } - - /// Deletes the char at the internal cursor and returns if any char was deleted - pub fn delete_char(&mut self, backspace: bool) -> bool { - if backspace { - if !self.text.is_empty() && self.offset > 0 { - self.text.remove_safe(self.offset - 1); - self.offset -= 1; - true - } else { - false - } - } else if !self.text.is_empty() && self.offset < self.text.len_chars() { - self.text.remove_safe(self.offset); - true - } else { - false - } - } -} - -pub struct OverflowText; -impl OverflowText { - /// Creates a new [Text] - /// - /// The `text` is not expected to contain any newlines - #[allow(clippy::new_ret_no_self)] - pub fn new(max_width: usize, text: &str) -> Text<'_> { - let text_width = text.len_chars(); - - if text_width > max_width { - let overflow = text_width - max_width; - let mut text_visible = text.to_owned(); - for _ in 0..overflow { - text_visible.remove_safe(0); - } - Text::raw(text_visible) - } else { - Text::raw(text) - } - } -} - -/// List that keeps the selected item state -#[derive(Default)] -pub struct StatefulList { - state: ListState, - items: Vec, -} - -impl StatefulList { - /// Builds a new [StatefulList] from the given items - pub fn with_items(items: Vec) -> StatefulList { - let mut state = ListState::default(); - if !items.is_empty() { - state.select(Some(0)); - } - StatefulList { state, items } - } - - /// Updates this list inner items - pub fn update_items(&mut self, items: Vec) { - self.items = items; - - if self.items.is_empty() { - self.state.select(None); - } else if let Some(selected) = self.state.selected() { - if selected > self.items.len() - 1 { - self.state.select(Some(self.items.len() - 1)); - } - } else { - self.state.select(Some(0)); - } - } - - /// Returns the number of items on this list - pub fn len(&self) -> usize { - self.items.len() - } - - /// Borrows the list to retrieve both inner items and list state - pub fn borrow(&mut self) -> (&Vec, &mut ListState) { - (&self.items, &mut self.state) - } - - /// Selects the next item on the list - pub fn next(&mut self) { - if let Some(selected) = self.state.selected() { - if self.items.is_empty() { - self.state.select(None); - } else { - let i = if selected >= self.items.len() - 1 { - 0 - } else { - selected + 1 - }; - self.state.select(Some(i)); - } - } - } - - /// Selects the previous item on the list - pub fn previous(&mut self) { - if let Some(selected) = self.state.selected() { - if self.items.is_empty() { - self.state.select(None); - } else { - let i = if selected == 0 { - self.items.len() - 1 - } else { - selected - 1 - }; - self.state.select(Some(i)); - } - } - } - - /// Returns a mutable reference to the current selected item - pub fn current_mut(&mut self) -> Option<&mut T> { - if let Some(selected) = self.state.selected() { - self.items.get_mut(selected) - } else { - None - } - } - - /// Returns a reference to the current selected item - pub fn current(&self) -> Option<&T> { - if let Some(selected) = self.state.selected() { - self.items.get(selected) - } else { - None - } - } - - /// Deletes the currently selected item and returns it - pub fn delete_current(&mut self) -> Option { - let deleted = if let Some(selected) = self.state.selected() { - Some(self.items.remove(selected)) - } else { - None - }; - - if self.items.is_empty() { - self.state.select(None); - } else if let Some(selected) = self.state.selected() { - if selected > self.items.len() - 1 { - self.state.select(Some(self.items.len() - 1)); - } - } else { - self.state.select(Some(0)); - } - - deleted - } -} - -/// Iterator to split a test by a regex and capture both unmatched and captured groups -pub struct SplitCaptures<'r, 't> { - finder: CaptureMatches<'r, 't>, - text: &'t str, - last: usize, - caps: Option>, -} - -impl<'r, 't> SplitCaptures<'r, 't> { - /// Builds a new [SplitCaptures] - pub fn new(re: &'r Regex, text: &'t str) -> SplitCaptures<'r, 't> { - SplitCaptures { - finder: re.captures_iter(text), - text, - last: 0, - caps: None, - } - } -} - -/// Represents each item of a [SplitCaptures] -#[derive(Debug)] -pub enum SplitItem<'t> { - Unmatched(&'t str), - Captured(Captures<'t>), -} - -impl<'r, 't> Iterator for SplitCaptures<'r, 't> { - type Item = SplitItem<'t>; - - fn next(&mut self) -> Option> { - if let Some(caps) = self.caps.take() { - return Some(SplitItem::Captured(caps)); - } - match self.finder.next() { - None => { - if self.last >= self.text.len() { - None - } else { - let s = &self.text[self.last..]; - self.last = self.text.len(); - Some(SplitItem::Unmatched(s)) - } - } - Some(caps) => { - let m = caps.get(0).unwrap(); - let unmatched = &self.text[self.last..m.start()]; - self.last = m.end(); - self.caps = Some(caps); - Some(SplitItem::Unmatched(unmatched)) - } - } - } -} - -/// String utilities to work with [grapheme clusters](https://doc.rust-lang.org/book/ch08-02-strings.html#bytes-and-scalar-values-and-grapheme-clusters-oh-my) -pub trait StringExt { - /// Inserts a `char` at a given char index position. - /// - /// Unlike [`String::insert`](String::insert), the index is char-based, not byte-based. - fn insert_safe(&mut self, char_index: usize, c: char); - - /// Inserts an `String` at a given char index position. - /// - /// Unlike [`String::insert`](String::insert), the index is char-based, not byte-based. - fn insert_safe_str(&mut self, char_index: usize, str: impl Into); - - /// Removes a `char` at a given char index position. - /// - /// Unlike [`String::remove`](String::remove), the index is char-based, not byte-based. - fn remove_safe(&mut self, char_index: usize); -} -pub trait StrExt { - /// Returns the number of characters. - /// - /// Unlike [`String::len`](String::len), the number is char-based, not byte-based. - fn len_chars(&self) -> usize; -} - -impl StringExt for String { - fn insert_safe(&mut self, char_index: usize, new_char: char) { - let mut v = self.graphemes(true).map(ToOwned::to_owned).collect_vec(); - v.insert(char_index, new_char.to_string()); - *self = v.join(""); - } - - fn insert_safe_str(&mut self, char_index: usize, str: impl Into) { - let mut v = self.graphemes(true).map(ToOwned::to_owned).collect_vec(); - v.insert(char_index, str.into()); - *self = v.join(""); - } - - fn remove_safe(&mut self, char_index: usize) { - *self = self - .graphemes(true) - .enumerate() - .filter_map(|(i, c)| if i != char_index { Some(c) } else { None }) - .collect_vec() - .join(""); - } -} - -impl StrExt for String { - fn len_chars(&self) -> usize { - self.graphemes(true).count() - } -} - -impl StrExt for str { - fn len_chars(&self) -> usize { - self.graphemes(true).count() - } -} diff --git a/src/common/misc.rs b/src/common/misc.rs new file mode 100644 index 0000000..2c58bc0 --- /dev/null +++ b/src/common/misc.rs @@ -0,0 +1,137 @@ +use itertools::Itertools; +use once_cell::sync::Lazy; +use regex::{CaptureMatches, Captures, Regex}; +use unicode_segmentation::UnicodeSegmentation; +use unidecode::unidecode; + +/// Regex to match not allowed FTS characters +static NEW_LINES: Lazy = Lazy::new(|| Regex::new(r#"\r|\n|\r\n"#).unwrap()); + +/// Converts all newline kinds to `\n` +pub fn unify_newlines(str: String) -> String { + NEW_LINES.replace_all(&str, "\n").to_string() +} + +/// Removes newlines +pub fn remove_newlines(str: String) -> String { + NEW_LINES.replace_all(&str, "").to_string() +} + +/// Applies [unidecode] to the given string and then converts it to lower case +pub fn flatten_str(s: impl AsRef) -> String { + unidecode(s.as_ref()).to_lowercase() +} + +/// Iterator to split a test by a regex and capture both unmatched and captured groups +pub struct SplitCaptures<'r, 't> { + finder: CaptureMatches<'r, 't>, + text: &'t str, + last: usize, + caps: Option>, +} + +impl<'r, 't> SplitCaptures<'r, 't> { + /// Builds a new [SplitCaptures] + pub fn new(re: &'r Regex, text: &'t str) -> SplitCaptures<'r, 't> { + SplitCaptures { + finder: re.captures_iter(text), + text, + last: 0, + caps: None, + } + } +} + +/// Represents each item of a [SplitCaptures] +#[derive(Debug)] +pub enum SplitItem<'t> { + Unmatched(&'t str), + Captured(Captures<'t>), +} + +impl<'r, 't> Iterator for SplitCaptures<'r, 't> { + type Item = SplitItem<'t>; + + fn next(&mut self) -> Option> { + if let Some(caps) = self.caps.take() { + return Some(SplitItem::Captured(caps)); + } + match self.finder.next() { + None => { + if self.last >= self.text.len() { + None + } else { + let s = &self.text[self.last..]; + self.last = self.text.len(); + Some(SplitItem::Unmatched(s)) + } + } + Some(caps) => { + let m = caps.get(0).unwrap(); + let unmatched = &self.text[self.last..m.start()]; + self.last = m.end(); + self.caps = Some(caps); + Some(SplitItem::Unmatched(unmatched)) + } + } + } +} + +/// String utilities to work with [grapheme clusters](https://doc.rust-lang.org/book/ch08-02-strings.html#bytes-and-scalar-values-and-grapheme-clusters-oh-my) +pub trait StringExt { + /// Inserts a `char` at a given char index position. + /// + /// Unlike [`String::insert`](String::insert), the index is char-based, not byte-based. + fn insert_safe(&mut self, char_index: usize, c: char); + + /// Inserts an `String` at a given char index position. + /// + /// Unlike [`String::insert`](String::insert), the index is char-based, not byte-based. + fn insert_safe_str(&mut self, char_index: usize, str: impl Into); + + /// Removes a `char` at a given char index position. + /// + /// Unlike [`String::remove`](String::remove), the index is char-based, not byte-based. + fn remove_safe(&mut self, char_index: usize); +} +pub trait StrExt { + /// Returns the number of characters. + /// + /// Unlike [`String::len`](String::len), the number is char-based, not byte-based. + fn len_chars(&self) -> usize; +} + +impl StringExt for String { + fn insert_safe(&mut self, char_index: usize, new_char: char) { + let mut v = self.graphemes(true).map(ToOwned::to_owned).collect_vec(); + v.insert(char_index, new_char.to_string()); + *self = v.join(""); + } + + fn insert_safe_str(&mut self, char_index: usize, str: impl Into) { + let mut v = self.graphemes(true).map(ToOwned::to_owned).collect_vec(); + v.insert(char_index, str.into()); + *self = v.join(""); + } + + fn remove_safe(&mut self, char_index: usize) { + *self = self + .graphemes(true) + .enumerate() + .filter_map(|(i, c)| if i != char_index { Some(c) } else { None }) + .collect_vec() + .join(""); + } +} + +impl StrExt for String { + fn len_chars(&self) -> usize { + self.graphemes(true).count() + } +} + +impl StrExt for str { + fn len_chars(&self) -> usize { + self.graphemes(true).count() + } +} diff --git a/src/common/mod.rs b/src/common/mod.rs new file mode 100644 index 0000000..bee6089 --- /dev/null +++ b/src/common/mod.rs @@ -0,0 +1,7 @@ +pub mod widget; + +mod misc; +mod process; + +pub use misc::*; +pub use process::*; diff --git a/src/common/process.rs b/src/common/process.rs new file mode 100644 index 0000000..fa8daec --- /dev/null +++ b/src/common/process.rs @@ -0,0 +1,173 @@ +use anyhow::Result; +use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; +use tui::{backend::Backend, layout::Rect, Frame, Terminal}; + +use super::remove_newlines; +use crate::theme::Theme; + +/// Output of a process +pub struct ProcessOutput { + pub message: Option, + pub output: Option, +} + +impl ProcessOutput { + pub fn new(message: impl Into, output: impl Into) -> Self { + Self { + message: Some(message.into()), + output: Some(output.into()), + } + } + + pub fn empty() -> Self { + Self { + message: None, + output: None, + } + } + + pub fn message(message: impl Into) -> Self { + Self { + message: Some(message.into()), + output: None, + } + } + + pub fn output(output: impl Into) -> Self { + Self { + output: Some(output.into()), + message: None, + } + } +} + +/// Context of an execution +#[derive(Clone, Copy)] +pub struct ExecutionContext { + pub inline: bool, + pub theme: Theme, +} + +/// Trait to display a process on the shell +pub trait Process { + /// Minimum height needed to render the process + fn min_height(&self) -> usize; + + /// Peeks into the result to check wether the UI should be shown ([None]) or we can give a straight result + /// ([Some]) + fn peek(&mut self) -> Result> { + Ok(None) + } + + /// Render `self` in the given area from the frame + fn render(&mut self, frame: &mut Frame, area: Rect); + + /// Process raw user input event and return [Some] to end user interaction or [None] to keep waiting for user input + fn process_raw_event(&mut self, event: Event) -> Result>; + + /// Run this process `render` and `process_event` until we've got an output + fn show(mut self, terminal: &mut Terminal, mut area: F) -> Result + where + B: Backend, + F: FnMut(&Frame) -> Rect, + Self: Sized, + { + loop { + // Draw UI + terminal.draw(|f| { + let area = area(f); + self.render(f, area); + })?; + + let event = event::read()?; + if let Event::Key(k) = &event { + // Ignore release & repeat events, we're only counting Press + if k.kind != KeyEventKind::Press { + continue; + } + // Exit on Ctrl+C + if let KeyCode::Char(c) = k.code { + if c == 'c' && k.modifiers.contains(KeyModifiers::CONTROL) { + return Ok(ProcessOutput::empty()); + } + } + } + + // Process event + if let Some(res) = self.process_raw_event(event)? { + return Ok(res); + } + } + } +} + +/// Utility trait to implement an interactive process +pub trait InteractiveProcess: Process { + /// Process user input event and return [Some] to end user interaction or [None] to keep waiting for user input + fn process_event(&mut self, event: Event) -> Result> { + match event { + Event::Paste(content) => self.insert_text(remove_newlines(content))?, + Event::Key(key) => { + let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + match key.code { + // `ctrl + d` - Delete + KeyCode::Char(c) if has_ctrl && c == 'd' => self.delete_current()?, + // `ctrl + u` | `ctrl + e` | F2 - Edit / Update + KeyCode::F(f) if f == 2 => { + // TODO edit - delegate to process? + } + KeyCode::Char(c) if has_ctrl && (c == 'e' || c == 'u') => { + // TODO edit + } + // Selection + KeyCode::Char(c) if has_ctrl && c == 'k' => self.prev(), + KeyCode::Char(c) if has_ctrl && c == 'j' => self.next(), + KeyCode::Up => self.move_up(), + KeyCode::Down => self.move_down(), + KeyCode::Right => self.move_right(), + KeyCode::Left => self.move_left(), + // Text edit + KeyCode::Char(c) => self.insert_char(c)?, + KeyCode::Backspace => self.delete_char(true)?, + KeyCode::Delete => self.delete_char(false)?, + // Control flow + KeyCode::Enter | KeyCode::Tab => return self.accept_current(), + KeyCode::Esc => return self.exit().map(Some), + _ => (), + } + } + _ => (), + }; + + // Keep waiting for input + Ok(None) + } + + /// Moves the selection up + fn move_up(&mut self); + /// Moves the selection down + fn move_down(&mut self); + /// Moves the selection left + fn move_left(&mut self); + /// Moves the selection right + fn move_right(&mut self); + + /// Moves the selection to the previous item + fn prev(&mut self); + /// Moves the selection to the next item + fn next(&mut self); + + /// Inserts the given text into the currently selected input, if any + fn insert_text(&mut self, text: String) -> Result<()>; + /// Inserts the given char into the currently selected input, if any + fn insert_char(&mut self, c: char) -> Result<()>; + /// Removes a character from the currently selected input, if any + fn delete_char(&mut self, backspace: bool) -> Result<()>; + + /// Deleted the currently selected item, if any + fn delete_current(&mut self) -> Result<()>; + /// Accepts the currently selected item, if any + fn accept_current(&mut self) -> Result>; + /// Exits with the current state + fn exit(&mut self) -> Result; +} diff --git a/src/common/widget/command.rs b/src/common/widget/command.rs new file mode 100644 index 0000000..2b685b0 --- /dev/null +++ b/src/common/widget/command.rs @@ -0,0 +1,19 @@ +use tui::{ + style::Style, + text::{Span, Spans}, + widgets::ListItem, +}; + +use super::IntoWidget; +use crate::{model::Command, theme::Theme}; + +impl<'a> IntoWidget> for &'a Command { + fn into_widget(self, theme: Theme) -> ListItem<'a> { + let content = Spans::from(vec![ + Span::raw(&self.cmd), + Span::styled(" # ", Style::default().fg(theme.secondary)), + Span::styled(&self.description, Style::default().fg(theme.secondary)), + ]); + ListItem::new(content) + } +} diff --git a/src/common/widget/label.rs b/src/common/widget/label.rs new file mode 100644 index 0000000..a3ae652 --- /dev/null +++ b/src/common/widget/label.rs @@ -0,0 +1,83 @@ +use itertools::Itertools; +use tui::{ + style::{Modifier, Style}, + text::{Span, Spans, Text}, + widgets::ListItem, +}; + +use super::{Area, IntoCursorWidget, Offset, TextInput}; +use crate::{ + common::StrExt, + model::{CommandPart, LabelSuggestion, LabeledCommand}, + theme::Theme, +}; + +pub const NEW_LABEL_PREFIX: &str = "(new) "; + +#[cfg_attr(debug_assertions, derive(Debug))] +pub enum LabelSuggestionItem { + New(TextInput), + Label(String), + Persisted(LabelSuggestion), +} + +impl<'a> From<&'a LabelSuggestionItem> for ListItem<'a> { + fn from(item: &'a LabelSuggestionItem) -> Self { + match item { + LabelSuggestionItem::New(value) => ListItem::new(Spans::from(vec![ + Span::styled(NEW_LABEL_PREFIX, Style::default().add_modifier(Modifier::ITALIC)), + Span::raw(value.as_str()), + ])), + LabelSuggestionItem::Label(value) => ListItem::new(value.clone()), + LabelSuggestionItem::Persisted(e) => ListItem::new(e.suggestion.clone()), + } + } +} + +impl<'a> IntoCursorWidget> for &'a LabeledCommand { + fn into_widget_and_cursor(self, theme: Theme) -> (Text<'a>, Option<(Offset, Area)>) { + let mut first_label_found = false; + let mut first_label_offset_x = 0; + let mut first_label_width = 0; + + let text = Spans::from( + self.parts + .iter() + .map(|p| { + let span = match p { + CommandPart::Text(t) | CommandPart::LabelValue(t) => { + Span::styled(t, Style::default().fg(theme.disabled)) + } + CommandPart::Label(l) => { + let style = if !first_label_found { + first_label_found = true; + first_label_width = l.len_chars() as u16 + 4; + Style::default().fg(theme.main).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.disabled) + }; + Span::styled(format!("{{{{{l}}}}}"), style) + } + }; + if !first_label_found { + first_label_offset_x += span.width() as u16; + } + span + }) + .collect_vec(), + ) + .into(); + + ( + text, + if first_label_found { + Some(( + Offset::new(first_label_offset_x, 0), + Area::default_visible().min_width(first_label_width), + )) + } else { + None + }, + ) + } +} diff --git a/src/common/widget/list.rs b/src/common/widget/list.rs new file mode 100644 index 0000000..2d8d71b --- /dev/null +++ b/src/common/widget/list.rs @@ -0,0 +1,209 @@ +use itertools::Itertools; +use tui::{ + layout::Rect, + style::Style, + widgets::{Block, Borders, List, ListItem, ListState}, +}; + +use super::{Area, CustomStatefulWidget, IntoWidget}; +use crate::theme::Theme; + +pub const DEFAULT_HIGHLIGHT_SYMBOL_PREFIX: &str = ">> "; + +pub struct CustomStatefulList { + state: ListState, + items: Vec, + inline: bool, + block_title: Option<&'static str>, + + style: Style, + highlight_style: Style, + highlight_symbol: Option<&'static str>, +} + +impl<'s, T: 's> CustomStatefulList +where + &'s T: IntoWidget>, +{ + /// Builds a new list from the given items + pub fn new(items: Vec) -> Self { + let mut state = ListState::default(); + if !items.is_empty() { + state.select(Some(0)); + } + Self { + state, + items, + inline: true, + block_title: None, + style: Style::default(), + highlight_style: Style::default(), + highlight_symbol: None, + } + } + + pub fn inline(mut self, inline: bool) -> Self { + self.inline = inline; + self + } + + pub fn block_title(mut self, block_title: &'static str) -> Self { + self.block_title = Some(block_title); + self + } + + pub fn style(mut self, style: Style) -> Self { + self.style = style; + self + } + + pub fn highlight_symbol(mut self, highlight_symbol: &'static str) -> Self { + self.highlight_symbol = Some(highlight_symbol); + self + } + + pub fn highlight_style(mut self, style: Style) -> Self { + self.highlight_style = style; + self + } + + /// Resets the internal selected state + pub fn reset_state(&mut self) { + self.state = ListState::default(); + if !self.items.is_empty() { + self.state.select(Some(0)); + } + } + + /// Updates this list inner items + pub fn update_items(&mut self, items: Vec) { + self.items = items; + + if self.items.is_empty() { + self.state.select(None); + } else if let Some(selected) = self.state.selected() { + if selected > self.items.len() - 1 { + self.state.select(Some(self.items.len() - 1)); + } + } else { + self.state.select(Some(0)); + } + } + + /// Returns the number of items on this list + pub fn len(&self) -> usize { + self.items.len() + } + + /// Selects the next item on the list + pub fn next(&mut self) { + if let Some(selected) = self.state.selected() { + if self.items.is_empty() { + self.state.select(None); + } else { + let i = if selected >= self.items.len() - 1 { + 0 + } else { + selected + 1 + }; + self.state.select(Some(i)); + } + } + } + + /// Selects the previous item on the list + pub fn previous(&mut self) { + if let Some(selected) = self.state.selected() { + if self.items.is_empty() { + self.state.select(None); + } else { + let i = if selected == 0 { + self.items.len() - 1 + } else { + selected - 1 + }; + self.state.select(Some(i)); + } + } + } + + /// Returns a mutable reference to the current selected item + pub fn current_mut(&mut self) -> Option<&mut T> { + if let Some(selected) = self.state.selected() { + self.items.get_mut(selected) + } else { + None + } + } + + /// Returns a reference to the current selected item + pub fn current(&self) -> Option<&T> { + if let Some(selected) = self.state.selected() { + self.items.get(selected) + } else { + None + } + } + + /// Deletes the currently selected item and returns it + pub fn delete_current(&mut self) -> Option { + let deleted = if let Some(selected) = self.state.selected() { + Some(self.items.remove(selected)) + } else { + None + }; + + if self.items.is_empty() { + self.state.select(None); + } else if let Some(selected) = self.state.selected() { + if selected > self.items.len() - 1 { + self.state.select(Some(self.items.len() - 1)); + } + } else { + self.state.select(Some(0)); + } + + deleted + } +} + +impl<'s, T: 's> CustomStatefulWidget<'s> for CustomStatefulList +where + &'s T: IntoWidget>, +{ + type Inner = List<'s>; + + fn min_size(&self) -> Area { + let borders = 2 * (!self.inline as u16); + let height = 1 + borders; + let width = Area::default_visible().width + borders; + Area::new(width, height) + } + + fn prepare(&'s mut self, _area: Rect, theme: Theme) -> (Self::Inner, &mut ListState) { + // Get the widget of each item + let widget_items = self + .items + .iter() + .map(|i| IntoWidget::into_widget(i, theme)) + .collect_vec(); + + // Generate the list + let mut list = List::new(widget_items) + .style(self.style) + .highlight_style(self.highlight_style); + if let Some(highlight_symbol) = self.highlight_symbol { + list = list.highlight_symbol(highlight_symbol); + } + if !self.inline { + let mut block = Block::default().borders(Borders::ALL); + if let Some(block_title) = self.block_title { + block = block.title(format!(" {block_title} ")); + } + list = list.block(block); + } + + // Return + (list, &mut self.state) + } +} diff --git a/src/common/widget/mod.rs b/src/common/widget/mod.rs new file mode 100644 index 0000000..4a1cb6e --- /dev/null +++ b/src/common/widget/mod.rs @@ -0,0 +1,137 @@ +mod command; +mod label; +mod list; +mod text; + +pub use command::*; +pub use label::*; +pub use list::*; +pub use text::*; +use tui::{ + backend::Backend, + layout::Rect, + widgets::{StatefulWidget, Widget}, + Frame, +}; + +use crate::theme::Theme; + +// Represents an offset +#[derive(Default, Clone, Copy)] +#[cfg_attr(debug_assertions, derive(Debug))] +pub struct Offset { + pub x: u16, + pub y: u16, +} + +impl Offset { + pub fn new(x: u16, y: u16) -> Self { + Self { x, y } + } +} + +// Represents an area +#[derive(Clone, Copy)] +#[cfg_attr(debug_assertions, derive(Debug))] +pub struct Area { + pub width: u16, + pub height: u16, +} +impl Default for Area { + fn default() -> Self { + Self { width: 1, height: 1 } + } +} + +impl Area { + pub fn default_visible() -> Self { + Self { width: 25, height: 2 } + } + + pub fn new(width: u16, height: u16) -> Self { + Self { width, height } + } + + pub fn min_width(mut self, width: u16) -> Self { + self.width = width.max(self.width); + self + } +} + +/// Custom widget +pub trait CustomWidget<'s> { + type Inner: Widget; + + /// Retrieves the minimum size needed to render this widget + fn min_size(&self) -> Area; + + /// Determines if the widget is currently focused + fn is_focused(&self) -> bool; + + /// Prepares both cursor offset (relative to the area) and widget parts + fn prepare(&'s self, area: Rect, theme: Theme) -> (Option, Self::Inner); + + /// Renders itself in the frame and places the cursor if needed + fn render_in(&'s self, frame: &mut Frame, area: Rect, theme: Theme) + where + Self: Sized, + { + let (offset, widget) = self.prepare(area, theme); + frame.render_widget(widget, area); + + if self.is_focused() { + if let Some(offset) = offset { + // Make the cursor visible and ask tui-rs to put it at the specified coordinates after rendering + frame.set_cursor(area.x + offset.x, area.y + offset.y); + } + } + } +} + +pub trait CustomStatefulWidget<'s> { + type Inner: StatefulWidget; + + /// Retrieves the minimum size needed to render this widget + fn min_size(&self) -> Area; + + /// Prepares widget and state parts + fn prepare(&'s mut self, area: Rect, theme: Theme) + -> (Self::Inner, &'s mut ::State); + + /// Renders itself in the frame + fn render_in(&'s mut self, frame: &mut Frame, area: Rect, theme: Theme) + where + Self: Sized, + { + let (widget, state) = self.prepare(area, theme); + frame.render_stateful_widget(widget, area, state); + } +} + +pub trait IntoWidget { + /// Converts self into a widget + fn into_widget(self, theme: Theme) -> W; +} + +impl IntoWidget for T +where + T: Into, +{ + fn into_widget(self, _theme: Theme) -> W { + self.into() + } +} + +pub trait IntoCursorWidget { + /// Converts self into a widget and its cursor + fn into_widget_and_cursor(self, theme: Theme) -> (W, Option<(Offset, Area)>); +} + +impl IntoCursorWidget for T +where + T: Into, +{ + fn into_widget_and_cursor(self, _theme: Theme) -> (W, Option<(Offset, Area)>) { + (self.into(), None) + } +} diff --git a/src/common/widget/text.rs b/src/common/widget/text.rs new file mode 100644 index 0000000..988897c --- /dev/null +++ b/src/common/widget/text.rs @@ -0,0 +1,384 @@ +use std::{borrow::Cow, fmt::Display}; + +use tui::{ + layout::Rect, + style::Style, + text::{Span, Spans, Text}, + widgets::{Block, Borders, Paragraph}, +}; + +use super::{ + super::{StrExt, StringExt}, + Area, CustomWidget, IntoCursorWidget, Offset, +}; +use crate::{common::unify_newlines, theme::Theme}; + +pub struct CustomParagraph { + text: T, + inline: bool, + inline_title: Option<&'static str>, + block_title: Option<&'static str>, + focus: bool, + style: Style, +} + +impl<'s, T: 's> CustomParagraph +where + &'s T: IntoCursorWidget>, +{ + pub fn new(text: T) -> Self { + Self { + text, + inline: true, + inline_title: None, + block_title: None, + focus: false, + style: Style::default(), + } + } + + pub fn inline(mut self, inline: bool) -> Self { + self.inline = inline; + self + } + + pub fn inline_title(mut self, inline_title: &'static str) -> Self { + self.inline_title = Some(inline_title); + self + } + + pub fn block_title(mut self, block_title: &'static str) -> Self { + self.block_title = Some(block_title); + self + } + + pub fn style(mut self, style: Style) -> Self { + self.style = style; + self + } + + pub fn focus(mut self, focus: bool) -> Self { + self.focus = focus; + self + } + + pub fn inner(&self) -> &T { + &self.text + } + + pub fn inner_mut(&mut self) -> &mut T { + &mut self.text + } +} + +impl<'s, T: 's> CustomWidget<'s> for CustomParagraph +where + &'s T: IntoCursorWidget>, +{ + type Inner = Paragraph<'s>; + + fn min_size(&self) -> Area { + let borders = 2 * (!self.inline as u16); + let height = 1 + borders; + let width = 50 + borders; + Area::new(width, height) + } + + fn is_focused(&self) -> bool { + self.focus + } + + fn prepare(&'s self, area: Rect, theme: Theme) -> (Option, Self::Inner) { + let (mut text, data) = (&self.text).into_widget_and_cursor(theme); + let (mut cursor, visible_area) = data.unzip(); + // Cap cursor and ending offset based on the text + let mut end_offset = if let Some(cursor) = cursor.as_mut() { + cursor.x = cursor.x.min(text.width() as u16); + cursor.y = cursor.y.min(text.lines.len().max(1) as u16 - 1); + let visible_area = visible_area.unwrap_or_else(Area::default_visible); + let mut end_offset = Offset::new(cursor.x + visible_area.width, cursor.y + visible_area.height - 1); + end_offset.x = end_offset.x.min(text.width() as u16); + end_offset.y = end_offset.y.min(text.lines.len().max(1) as u16 - 1); + Some(end_offset) + } else { + None + }; + // Always allow an extra char + let mut max_width = area.width - 1; + let mut max_height = area.height; + // If inline, prefix the title and shift the offset + if self.inline { + if let Some(inline_title) = self.inline_title { + if let Some(line) = text.lines.get_mut(0) { + line.0.insert(0, Span::raw(inline_title)); + line.0.insert(1, Span::raw(" ")); + } else { + text.lines + .push(Spans::from(vec![Span::raw(inline_title), Span::raw(" ")])); + } + // Shift cursor if on the first line + if let (Some(cursor), Some(end_offset)) = (cursor.as_mut(), end_offset.as_mut()) { + if cursor.y == 0 { + let extra_offset = inline_title.len_chars() as u16 + 1; + cursor.x += extra_offset; + end_offset.x += extra_offset; + } + } + } + } + let mut paragraph = Paragraph::new(text).style(self.style); + // If not inline, include bordered block + if !self.inline { + let mut block = Block::default().borders(Borders::ALL); + if let Some(block_title) = self.block_title { + block = block.title(format!(" {block_title} ")); + } + paragraph = paragraph.block(block); + // Remove borders from max width & height + max_width -= 2; + max_height -= 2; + // Shift offset because of borders + if let (Some(cursor), Some(end_offset)) = (cursor.as_mut(), end_offset.as_mut()) { + cursor.x += 1; + cursor.y += 1; + end_offset.x += 1; + end_offset.y += 1; + } + } + // If we can't fully "see" the visible offset, scroll + if let (Some(cursor), Some(end_offset)) = (cursor.as_mut(), end_offset.as_mut()) { + let mut scroll_x = 0; + let mut scroll_y = 0; + if end_offset.x > max_width { + scroll_x = end_offset.x - max_width; + scroll_x = scroll_x.min(cursor.x); + cursor.x -= scroll_x; + } + if end_offset.y > max_height { + scroll_y = end_offset.y - max_height; + scroll_y = scroll_y.min(cursor.y); + cursor.y -= scroll_y; + } + paragraph = paragraph.scroll((scroll_y, scroll_x)); + } + // Return + (cursor, paragraph) + } +} + +/// Convenience class to store input text (with cursor offset) +#[derive(Clone, Default)] +#[cfg_attr(debug_assertions, derive(Debug))] +pub struct TextInput { + text: String, + cursor: Offset, +} + +impl TextInput { + /// Builds a [TextInput] from any string + pub fn new(text: impl Into) -> Self { + let text = unify_newlines(text.into()); + // `lines` excludes the last line ending, but we want to include it + let lines_count = format!("{text}-").lines().count().max(1); + Self { + cursor: Offset::new( + text.lines() + .nth(lines_count - 1) + .map(str::len_chars) + .unwrap_or_default() as u16, + lines_count as u16 - 1, + ), + text, + } + } + + /// Retrieves the char index from `self.text` at the current cursor position + fn char_idx_at_cursor(&self) -> usize { + if self.cursor.y == 0 { + self.cursor.x as usize + } else { + // Compute previous lines + let mut idx = self + .text + .lines() + .enumerate() + .filter_map(|(ix, line)| if ix < self.cursor.y as usize { Some(line) } else { None }) + .map(str::len_chars) + .sum(); + // Add newline chars + idx += self.cursor.y as usize; + // Add current line offset + idx += self.cursor.x as usize; + + idx + } + } + + /// Returns the number of lines of the text (minimum of 1 even if empty) + pub fn lines_count(&self) -> u16 { + // `lines` excludes the last line ending, but we want to include it + format!("{}-", self.text).lines().count().max(1) as u16 + } + + /// Returns the total number of characters of the current line + pub fn current_line_length(&self) -> u16 { + self.text + .lines() + .nth(self.cursor.y as usize) + .map(|l| l.len_chars()) + .unwrap_or_default() as u16 + } + + /// Retrieves internal text + pub fn as_str(&self) -> &str { + &self.text + } + + /// Retrieves current cursor position + pub fn cursor(&self) -> Offset { + self.cursor + } + + /// Moves internal cursor left + pub fn move_left(&mut self) { + if self.cursor.x > 0 { + self.cursor.x -= 1; + } else if self.cursor.y > 0 { + self.cursor.y -= 1; + self.cursor.x = self.current_line_length(); + } + } + + /// Moves internal cursor right + pub fn move_right(&mut self) { + if self.cursor.x < self.current_line_length() { + self.cursor.x += 1; + } else if self.cursor.y < (self.lines_count() - 1) { + self.cursor.y += 1; + self.cursor.x = 0; + } + } + + /// Moves internal cursor up + pub fn move_up(&mut self) { + if self.cursor.y > 0 { + self.cursor.y -= 1; + self.cursor.x = self.cursor.x.clamp(0, self.current_line_length()); + } + } + + /// Moves internal cursor down + pub fn move_down(&mut self) { + if self.cursor.y < (self.lines_count() - 1) { + self.cursor.y += 1; + self.cursor.x = self.cursor.x.clamp(0, self.current_line_length()); + } + } + + /// Inserts the given text at the internal cursor + pub fn insert_text(&mut self, text: impl Into) { + let text = unify_newlines(text.into()); + let text_lines = format!("{}-", text).lines().count().max(1); + let last_line_len = text.lines().nth(text_lines - 1).map(str::len_chars).unwrap_or_default() as u16; + let char_idx = self.char_idx_at_cursor(); + self.text.insert_safe_str(char_idx, text); + self.cursor.x = if text_lines > 1 { + last_line_len + } else { + self.cursor.x + last_line_len + }; + self.cursor.y += text_lines as u16 - 1; + } + + /// Inserts a newline + pub fn insert_newline(&mut self) { + let char_idx = self.char_idx_at_cursor(); + self.text.insert_safe(char_idx, '\n'); + self.cursor.y += 1; + self.cursor.x = 0; + } + + /// Inserts the given char at the internal cursor + pub fn insert_char(&mut self, c: char) { + if c == '\n' || c == 0xA as char { + self.insert_newline() + } else { + let char_idx = self.char_idx_at_cursor(); + self.text.insert_safe(char_idx, c); + self.cursor.x += 1; + } + } + + /// Deletes the char at the internal cursor and returns if any char was deleted + pub fn delete_char(&mut self, backspace: bool) -> bool { + if self.text.is_empty() { + return false; + } + + match backspace { + // Backspace + true => { + let char_idx = self.char_idx_at_cursor(); + if char_idx > 0 { + let mut prev_line_length = 0; + if self.cursor.y > 0 { + self.cursor.y -= 1; + prev_line_length = self.current_line_length(); + self.cursor.y += 1; + } + self.text.remove_safe(char_idx - 1); + if self.cursor.x == 0 { + self.cursor.y -= 1; + self.cursor.x = prev_line_length; + } else { + self.cursor.x -= 1; + } + true + } else { + false + } + } + // Delete + false => { + let char_idx = self.char_idx_at_cursor(); + let text_len = self.text.len_chars(); + if char_idx < text_len { + self.text.remove_safe(char_idx); + true + } else { + false + } + } + } + } +} + +impl Display for TextInput { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl From<&str> for TextInput { + fn from(value: &str) -> Self { + Self::new(value) + } +} + +impl From for TextInput { + fn from(value: String) -> Self { + Self::new(value) + } +} + +impl<'a> From<&'a TextInput> for Cow<'a, str> { + fn from(value: &'a TextInput) -> Self { + value.as_str().into() + } +} + +impl<'a> IntoCursorWidget> for &'a TextInput { + fn into_widget_and_cursor(self, _theme: Theme) -> (Text<'a>, Option<(Offset, Area)>) { + (self.as_str().into(), Some((self.cursor(), Area::default_visible()))) + } +} diff --git a/src/debug.rs b/src/debug.rs new file mode 100644 index 0000000..7f5a63b --- /dev/null +++ b/src/debug.rs @@ -0,0 +1,37 @@ +use std::{ + fs::{File, OpenOptions}, + sync::Mutex, +}; + +use once_cell::sync::Lazy; + +static _LOG_FILE: Lazy> = Lazy::new(|| { + Mutex::new( + OpenOptions::new() + .create(true) + .truncate(true) + .read(true) + .write(true) + .open("debug.log") + .unwrap(), + ) +}); + +#[macro_export] +macro_rules! trace { + ($($arg:tt)*) => { + $crate::debug::trace_log(format!($($arg)*)) + }; +} + +pub fn trace_log(_str: impl AsRef) { + #[cfg(feature = "debug")] + { + use std::io::Write; + let mut file = _LOG_FILE.lock().unwrap(); + writeln!(file, "{}", _str.as_ref()).unwrap(); + } +} + +#[allow(unused_imports)] +pub(crate) use trace; diff --git a/src/lib.rs b/src/lib.rs index 569b1ea..fc9084f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,14 +17,15 @@ #![forbid(unsafe_code)] +pub mod debug; pub mod model; +pub mod process; pub mod storage; pub mod theme; -pub mod widgets; mod cfg; mod common; #[cfg(feature = "tldr")] mod tldr; -pub use common::{Widget, WidgetOutput}; +pub use common::{ExecutionContext, Process, ProcessOutput}; diff --git a/src/main.rs b/src/main.rs index 71ac4f5..8b49a65 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,10 +16,9 @@ use crossterm::{ }; use intelli_shell::{ model::AsLabeledCommand, + process::{LabelProcess, SaveCommandProcess, SearchProcess}, storage::{SqliteStorage, USER_CATEGORY}, - theme::{self, Theme}, - widgets::{LabelWidget, SaveCommandWidget, SearchWidget}, - Widget, WidgetOutput, + theme, ExecutionContext, Process, ProcessOutput, }; use once_cell::sync::OnceCell; use tui::{backend::CrosstermBackend, layout::Rect, Terminal}; @@ -105,7 +104,7 @@ fn main() { Err(_) => { disable_raw_mode().unwrap(); if let Some(panic_info) = PANIC_INFO.get() { - println!("{panic_info}"); + eprintln!("{panic_info}"); } } } @@ -115,48 +114,54 @@ fn run(cli: Args) -> Result<()> { // Prepare storage let storage = SqliteStorage::new()?; + // Execution context + let context = ExecutionContext { + inline: cli.inline, + theme: theme::DARK, + }; + // Execute command let res = match cli.action { Actions::Save { command, description } => exec( cli.inline, cli.inline_extra_line, - SaveCommandWidget::new(&storage, command, description), + SaveCommandProcess::new(&storage, command, description, context), ), Actions::Search { filter } => exec( cli.inline, cli.inline_extra_line, - SearchWidget::new(&storage, filter.unwrap_or_default())?, + SearchProcess::new(&storage, filter.unwrap_or_default(), context)?, ), Actions::Label { command } => match command.as_labeled_command() { Some(labeled_command) => exec( cli.inline, cli.inline_extra_line, - LabelWidget::new(&storage, labeled_command)?, + LabelProcess::new(&storage, labeled_command, context)?, ), - None => Ok(WidgetOutput::new(" -> The command contains no labels!", command)), + None => Ok(ProcessOutput::new(" -> The command contains no labels!", command)), }, Actions::Export { file } => { let file_path = file.as_deref().unwrap_or("user_commands.txt"); let exported = storage.export(USER_CATEGORY, file_path)?; - Ok(WidgetOutput::message(format!( + Ok(ProcessOutput::message(format!( " -> Successfully exported {exported} commands to '{file_path}'" ))) } Actions::Import { file } => { let new = storage.import(USER_CATEGORY, file)?; - Ok(WidgetOutput::message(format!(" -> Imported {new} new commands"))) + Ok(ProcessOutput::message(format!(" -> Imported {new} new commands"))) } #[cfg(feature = "tldr")] Actions::Fetch { category } => exec( cli.inline, cli.inline_extra_line, - intelli_shell::widgets::FetchWidget::new(category, &storage), + intelli_shell::process::FetchProcess::new(category, &storage), ), }?; // Print any message received if let Some(msg) = res.message { - println!("{msg}"); + eprintln!("{msg}"); } // Write out the result @@ -172,24 +177,23 @@ fn run(cli: Args) -> Result<()> { Ok(()) } -fn exec(inline: bool, inline_extra_line: bool, widget: W) -> Result +fn exec

(inline: bool, inline_extra_line: bool, process: P) -> Result where - W: Widget, + P: Process, { - let theme = theme::DARK; if inline { - exec_inline(widget, theme, inline_extra_line) + exec_inline(process, inline_extra_line) } else { - exec_alt_screen(widget, theme) + exec_alt_screen(process) } } -fn exec_alt_screen(mut widget: W, theme: Theme) -> Result +fn exec_alt_screen

(mut process: P) -> Result where - W: Widget, + P: Process, { // Check if we've got a straight result - if let Some(result) = widget.peek()? { + if let Some(result) = process.peek()? { return Ok(result); } @@ -202,8 +206,8 @@ where let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; - // Show widget - let res = widget.show(&mut terminal, false, theme, |f| f.size()); + // Show process + let res = process.show(&mut terminal, |f| f.size()); // Restore terminal disable_raw_mode()?; @@ -214,18 +218,18 @@ where res } -fn exec_inline(mut widget: W, theme: Theme, extra_line: bool) -> Result +fn exec_inline

(mut process: P, extra_line: bool) -> Result where - W: Widget, + P: Process, { // Check if we've got a straight result - if let Some(result) = widget.peek()? { + if let Some(result) = process.peek()? { return Ok(result); } // Setup terminal let (orig_cursor_x, orig_cursor_y) = cursor::position()?; - let min_height = widget.min_height() as u16; + let min_height = process.min_height() as u16; let mut stdout = io::stdout(); for _ in 0..min_height { stdout.queue(Print("\n"))?; @@ -246,8 +250,8 @@ where let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; - // Show widget - let res = widget.show(&mut terminal, true, theme, |f| { + // Show process + let res = process.show(&mut terminal, |f| { let Rect { x: _, y: _, diff --git a/src/model/command.rs b/src/model/command.rs index a9bc425..c606aa8 100644 --- a/src/model/command.rs +++ b/src/model/command.rs @@ -26,6 +26,10 @@ impl Command { pub fn increment_usage(&mut self) { self.usage += 1; } + + pub fn is_persisted(&self) -> bool { + self.id > 0 + } } impl Display for Command { diff --git a/src/widgets/fetch.rs b/src/process/fetch.rs similarity index 60% rename from src/widgets/fetch.rs rename to src/process/fetch.rs index 5e36b47..62ff61f 100644 --- a/src/widgets/fetch.rs +++ b/src/process/fetch.rs @@ -2,47 +2,49 @@ use anyhow::Result; use crossterm::event::Event; use tui::{backend::Backend, layout::Rect, Frame}; -use crate::{storage::SqliteStorage, theme::Theme, tldr::scrape_tldr_github, Widget, WidgetOutput}; +use crate::{storage::SqliteStorage, tldr::scrape_tldr_github, Process, ProcessOutput}; -/// Widget to fetch new commands +/// Process to fetch new commands /// -/// This widget will provide no UI, it will perform the job on `peek` -pub struct FetchWidget<'a> { +/// This process will provide no UI, it will perform the job on `peek` +pub struct FetchProcess<'a> { /// Storage storage: &'a SqliteStorage, /// Category to fetch category: Option, } -impl<'a> FetchWidget<'a> { +impl<'a> FetchProcess<'a> { pub fn new(category: Option, storage: &'a SqliteStorage) -> Self { Self { category, storage } } } -impl<'a> Widget for FetchWidget<'a> { +impl<'a> Process for FetchProcess<'a> { fn min_height(&self) -> usize { 1 } - fn peek(&mut self) -> Result> { + fn peek(&mut self) -> Result> { let mut commands = scrape_tldr_github(self.category.as_deref())?; let new = self.storage.insert_commands(&mut commands)?; if new == 0 { - Ok(Some(WidgetOutput::message( + Ok(Some(ProcessOutput::message( " -> No new commands to retrieve".to_owned(), ))) } else { - Ok(Some(WidgetOutput::message(format!(" -> Retrieved {new} new commands")))) + Ok(Some(ProcessOutput::message(format!( + " -> Retrieved {new} new commands" + )))) } } - fn render(&mut self, _frame: &mut Frame, _area: Rect, _inline: bool, _theme: Theme) { + fn render(&mut self, _frame: &mut Frame, _area: Rect) { unreachable!() } - fn process_raw_event(&mut self, _event: Event) -> Result> { + fn process_raw_event(&mut self, _event: Event) -> Result> { unreachable!() } } diff --git a/src/process/label.rs b/src/process/label.rs new file mode 100644 index 0000000..aaf9d3e --- /dev/null +++ b/src/process/label.rs @@ -0,0 +1,274 @@ +use anyhow::{bail, Result}; +use crossterm::event::Event; +use itertools::Itertools; +use tui::{ + backend::Backend, + layout::{Constraint, Direction, Layout, Rect}, + style::{Modifier, Style}, + Frame, +}; + +use crate::{ + common::{ + widget::{ + CustomParagraph, CustomStatefulList, CustomStatefulWidget, CustomWidget, LabelSuggestionItem, TextInput, + DEFAULT_HIGHLIGHT_SYMBOL_PREFIX, NEW_LABEL_PREFIX, + }, + ExecutionContext, InteractiveProcess, + }, + model::LabeledCommand, + storage::SqliteStorage, + Process, ProcessOutput, +}; + +/// Process to complete [LabeledCommand] +pub struct LabelProcess<'s> { + /// Storage + storage: &'s SqliteStorage, + /// Command + command: CustomParagraph, + /// Current label index + current_label_ix: usize, + /// Current label name + current_label: String, + /// Suggestions for the current label + suggestions: CustomStatefulList, + // Execution context + ctx: ExecutionContext, +} + +impl<'s> LabelProcess<'s> { + pub fn new(storage: &'s SqliteStorage, command: LabeledCommand, ctx: ExecutionContext) -> Result { + let (current_label_ix, current_label) = command + .next_label() + .ok_or_else(|| anyhow::anyhow!("Command doesn't have labels"))?; + let current_label = current_label.to_owned(); + let suggestions = Self::suggestion_items_for(storage, &command.root, ¤t_label, TextInput::default())?; + + let suggestions = CustomStatefulList::new(suggestions) + .inline(ctx.inline) + .style(Style::default().fg(ctx.theme.main)) + .highlight_style( + Style::default() + .bg(ctx.theme.selected_background) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(DEFAULT_HIGHLIGHT_SYMBOL_PREFIX); + + let command = CustomParagraph::new(command) + .inline(ctx.inline) + .block_title("Command") + .style(Style::default().fg(ctx.theme.main)); + + Ok(Self { + storage, + command, + current_label_ix, + current_label, + suggestions, + ctx, + }) + } + + fn suggestion_items_for( + storage: &SqliteStorage, + root_cmd: &str, + label: &str, + new_suggestion: TextInput, + ) -> Result> { + let mut suggestions = storage + .find_suggestions_for(root_cmd, label)? + .into_iter() + .map(LabelSuggestionItem::Persisted) + .collect_vec(); + + let mut suggestions_from_label = label + .split('|') + .map(|l| LabelSuggestionItem::Label(l.to_owned())) + .collect_vec(); + suggestions.append(&mut suggestions_from_label); + + if !new_suggestion.as_str().is_empty() { + suggestions.retain(|s| match s { + LabelSuggestionItem::New(_) => true, + LabelSuggestionItem::Label(l) => l.contains(new_suggestion.as_str()), + LabelSuggestionItem::Persisted(s) => s.suggestion.contains(new_suggestion.as_str()), + }) + } + suggestions.insert(0, LabelSuggestionItem::New(new_suggestion)); + + Ok(suggestions) + } +} + +impl<'s> Process for LabelProcess<'s> { + fn min_height(&self) -> usize { + (self.suggestions.len() + 1).clamp(4, 15) + } + + fn render(&mut self, frame: &mut Frame, area: Rect) { + // Prepare main layout + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(!self.ctx.inline as u16) + .constraints([Constraint::Length(self.command.min_size().height), Constraint::Min(1)]) + .split(area); + + let header = chunks[0]; + let body = chunks[1]; + + // Display command + self.command.render_in(frame, header, self.ctx.theme); + + // Display label suggestions + self.suggestions.render_in(frame, body, self.ctx.theme); + // check if cursor is needed + if let Some(LabelSuggestionItem::New(t)) = self.suggestions.current() { + frame.set_cursor( + // Put cursor at the input text offset + body.x + + DEFAULT_HIGHLIGHT_SYMBOL_PREFIX.len() as u16 + + NEW_LABEL_PREFIX.len() as u16 + + t.cursor().x + + (!self.ctx.inline as u16), + // Move one line down, from the border to the input line + body.y + (!self.ctx.inline as u16), + ); + } + } + + fn process_raw_event(&mut self, event: Event) -> Result> { + self.process_event(event) + } +} + +impl<'s> InteractiveProcess for LabelProcess<'s> { + fn move_up(&mut self) { + self.suggestions.previous() + } + + fn move_down(&mut self) { + self.suggestions.next() + } + + fn move_left(&mut self) { + if let Some(LabelSuggestionItem::New(suggestion)) = self.suggestions.current_mut() { + suggestion.move_left() + } + } + + fn move_right(&mut self) { + if let Some(LabelSuggestionItem::New(suggestion)) = self.suggestions.current_mut() { + suggestion.move_right() + } + } + + fn prev(&mut self) { + self.suggestions.previous() + } + + fn next(&mut self) { + self.suggestions.next() + } + + fn insert_text(&mut self, text: String) -> Result<()> { + if let Some(LabelSuggestionItem::New(suggestion)) = self.suggestions.current_mut() { + suggestion.insert_text(text); + let suggestion = suggestion.clone(); + self.suggestions.update_items(Self::suggestion_items_for( + self.storage, + &self.command.inner().root, + &self.current_label, + suggestion, + )?); + } + Ok(()) + } + + fn insert_char(&mut self, c: char) -> Result<()> { + if let Some(LabelSuggestionItem::New(suggestion)) = self.suggestions.current_mut() { + suggestion.insert_char(c); + let suggestion = suggestion.clone(); + self.suggestions.update_items(Self::suggestion_items_for( + self.storage, + &self.command.inner().root, + &self.current_label, + suggestion, + )?); + } + Ok(()) + } + + fn delete_char(&mut self, backspace: bool) -> Result<()> { + if let Some(LabelSuggestionItem::New(suggestion)) = self.suggestions.current_mut() { + if suggestion.delete_char(backspace) { + let suggestion = suggestion.clone(); + self.suggestions.update_items(Self::suggestion_items_for( + self.storage, + &self.command.inner().root, + &self.current_label, + suggestion, + )?); + } + } + Ok(()) + } + + fn delete_current(&mut self) -> Result<()> { + if let Some(LabelSuggestionItem::Persisted(_)) = self.suggestions.current() { + if let Some(LabelSuggestionItem::Persisted(suggestion)) = self.suggestions.delete_current() { + self.storage.delete_label_suggestion(&suggestion)?; + } + } + Ok(()) + } + + fn accept_current(&mut self) -> Result> { + if let Some(suggestion) = self.suggestions.current_mut() { + match suggestion { + LabelSuggestionItem::New(value) => { + if !value.as_str().is_empty() { + let suggestion = self + .command + .inner() + .new_suggestion_for(&self.current_label, value.as_str()); + self.storage.insert_label_suggestion(&suggestion)?; + } + self.command.inner_mut().set_next_label(value.as_str()); + } + LabelSuggestionItem::Label(value) => { + self.command.inner_mut().set_next_label(value.clone()); + } + LabelSuggestionItem::Persisted(suggestion) => { + suggestion.increment_usage(); + self.storage.update_label_suggestion(suggestion)?; + self.command.inner_mut().set_next_label(&suggestion.suggestion); + } + } + match self.command.inner().next_label() { + Some((ix, label)) => { + self.current_label_ix = ix; + self.current_label = label.to_owned(); + + let suggestions = Self::suggestion_items_for( + self.storage, + &self.command.inner().root, + label, + TextInput::default(), + )?; + self.suggestions.update_items(suggestions); + self.suggestions.reset_state(); + + Ok(None) + } + None => Ok(Some(ProcessOutput::output(self.command.inner().to_string()))), + } + } else { + bail!("Expected at least one suggestion") + } + } + + fn exit(&mut self) -> Result { + Ok(ProcessOutput::output(self.command.inner().to_string())) + } +} diff --git a/src/widgets/mod.rs b/src/process/mod.rs similarity index 100% rename from src/widgets/mod.rs rename to src/process/mod.rs diff --git a/src/process/save.rs b/src/process/save.rs new file mode 100644 index 0000000..a53a4cd --- /dev/null +++ b/src/process/save.rs @@ -0,0 +1,152 @@ +use anyhow::Result; +use crossterm::event::Event; +use tui::{backend::Backend, layout::Rect, style::Style, Frame}; + +use crate::{ + common::{ + widget::{CustomParagraph, CustomWidget, TextInput}, + ExecutionContext, InteractiveProcess, + }, + model::Command, + storage::{SqliteStorage, USER_CATEGORY}, + Process, ProcessOutput, +}; + +/// Process to save a new [Command] +/// +/// If both command and description are provided upon initialization, this process will show no UI. +/// If the description is missing, it will ask for it. +pub struct SaveCommandProcess<'s> { + /// Storage + storage: &'s SqliteStorage, + /// Command to save + command: String, + /// Provided description of the command + description: Option, + /// Current command description + current_description: CustomParagraph, + // Execution context + ctx: ExecutionContext, +} + +impl<'s> SaveCommandProcess<'s> { + pub fn new( + storage: &'s SqliteStorage, + command: String, + description: Option, + ctx: ExecutionContext, + ) -> Self { + let current_description = CustomParagraph::new(TextInput::default()) + .inline(ctx.inline) + .focus(true) + .inline_title("Description:") + .block_title("Description") + .style(Style::default().fg(ctx.theme.main)); + + Self { + storage, + command, + description, + current_description, + ctx, + } + } + + /// Inserts a new [Command] with provided fields on [USER_CATEGORY] + fn insert_command( + storage: &SqliteStorage, + command: impl Into, + description: impl Into, + ) -> Result { + let cmd = command.into(); + let mut command = Command::new(USER_CATEGORY, cmd, description); + Ok(match storage.insert_command(&mut command)? { + true => ProcessOutput::new(" -> Command was saved successfully", command.cmd), + false => ProcessOutput::new(" -> Command already existed, so it was updated", command.cmd), + }) + } +} + +impl<'s> Process for SaveCommandProcess<'s> { + fn min_height(&self) -> usize { + 5 + } + + fn peek(&mut self) -> Result> { + if self.command.is_empty() { + Ok(Some(ProcessOutput::message(" -> A command must be typed first!"))) + } else { + match &self.description { + Some(d) => Ok(Some(Self::insert_command(self.storage, &self.command, d)?)), + None => Ok(None), + } + } + } + + fn render(&mut self, frame: &mut Frame, area: Rect) { + self.current_description.render_in(frame, area, self.ctx.theme); + } + + fn process_raw_event(&mut self, event: Event) -> Result> { + self.process_event(event) + } +} + +impl<'s> InteractiveProcess for SaveCommandProcess<'s> { + fn move_up(&mut self) { + self.current_description.inner_mut().move_up() + } + + fn move_down(&mut self) { + self.current_description.inner_mut().move_down() + } + + fn move_left(&mut self) { + self.current_description.inner_mut().move_left() + } + + fn move_right(&mut self) { + self.current_description.inner_mut().move_right() + } + + fn prev(&mut self) {} + + fn next(&mut self) {} + + fn insert_text(&mut self, text: String) -> Result<()> { + self.current_description.inner_mut().insert_text(text); + Ok(()) + } + + fn insert_char(&mut self, c: char) -> Result<()> { + self.current_description.inner_mut().insert_char(c); + Ok(()) + } + + fn delete_char(&mut self, backspace: bool) -> Result<()> { + self.current_description.inner_mut().delete_char(backspace); + Ok(()) + } + + fn delete_current(&mut self) -> Result<()> { + Ok(()) + } + + fn accept_current(&mut self) -> Result> { + if !self.current_description.inner().as_str().is_empty() { + // Exit after saving the command + Ok(Some(Self::insert_command( + self.storage, + &self.command, + self.current_description.inner().as_str(), + )?)) + } else { + // Keep waiting for input + Ok(None) + } + } + + fn exit(&mut self) -> Result { + Ok(ProcessOutput::output(self.command.clone())) + } +} diff --git a/src/process/search.rs b/src/process/search.rs new file mode 100644 index 0000000..8ee70f4 --- /dev/null +++ b/src/process/search.rs @@ -0,0 +1,216 @@ +use anyhow::Result; +use crossterm::event::Event; +use tui::{ + backend::Backend, + layout::{Constraint, Direction, Layout, Rect}, + style::{Modifier, Style}, + Frame, +}; + +use super::LabelProcess; +use crate::{ + common::{ + widget::{ + CustomParagraph, CustomStatefulList, CustomStatefulWidget, CustomWidget, TextInput, + DEFAULT_HIGHLIGHT_SYMBOL_PREFIX, + }, + ExecutionContext, InteractiveProcess, Process, + }, + model::{AsLabeledCommand, Command}, + storage::SqliteStorage, + ProcessOutput, +}; + +/// Process to search for [Command] +pub struct SearchProcess<'s> { + /// Storage + storage: &'s SqliteStorage, + /// Current value of the filter box + filter: CustomParagraph, + /// Command list of results + commands: CustomStatefulList, + /// Delegate label widget + delegate_label: Option>, + // Execution context + ctx: ExecutionContext, +} + +impl<'s> SearchProcess<'s> { + pub fn new(storage: &'s SqliteStorage, filter: String, ctx: ExecutionContext) -> Result { + let commands = storage.find_commands(&filter)?; + + let filter = CustomParagraph::new(TextInput::new(filter)) + .inline(ctx.inline) + .focus(true) + .inline_title("(filter)") + .block_title("Filter") + .style(Style::default().fg(ctx.theme.main)); + + let commands = CustomStatefulList::new(commands) + .inline(ctx.inline) + .block_title("Commands") + .style(Style::default().fg(ctx.theme.main)) + .highlight_style( + Style::default() + .bg(ctx.theme.selected_background) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(DEFAULT_HIGHLIGHT_SYMBOL_PREFIX); + + Ok(Self { + commands, + filter, + storage, + delegate_label: None, + ctx, + }) + } + + fn exit_or_label_replace(&mut self, output: ProcessOutput) -> Result> { + if let Some(cmd) = &output.output { + if let Some(labeled_cmd) = cmd.as_labeled_command() { + let w = LabelProcess::new(self.storage, labeled_cmd, self.ctx)?; + self.delegate_label = Some(w); + return Ok(None); + } + } + Ok(Some(output)) + } +} + +impl<'s> Process for SearchProcess<'s> { + fn min_height(&self) -> usize { + (self.commands.len() + 1).clamp(4, 15) + } + + fn peek(&mut self) -> Result> { + if self.storage.is_empty()? { + let message = indoc::indoc! { r#" + -> There are no stored commands yet! + - Try to bookmark some command with 'Ctrl + B' + - Or execute 'intelli-shell fetch' to download a bunch of tldr's useful commands"# + }; + Ok(Some(ProcessOutput::message(message))) + } else if !self.filter.inner().as_str().is_empty() && self.commands.len() == 1 { + if let Some(command) = self.commands.current_mut() { + command.increment_usage(); + self.storage.update_command(command)?; + let cmd = command.cmd.clone(); + self.exit_or_label_replace(ProcessOutput::output(cmd)) + } else { + Ok(None) + } + } else { + Ok(None) + } + } + + fn render(&mut self, frame: &mut Frame, area: Rect) { + // If there's a delegate active, forward to it + if let Some(delegate) = &mut self.delegate_label { + delegate.render(frame, area); + return; + } + + // Prepare main layout + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(!self.ctx.inline as u16) + .constraints([Constraint::Length(self.filter.min_size().height), Constraint::Min(1)]) + .split(area); + + let header = chunks[0]; + let body = chunks[1]; + + // Render filter + self.filter.render_in(frame, header, self.ctx.theme); + + // Render command list + self.commands.render_in(frame, body, self.ctx.theme); + } + + fn process_raw_event(&mut self, event: Event) -> Result> { + // If there's a delegate active, forward to it + if let Some(delegate) = &mut self.delegate_label { + delegate.process_event(event) + } else { + self.process_event(event) + } + } +} + +impl<'s> InteractiveProcess for SearchProcess<'s> { + fn move_up(&mut self) { + self.commands.previous() + } + + fn move_down(&mut self) { + self.commands.next() + } + + fn move_left(&mut self) { + self.filter.inner_mut().move_left() + } + + fn move_right(&mut self) { + self.filter.inner_mut().move_right() + } + + fn prev(&mut self) { + self.commands.previous() + } + + fn next(&mut self) { + self.commands.next() + } + + fn insert_text(&mut self, text: String) -> Result<()> { + self.filter.inner_mut().insert_text(text); + self.commands + .update_items(self.storage.find_commands(self.filter.inner().as_str())?); + Ok(()) + } + + fn insert_char(&mut self, c: char) -> Result<()> { + self.filter.inner_mut().insert_char(c); + self.commands + .update_items(self.storage.find_commands(self.filter.inner().as_str())?); + Ok(()) + } + + fn delete_char(&mut self, backspace: bool) -> Result<()> { + if self.filter.inner_mut().delete_char(backspace) { + self.commands + .update_items(self.storage.find_commands(self.filter.inner().as_str())?); + } + Ok(()) + } + + fn delete_current(&mut self) -> Result<()> { + if let Some(command) = self.commands.delete_current() { + self.storage.delete_command(command.id)?; + } + Ok(()) + } + + fn accept_current(&mut self) -> Result> { + if let Some(command) = self.commands.current_mut() { + command.increment_usage(); + self.storage.update_command(command)?; + let cmd = command.cmd.clone(); + self.exit_or_label_replace(ProcessOutput::output(cmd)) + } else if !self.filter.inner().as_str().is_empty() { + self.exit_or_label_replace(ProcessOutput::output(self.filter.inner().as_str())) + } else { + Ok(Some(ProcessOutput::empty())) + } + } + + fn exit(&mut self) -> Result { + if self.filter.inner().as_str().is_empty() { + Ok(ProcessOutput::empty()) + } else { + Ok(ProcessOutput::output(self.filter.inner().as_str())) + } + } +} diff --git a/src/widgets/label.rs b/src/widgets/label.rs deleted file mode 100644 index 18b0a4f..0000000 --- a/src/widgets/label.rs +++ /dev/null @@ -1,325 +0,0 @@ -use anyhow::{bail, Result}; -use crossterm::event::Event; -use itertools::Itertools; -use tui::{ - backend::Backend, - layout::{Constraint, Direction, Layout, Rect}, - style::{Modifier, Style}, - text::{Span, Spans}, - widgets::{Block, Borders, List, ListItem, Paragraph}, - Frame, -}; - -use crate::{ - common::{EditableText, InputWidget, StatefulList}, - model::{CommandPart, LabelSuggestion, LabeledCommand}, - storage::SqliteStorage, - theme::Theme, - Widget, WidgetOutput, -}; - -/// Widget to complete [LabeledCommand] -pub struct LabelWidget<'s> { - /// Storage - storage: &'s SqliteStorage, - /// Command - command: LabeledCommand, - /// Current label index - current_label_ix: usize, - /// Current label name - current_label: String, - /// Suggestions for the current label - suggestions: StatefulList, -} - -enum Suggestion { - New(EditableText), - Label(String), - Persisted(LabelSuggestion), -} - -impl<'s> LabelWidget<'s> { - pub fn new(storage: &'s SqliteStorage, command: LabeledCommand) -> Result { - let (current_label_ix, current_label) = command - .next_label() - .ok_or_else(|| anyhow::anyhow!("Command doesn't have labels"))?; - let current_label = current_label.to_owned(); - let suggestions = Self::suggestion_items_for(storage, &command.root, ¤t_label, EditableText::default())?; - Ok(Self { - storage, - command, - current_label_ix, - current_label, - suggestions: StatefulList::with_items(suggestions), - }) - } - - fn suggestion_items_for( - storage: &SqliteStorage, - root_cmd: &str, - label: &str, - new_suggestion: EditableText, - ) -> Result> { - let mut suggestions = storage - .find_suggestions_for(root_cmd, label)? - .into_iter() - .map(Suggestion::Persisted) - .collect_vec(); - let mut from_label = label.split('|').map(|l| Suggestion::Label(l.to_owned())).collect_vec(); - suggestions.append(&mut from_label); - if !new_suggestion.as_str().is_empty() { - suggestions.retain(|s| match s { - Suggestion::New(_) => true, - Suggestion::Label(l) => l.contains(new_suggestion.as_str()), - Suggestion::Persisted(s) => s.suggestion.contains(new_suggestion.as_str()), - }) - } - suggestions.insert(0, Suggestion::New(new_suggestion)); - Ok(suggestions) - } -} - -impl<'s> Widget for LabelWidget<'s> { - fn min_height(&self) -> usize { - (self.suggestions.len() + 1).clamp(4, 15) - } - - fn render(&mut self, frame: &mut Frame, area: Rect, inline: bool, theme: Theme) { - // Prepare main layout - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(!inline as u16) - .constraints([ - if inline { - Constraint::Length(1) - } else { - Constraint::Length(3) - }, - Constraint::Min(1), - ]) - .split(area); - - let header = chunks[0]; - let body = chunks[1]; - - // Display command - let max_width = header.width - 1 - (2 * (!inline as u16)); - let mut first_label = true; - let mut current_width = 0; - let mut command_parts = self - .command - .parts - .iter() - .filter_map(|p| { - if first_label || current_width <= max_width { - let was_first_label = first_label; - let span = match p { - CommandPart::Text(t) => Span::raw(t), - CommandPart::Label(l) => { - let style = if first_label { - first_label = false; - Style::default().fg(theme.main).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(theme.disabled) - }; - Span::styled(format!("{{{{{l}}}}}"), style) - } - CommandPart::LabelValue(v) => Span::raw(v), - }; - current_width += span.width() as u16; - if was_first_label || current_width <= max_width { - Some(span) - } else { - None - } - } else { - None - } - }) - .collect_vec(); - while command_parts.iter().map(|s| s.width() as u16).sum::() > max_width { - command_parts.remove(0); - } - let command = Spans::from(command_parts); - let mut command_widget = Paragraph::new(command).style(Style::default().fg(theme.disabled)); - if !inline { - command_widget = command_widget.block(Block::default().borders(Borders::ALL).title(" Command ")); - } - frame.render_widget(command_widget, header); - - // Display label suggestions - const NEW_LABEL_PREFIX: &str = "(new) "; - const HIGHLIGHT_SYMBOL_PREFIX: &str = ">> "; - let (suggestions, state) = self.suggestions.borrow(); - let suggestions: Vec = suggestions - .iter() - .map(|c| match c { - Suggestion::New(value) => ListItem::new(Spans::from(vec![ - Span::styled(NEW_LABEL_PREFIX, Style::default().add_modifier(Modifier::ITALIC)), - Span::raw(value.as_str()), - ])), - Suggestion::Label(value) => ListItem::new(value.clone()), - Suggestion::Persisted(e) => ListItem::new(e.suggestion.clone()), - }) - .collect(); - - let mut suggestions = List::new(suggestions) - .style(Style::default().fg(theme.main)) - .highlight_style( - Style::default() - .bg(theme.selected_background) - .add_modifier(Modifier::BOLD), - ) - .highlight_symbol(HIGHLIGHT_SYMBOL_PREFIX); - if !inline { - suggestions = suggestions.block( - Block::default() - .border_style(Style::default().fg(theme.main)) - .borders(Borders::ALL) - .title(" Labels "), - ); - } - frame.render_stateful_widget(suggestions, body, state); - - if let Some(Suggestion::New(t)) = self.suggestions.current() { - // Make the cursor visible and ask tui-rs to put it at the specified coordinates after rendering - frame.set_cursor( - // Put cursor at the input text offset - body.x - + HIGHLIGHT_SYMBOL_PREFIX.len() as u16 - + NEW_LABEL_PREFIX.len() as u16 - + t.offset() as u16 - + (!inline as u16), - // Move one line down, from the border to the input line - body.y + (!inline as u16), - ); - } - } - - fn process_raw_event(&mut self, event: Event) -> Result> { - self.process_event(event) - } -} - -impl<'s> InputWidget for LabelWidget<'s> { - fn move_up(&mut self) { - self.suggestions.previous() - } - - fn move_down(&mut self) { - self.suggestions.next() - } - - fn move_left(&mut self) { - if let Some(Suggestion::New(suggestion)) = self.suggestions.current_mut() { - suggestion.move_left() - } - } - - fn move_right(&mut self) { - if let Some(Suggestion::New(suggestion)) = self.suggestions.current_mut() { - suggestion.move_right() - } - } - - fn prev(&mut self) { - self.suggestions.previous() - } - - fn next(&mut self) { - self.suggestions.next() - } - - fn insert_text(&mut self, text: String) -> Result<()> { - if let Some(Suggestion::New(suggestion)) = self.suggestions.current_mut() { - suggestion.insert_text(text); - let suggestion = suggestion.clone(); - self.suggestions.update_items(Self::suggestion_items_for( - self.storage, - &self.command.root, - &self.current_label, - suggestion, - )?); - } - Ok(()) - } - - fn insert_char(&mut self, c: char) -> Result<()> { - if let Some(Suggestion::New(suggestion)) = self.suggestions.current_mut() { - suggestion.insert_char(c); - let suggestion = suggestion.clone(); - self.suggestions.update_items(Self::suggestion_items_for( - self.storage, - &self.command.root, - &self.current_label, - suggestion, - )?); - } - Ok(()) - } - - fn delete_char(&mut self, backspace: bool) -> Result<()> { - if let Some(Suggestion::New(suggestion)) = self.suggestions.current_mut() { - if suggestion.delete_char(backspace) { - let suggestion = suggestion.clone(); - self.suggestions.update_items(Self::suggestion_items_for( - self.storage, - &self.command.root, - &self.current_label, - suggestion, - )?); - } - } - Ok(()) - } - - fn delete_current(&mut self) -> Result<()> { - if let Some(Suggestion::Persisted(_)) = self.suggestions.current() { - if let Some(Suggestion::Persisted(suggestion)) = self.suggestions.delete_current() { - self.storage.delete_label_suggestion(&suggestion)?; - } - } - Ok(()) - } - - fn accept_current(&mut self) -> Result> { - if let Some(suggestion) = self.suggestions.current_mut() { - match suggestion { - Suggestion::New(value) => { - if !value.as_str().is_empty() { - let suggestion = self.command.new_suggestion_for(&self.current_label, value.as_str()); - self.storage.insert_label_suggestion(&suggestion)?; - } - self.command.set_next_label(value.as_str()); - } - Suggestion::Label(value) => { - self.command.set_next_label(value.clone()); - } - Suggestion::Persisted(suggestion) => { - suggestion.increment_usage(); - self.storage.update_label_suggestion(suggestion)?; - self.command.set_next_label(&suggestion.suggestion); - } - } - match self.command.next_label() { - Some((ix, label)) => { - self.current_label_ix = ix; - self.current_label = label.to_owned(); - - let suggestions = - Self::suggestion_items_for(self.storage, &self.command.root, label, EditableText::default())?; - self.suggestions = StatefulList::with_items(suggestions); - - Ok(None) - } - None => Ok(Some(WidgetOutput::output(self.command.to_string()))), - } - } else { - bail!("Expected at least one suggestion") - } - } - - fn exit(&mut self) -> Result { - Ok(WidgetOutput::output(self.command.to_string())) - } -} diff --git a/src/widgets/save.rs b/src/widgets/save.rs deleted file mode 100644 index fe70fab..0000000 --- a/src/widgets/save.rs +++ /dev/null @@ -1,164 +0,0 @@ -use anyhow::Result; -use crossterm::event::Event; -use tui::{ - backend::Backend, - layout::Rect, - style::Style, - widgets::{Block, Borders, Paragraph}, - Frame, -}; - -use crate::{ - common::{EditableText, InputWidget, OverflowText}, - model::Command, - storage::{SqliteStorage, USER_CATEGORY}, - theme::Theme, - Widget, WidgetOutput, -}; - -/// Widget to save a new [Command] -/// -/// If both command and description are provided upon initialization, this widget will show no UI. -/// If the description is missing, it will ask for it. -pub struct SaveCommandWidget<'s> { - /// Storage - storage: &'s SqliteStorage, - /// Command to save - command: String, - /// Provided description of the command - description: Option, - /// Current command description - current_description: EditableText, -} - -impl<'s> SaveCommandWidget<'s> { - pub fn new(storage: &'s SqliteStorage, command: String, description: Option) -> Self { - Self { - storage, - command, - description, - current_description: Default::default(), - } - } - - /// Inserts a new [Command] with provided fields on [USER_CATEGORY] - fn insert_command( - storage: &SqliteStorage, - command: impl Into, - description: impl Into, - ) -> Result { - let cmd = command.into(); - let mut command = Command::new(USER_CATEGORY, cmd, description); - Ok(match storage.insert_command(&mut command)? { - true => WidgetOutput::new(" -> Command was saved successfully", command.cmd), - false => WidgetOutput::new(" -> Command already existed, so it was updated", command.cmd), - }) - } -} - -impl<'s> Widget for SaveCommandWidget<'s> { - fn min_height(&self) -> usize { - 1 - } - - fn peek(&mut self) -> Result> { - if self.command.is_empty() { - Ok(Some(WidgetOutput::message(" -> A command must be typed first!"))) - } else { - match &self.description { - Some(d) => Ok(Some(Self::insert_command(self.storage, &self.command, d)?)), - None => Ok(None), - } - } - } - - fn render(&mut self, frame: &mut Frame, area: Rect, inline: bool, theme: Theme) { - // Display description prompt - let mut description_offset = self.current_description.offset(); - let max_width = area.width as usize - 1 - (2 * (!inline as usize)); - let text_inline = format!("Description: {}", self.current_description); - let description_text = if inline { - description_offset += 13; - OverflowText::new(max_width, &text_inline) - } else { - OverflowText::new(max_width, self.current_description.as_str()) - }; - let description_text_width = description_text.width(); - if text_inline.len() > description_text_width { - let overflow = text_inline.len() as i32 - description_text_width as i32; - if overflow < description_offset as i32 { - description_offset -= overflow as usize; - } else { - description_offset = 0; - } - } - let mut description_input = Paragraph::new(description_text).style(Style::default().fg(theme.main)); - if !inline { - description_input = description_input.block(Block::default().borders(Borders::ALL).title(" Description ")); - } - frame.render_widget(description_input, area); - - // Make the cursor visible and ask tui-rs to put it at the specified coordinates after rendering - frame.set_cursor( - // Put cursor past the end of the input text - area.x + description_offset as u16 + (!inline as u16), - // Move one line down, from the border to the input line - area.y + (!inline as u16), - ); - } - - fn process_raw_event(&mut self, event: Event) -> Result> { - self.process_event(event) - } -} - -impl<'s> InputWidget for SaveCommandWidget<'s> { - fn move_up(&mut self) {} - - fn move_down(&mut self) {} - - fn move_left(&mut self) {} - - fn move_right(&mut self) {} - - fn prev(&mut self) {} - - fn next(&mut self) {} - - fn insert_text(&mut self, text: String) -> Result<()> { - self.current_description.insert_text(text); - Ok(()) - } - - fn insert_char(&mut self, c: char) -> Result<()> { - self.current_description.insert_char(c); - Ok(()) - } - - fn delete_char(&mut self, backspace: bool) -> Result<()> { - self.current_description.delete_char(backspace); - Ok(()) - } - - fn delete_current(&mut self) -> Result<()> { - Ok(()) - } - - fn accept_current(&mut self) -> Result> { - if !self.current_description.as_str().is_empty() { - // Exit after saving the command - Ok(Some(Self::insert_command( - self.storage, - &self.command, - self.current_description.as_str(), - )?)) - } else { - // Keep waiting for input - Ok(None) - } - } - - fn exit(&mut self) -> Result { - Ok(WidgetOutput::output(self.command.clone())) - } -} diff --git a/src/widgets/search.rs b/src/widgets/search.rs deleted file mode 100644 index fd3deab..0000000 --- a/src/widgets/search.rs +++ /dev/null @@ -1,257 +0,0 @@ -use anyhow::Result; -use crossterm::event::Event; -use tui::{ - backend::Backend, - layout::{Constraint, Direction, Layout, Rect}, - style::{Modifier, Style}, - text::{Span, Spans}, - widgets::{Block, Borders, List, ListItem, Paragraph}, - Frame, -}; - -use super::LabelWidget; -use crate::{ - common::{EditableText, InputWidget, OverflowText, StatefulList, StrExt, Widget}, - model::{AsLabeledCommand, Command}, - storage::SqliteStorage, - theme::Theme, - WidgetOutput, -}; - -/// Widget to search for [Command] -pub struct SearchWidget<'s> { - /// Storage - storage: &'s SqliteStorage, - /// Current value of the filter box - filter: EditableText, - /// Command list of results - commands: StatefulList, - /// Delegate label widget - delegate_label: Option>, -} - -impl<'s> SearchWidget<'s> { - pub fn new(storage: &'s SqliteStorage, filter: String) -> Result { - let commands = storage.find_commands(&filter)?; - Ok(Self { - commands: StatefulList::with_items(commands), - filter: EditableText::from_str(filter), - storage, - delegate_label: None, - }) - } - - pub fn exit_or_label_replace(&mut self, output: WidgetOutput) -> Result> { - if let Some(cmd) = &output.output { - if let Some(labeled_cmd) = cmd.as_labeled_command() { - let w = LabelWidget::new(self.storage, labeled_cmd)?; - self.delegate_label = Some(w); - return Ok(None); - } - } - Ok(Some(output)) - } -} - -impl<'s> Widget for SearchWidget<'s> { - fn min_height(&self) -> usize { - (self.commands.len() + 1).clamp(4, 15) - } - - fn peek(&mut self) -> Result> { - if self.storage.is_empty()? { - let message = indoc::indoc! { r#" - -> There are no stored commands yet! - - Try to bookmark some command with 'Ctrl + B' - - Or execute 'intelli-shell fetch' to download a bunch of tldr's useful commands"# - }; - Ok(Some(WidgetOutput::message(message))) - } else if !self.filter.as_str().is_empty() && self.commands.len() == 1 { - if let Some(command) = self.commands.current_mut() { - command.increment_usage(); - self.storage.update_command(command)?; - let cmd = command.cmd.clone(); - self.exit_or_label_replace(WidgetOutput::output(cmd)) - } else { - Ok(None) - } - } else { - Ok(None) - } - } - - fn render(&mut self, frame: &mut Frame, area: Rect, inline: bool, theme: Theme) { - // If there's a delegate active, forward to it - if let Some(delegate) = &mut self.delegate_label { - delegate.render(frame, area, inline, theme); - return; - } - - // Prepare main layout - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(!inline as u16) - .constraints([ - if inline { - Constraint::Length(1) - } else { - Constraint::Length(3) - }, - Constraint::Min(1), - ]) - .split(area); - - let header = chunks[0]; - let body = chunks[1]; - - // Display filter - let mut filter_offset = self.filter.offset(); - let max_width = header.width as usize - 1 - (2 * (!inline as usize)); - let text_inline = format!("(filter): {}", self.filter); - let filter_text = if inline { - filter_offset += 10; - OverflowText::new(max_width, &text_inline) - } else { - OverflowText::new(max_width, self.filter.as_str()) - }; - let filter_text_width = filter_text.width(); - if text_inline.len_chars() > filter_text_width { - let overflow = text_inline.len_chars() as i32 - filter_text_width as i32; - if overflow < filter_offset as i32 { - filter_offset -= overflow as usize; - } else { - filter_offset = 0; - } - } - let mut filter_input = Paragraph::new(filter_text).style(Style::default().fg(theme.main)); - if !inline { - filter_input = filter_input.block(Block::default().borders(Borders::ALL).title(" Filter ")); - } - frame.render_widget(filter_input, header); - - // Make the cursor visible and ask tui-rs to put it at the specified coordinates after rendering - frame.set_cursor( - // Put cursor past the end of the input text - header.x + filter_offset as u16 + (!inline as u16), - // Move one line down, from the border to the input line - header.y + (!inline as u16), - ); - - // Display command suggestions - let (commands, state) = self.commands.borrow(); - let commands: Vec = commands - .iter() - .map(|c| { - let content = Spans::from(vec![ - Span::raw(&c.cmd), - Span::styled(" # ", Style::default().fg(theme.secondary)), - Span::styled(&c.description, Style::default().fg(theme.secondary)), - ]); - ListItem::new(content) - }) - .collect(); - - let mut commands = List::new(commands) - .style(Style::default().fg(theme.main)) - .highlight_style( - Style::default() - .bg(theme.selected_background) - .add_modifier(Modifier::BOLD), - ) - .highlight_symbol(">> "); - if !inline { - commands = commands.block( - Block::default() - .border_style(Style::default().fg(theme.main)) - .borders(Borders::ALL) - .title(" Commands "), - ); - } - frame.render_stateful_widget(commands, body, state); - } - - fn process_raw_event(&mut self, event: Event) -> Result> { - // If there's a delegate active, forward to it - if let Some(delegate) = &mut self.delegate_label { - delegate.process_event(event) - } else { - self.process_event(event) - } - } -} - -impl<'s> InputWidget for SearchWidget<'s> { - fn move_up(&mut self) { - self.commands.previous() - } - - fn move_down(&mut self) { - self.commands.next() - } - - fn move_left(&mut self) { - self.filter.move_left() - } - - fn move_right(&mut self) { - self.filter.move_right() - } - - fn prev(&mut self) { - self.commands.previous() - } - - fn next(&mut self) { - self.commands.next() - } - - fn insert_text(&mut self, text: String) -> Result<()> { - self.filter.insert_text(text); - self.commands - .update_items(self.storage.find_commands(self.filter.as_str())?); - Ok(()) - } - - fn insert_char(&mut self, c: char) -> Result<()> { - self.filter.insert_char(c); - self.commands - .update_items(self.storage.find_commands(self.filter.as_str())?); - Ok(()) - } - - fn delete_char(&mut self, backspace: bool) -> Result<()> { - if self.filter.delete_char(backspace) { - self.commands - .update_items(self.storage.find_commands(self.filter.as_str())?); - } - Ok(()) - } - - fn delete_current(&mut self) -> Result<()> { - if let Some(cmd) = self.commands.delete_current() { - self.storage.delete_command(cmd.id)?; - } - Ok(()) - } - - fn accept_current(&mut self) -> Result> { - if let Some(command) = self.commands.current_mut() { - command.increment_usage(); - self.storage.update_command(command)?; - let cmd = command.cmd.clone(); - self.exit_or_label_replace(WidgetOutput::output(cmd)) - } else if !self.filter.as_str().is_empty() { - self.exit_or_label_replace(WidgetOutput::output(self.filter.as_str())) - } else { - Ok(Some(WidgetOutput::empty())) - } - } - - fn exit(&mut self) -> Result { - if self.filter.as_str().is_empty() { - Ok(WidgetOutput::empty()) - } else { - Ok(WidgetOutput::output(self.filter.as_str())) - } - } -}