diff --git a/Cargo.toml b/Cargo.toml index 0a4b145..ef8332e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,13 @@ [package] -name = "inquire-rs" +name = "inquisition" version = "0.1.0" -authors = ["Lutetium Vanadium "] +authors = ["Lutetium Vanadium"] edition = "2018" -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[workspace] +members = [ + ".", + "ui", +] [dependencies] diff --git a/ui/Cargo.toml b/ui/Cargo.toml new file mode 100644 index 0000000..4c58e0d --- /dev/null +++ b/ui/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "ui" +version = "0.1.0" +authors = ["Lutetium Vanadium"] +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +crossterm = "0.19.0" diff --git a/ui/src/char_input.rs b/ui/src/char_input.rs new file mode 100644 index 0000000..ab36b56 --- /dev/null +++ b/ui/src/char_input.rs @@ -0,0 +1,93 @@ +use std::{fmt, io::Write}; + +use crossterm::event; + +use crate::widget::Widget; + +/// A widget that inputs a single character. If multiple characters are inputted to it, it will have +/// the last character +pub struct CharInput { + value: Option, + filter_map_char: F, +} + +impl CharInput +where + F: Fn(char) -> Option, +{ + /// Creates a new [`CharInput`]. The filter_map_char is used in [`CharInput::handle_key`] to + /// avoid some characters to limit and filter characters. + pub fn new(filter_map_char: F) -> Self { + Self { + value: None, + filter_map_char, + } + } + + /// The last inputted char (if any) + pub fn value(&self) -> Option { + self.value + } + + /// Set the value + pub fn set_value(&mut self, value: Option) { + self.value = value; + } + + /// Consumes self, returning the value + pub fn finish(self) -> Option { + self.value + } +} + +impl Widget for CharInput +where + F: Fn(char) -> Option, +{ + /// Handles character, backspace and delete events. + fn handle_key(&mut self, key: event::KeyEvent) -> bool { + match key.code { + event::KeyCode::Char(c) => { + if let Some(c) = (self.filter_map_char)(c) { + self.value = Some(c); + + return true; + } + + false + } + + event::KeyCode::Backspace | event::KeyCode::Delete if self.value.is_some() => { + self.value = None; + true + } + + _ => false, + } + } + + fn render(&mut self, max_width: usize, w: &mut W) -> crossterm::Result<()> { + if let Some(value) = self.value { + if max_width == 0 { + return Err(fmt::Error.into()); + } + + write!(w, "{}", value)?; + } + Ok(()) + } + + fn cursor_pos(&self, prompt_len: u16) -> (u16, u16) { + (self.value.map(|_| 1).unwrap_or(0) + prompt_len, 0) + } + + fn height(&self) -> usize { + 0 + } +} + +impl Default for CharInput { + fn default() -> Self { + Self::new(crate::widgets::no_filter) + } +} diff --git a/ui/src/lib.rs b/ui/src/lib.rs new file mode 100644 index 0000000..ab9c07f --- /dev/null +++ b/ui/src/lib.rs @@ -0,0 +1,346 @@ +#![deny(missing_docs, rust_2018_idioms)] +//! A widget based cli ui rendering library +use std::{convert::TryFrom, io}; + +use crossterm::{ + cursor, event, execute, queue, + style::{Colorize, Print, PrintStyledContent, Styler}, + terminal, +}; + +pub use widget::Widget; + +/// In build widgets +pub mod widgets { + pub use crate::char_input::CharInput; + pub use crate::list::{List, ListPicker}; + pub use crate::string_input::StringInput; + + /// The default type for filter_map_char in [`StringInput`] and [`CharInput`] + pub type FilterMapChar = fn(char) -> Option; + + /// Character filter that lets every character through + pub fn no_filter(c: char) -> Option { + Some(c) + } +} + +mod char_input; +mod list; +mod string_input; +mod widget; + +/// Returned by [`Prompt::validate`] +pub enum Validation { + /// If the prompt is ready to finish. + Finish, + /// If the state is valid, but the prompt should still persist. + /// Unlike returning an Err, this will not print anything unique, and is a way for the prompt to + /// say that it internally has processed the `Enter` key, but is not complete. + Continue, +} + +/// This trait should be implemented by all 'root' widgets. +/// +/// It provides the functionality specifically required only by the main controlling widget. For the +/// trait required for general rendering to terminal, see [`Widget`]. +pub trait Prompt: Widget { + /// The error type returned by validate. It **must** be only one line long. + type ValidateErr: std::fmt::Display; + + /// The output type returned by [`Input::run`] + type Output; + + /// The main prompt text. It is printed in bold. + fn prompt(&self) -> &str; + /// The hint text. If a hint is there, it is printed in dark grey. + fn hint(&self) -> Option<&str> { + None + } + + /// Determine whether the prompt state is ready to be submitted. It is called whenever the use + /// presses the enter key. + #[allow(unused_variables)] + fn validate(&mut self) -> Result { + Ok(Validation::Finish) + } + /// The value to return from [`Input::run`]. This will only be called after + /// [`validate`](Prompt::validate), if validate returns `Ok(true)` + fn finish(self) -> Self::Output; + + /// The prompt has some default value that can be returned. + fn has_default(&self) -> bool { + true + } + /// The default value to be returned. It will only be called when has_default is true and the + /// user presses escape. + fn finish_default(self) -> Self::Output + where + Self: Sized, + { + unreachable!(); + } +} + +/// The ui runner. It renders and processes events with the help of a type that implements [`Prompt`] +/// +/// See [`run`](Input::run) for more information +pub struct Input

{ + prompt: P, + terminal_h: u16, + terminal_w: u16, + base_row: u16, + base_col: u16, + error_row: Option, + hide_cursor: bool, +} + +impl Input

{ + fn adjust_scrollback( + &self, + height: usize, + stdout: &mut W, + ) -> crossterm::Result { + let th = self.terminal_h as usize; + + let mut base_row = self.base_row; + + if self.base_row as usize >= th - height { + let dist = (self.base_row as usize + height - th + 1) as u16; + base_row -= dist; + queue!(stdout, terminal::ScrollUp(dist), cursor::MoveUp(dist))?; + } + + Ok(base_row) + } + + fn set_cursor_pos(&self, stdout: &mut W) -> crossterm::Result<()> { + let (dcw, dch) = self.prompt.cursor_pos(self.base_col); + execute!(stdout, cursor::MoveTo(dcw, self.base_row + dch)) + } + + fn render(&mut self, stdout: &mut W) -> crossterm::Result<()> { + let height = self.prompt.height(); + self.base_row = self.adjust_scrollback(height, stdout)?; + self.clear(self.base_col, stdout)?; + queue!(stdout, cursor::MoveTo(self.base_col, self.base_row))?; + + self.prompt + .render((self.terminal_w - self.base_col) as usize, stdout)?; + + self.set_cursor_pos(stdout) + } + + fn clear(&self, prompt_len: u16, stdout: &mut W) -> crossterm::Result<()> { + if self.base_row + 1 < self.terminal_h { + queue!( + stdout, + cursor::MoveTo(0, self.base_row + 1), + terminal::Clear(terminal::ClearType::FromCursorDown), + )?; + } + + queue!( + stdout, + cursor::MoveTo(prompt_len, self.base_row), + terminal::Clear(terminal::ClearType::UntilNewLine), + ) + } + + #[inline] + fn finish( + self, + pressed_enter: bool, + prompt_len: u16, + stdout: &mut W, + ) -> crossterm::Result { + self.clear(prompt_len, stdout)?; + stdout.flush()?; + if pressed_enter { + Ok(self.prompt.finish()) + } else { + Ok(self.prompt.finish_default()) + } + } + + /// Run the ui on the given writer. It will return when the user presses `Enter` or `Escape` + /// based on the [`Prompt`] implementation. + pub fn run(mut self, stdout: &mut W) -> crossterm::Result { + let (tw, th) = terminal::size()?; + self.terminal_h = th; + self.terminal_w = tw; + + let prompt = self.prompt.prompt(); + let prompt_len = u16::try_from(prompt.chars().count() + 3).expect("really big prompt"); + + let _raw = RawMode::enable()?; + let _cursor = if self.hide_cursor { + Some(HideCursor::enable(stdout)?) + } else { + None + }; + + let height = self.prompt.height(); + self.base_row = cursor::position()?.1; + self.base_row = self.adjust_scrollback(height, stdout)?; + + queue!( + stdout, + PrintStyledContent("? ".green()), + PrintStyledContent(prompt.bold()), + Print(' '), + )?; + + let hint_len = match self.prompt.hint() { + Some(hint) => { + queue!(stdout, PrintStyledContent(hint.dark_grey()), Print(' '))?; + u16::try_from(hint.chars().count() + 1).expect("really big prompt") + } + None => 0, + }; + + self.base_col = prompt_len + hint_len; + + self.render(stdout)?; + + loop { + match event::read()? { + event::Event::Resize(tw, th) => { + self.terminal_w = tw; + self.terminal_h = th; + } + + event::Event::Key(e) => { + if let Some(error_row) = self.error_row.take() { + let pos = cursor::position()?; + queue!( + stdout, + cursor::MoveTo(0, error_row), + terminal::Clear(terminal::ClearType::CurrentLine), + cursor::MoveTo(pos.0, pos.1) + )?; + } + + let key_handled = match e.code { + event::KeyCode::Char('c') + if e.modifiers.contains(event::KeyModifiers::CONTROL) => + { + queue!( + stdout, + cursor::MoveTo(0, self.base_row + self.prompt.height() as u16) + )?; + drop(_raw); + drop(_cursor); + exit() + } + event::KeyCode::Null => { + queue!( + stdout, + cursor::MoveTo(0, self.base_row + self.prompt.height() as u16) + )?; + drop(_raw); + drop(_cursor); + exit() + } + event::KeyCode::Esc if self.prompt.has_default() => { + return self.finish(false, prompt_len, stdout); + } + + event::KeyCode::Enter => match self.prompt.validate() { + Ok(Validation::Finish) => { + return self.finish(true, prompt_len, stdout); + } + Ok(Validation::Continue) => true, + Err(e) => { + let height = self.prompt.height() + 1; + self.base_row = self.adjust_scrollback(height, stdout)?; + + queue!(stdout, cursor::MoveTo(0, self.base_row + height as u16))?; + write!(stdout, "{} {}", ">>".dark_red(), e)?; + + self.error_row = Some(self.base_row + height as u16); + + self.set_cursor_pos(stdout)?; + + continue; + } + }, + _ => self.prompt.handle_key(e), + }; + + if key_handled { + self.render(stdout)?; + } + } + _ => {} + } + } + } +} + +impl

Input

{ + /// Creates a new Input + pub fn new(prompt: P) -> Self { + Self { + prompt, + base_row: 0, + base_col: 0, + terminal_h: 0, + terminal_w: 0, + error_row: None, + hide_cursor: false, + } + } + + /// Hides the cursor while running the input + pub fn hide_cursor(mut self) -> Self { + self.hide_cursor = true; + self + } +} + +// FIXME: maybe allow this to be changed? +fn exit() -> ! { + std::process::exit(130); +} + +/// Simple helper to make sure if the code panics in between, raw mode is disabled +pub struct RawMode { + _private: (), +} + +impl RawMode { + /// Enable raw mode for the terminal + pub fn enable() -> crossterm::Result { + terminal::enable_raw_mode()?; + Ok(Self { _private: () }) + } +} + +impl Drop for RawMode { + fn drop(&mut self) { + let _ = terminal::disable_raw_mode(); + } +} + +/// Simple helper to make sure if the code panics in between, cursor is shown +pub struct HideCursor { + _private: (), +} + +impl HideCursor { + /// Hide the cursor in the terminal + /// note: it is implicitly bound to stdout because it is required in the destructor + pub fn enable(stdout: &mut W) -> crossterm::Result { + queue!(stdout, cursor::Hide)?; + Ok(Self { _private: () }) + } +} + +impl Drop for HideCursor { + fn drop(&mut self) { + // FIXME: this implicitly binds the hiding cursor to stdout, even though enable is generic + // over any write + let _ = queue!(io::stdout(), cursor::Show); + } +} diff --git a/ui/src/list.rs b/ui/src/list.rs new file mode 100644 index 0000000..bf5ee55 --- /dev/null +++ b/ui/src/list.rs @@ -0,0 +1,148 @@ +use std::io::Write; + +use crossterm::{cursor, event, queue, terminal}; + +use crate::widget::Widget; + +/// A trait to represent a renderable list +pub trait List { + /// Render a single element at some index in **only** one line + fn render_item( + &mut self, + index: usize, + hovered: bool, + max_width: usize, + w: &mut W, + ) -> crossterm::Result<()>; + + /// Whether the element at a particular index is selectable. Those that are not selectable are + /// skipped over when the navigation keys are used. + fn is_selectable(&self, index: usize) -> bool; + + /// The length of the list + fn len(&self) -> usize; + /// Returns true if the list has no elements + fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +/// A widget to select a single item from a list. It can also be used to generally keep track of +/// movements within a list. +pub struct ListPicker { + first_selectable: usize, + last_selectable: usize, + at: usize, + /// The underlying list + pub list: L, +} + +impl ListPicker { + /// Creates a new [`ListPicker`] + pub fn new(list: L) -> Self { + let first_selectable = (0..list.len()) + .position(|i| list.is_selectable(i)) + .expect("there must be at least one selectable item"); + + let last_selectable = (0..list.len()) + .rposition(|i| list.is_selectable(i)) + .unwrap(); + + Self { + first_selectable, + last_selectable, + at: first_selectable, + list, + } + } + + /// The index of the element that is currently being hovered + pub fn get_at(&self) -> usize { + self.at + } + + /// Set the index of the element that is currently being hovered + pub fn set_at(&mut self, at: usize) { + self.at = at; + } + + /// Consumes the list picker returning the original list. If you need the selected item, use + /// [`get_at`](ListPicker::get_at) + pub fn finish(self) -> L { + self.list + } + + fn next_selectable(&self) -> usize { + let mut at = self.at; + loop { + at = (at + 1).min(self.list.len()) % self.list.len(); + if self.list.is_selectable(at) { + break; + } + } + at + } + + fn prev_selectable(&self) -> usize { + let mut at = self.at; + loop { + at = (self.list.len() + at.min(self.list.len()) - 1) % self.list.len(); + if self.list.is_selectable(at) { + break; + } + } + at + } +} + +impl Widget for ListPicker { + /// It handles the following keys: + /// - Up and 'k' to move up to the previous selectable element + /// - Down and 'j' to move up to the next selectable element + /// - Home, PageUp and 'g' to go to the first selectable element + /// - End, PageDown and 'G' to go to the last selectable element + fn handle_key(&mut self, key: event::KeyEvent) -> bool { + match key.code { + event::KeyCode::Up | event::KeyCode::Char('k') => { + self.at = self.prev_selectable(); + } + event::KeyCode::Down | event::KeyCode::Char('j') => { + self.at = self.next_selectable(); + } + + event::KeyCode::Home | event::KeyCode::PageUp | event::KeyCode::Char('g') + if self.at != 0 => + { + self.at = self.first_selectable; + } + event::KeyCode::End | event::KeyCode::PageDown | event::KeyCode::Char('G') + if self.at != self.list.len() - 1 => + { + self.at = self.last_selectable; + } + + _ => return false, + } + + true + } + + fn render(&mut self, _: usize, w: &mut W) -> crossterm::Result<()> { + let max_width = terminal::size()?.0 as usize; + queue!(w, cursor::MoveToNextLine(1))?; + for i in 0..self.list.len() { + self.list.render_item(i, i == self.at, max_width, w)?; + queue!(w, cursor::MoveToNextLine(1))?; + } + + Ok(()) + } + + fn cursor_pos(&self, _: u16) -> (u16, u16) { + (0, 1 + self.at as u16) + } + + fn height(&self) -> usize { + self.list.len() + } +} diff --git a/ui/src/string_input.rs b/ui/src/string_input.rs new file mode 100644 index 0000000..531e787 --- /dev/null +++ b/ui/src/string_input.rs @@ -0,0 +1,265 @@ +use std::{ + fmt, + io::{self, Write}, +}; + +use crossterm::event; + +use crate::widget::Widget; + +/// A widget that inputs a line of text +pub struct StringInput { + value: String, + mask: Option, + hide_output: bool, + /// The character length of the string + value_len: usize, + /// The position of the 'cursor' in characters + at: usize, + filter_map_char: F, +} + +impl StringInput { + /// Creates a new [`StringInput`]. The filter_map_char is used in [`StringInput::handle_key`] to + /// avoid some characters to limit and filter characters. + pub fn new(filter_map_char: F) -> Self { + Self { + value: String::new(), + value_len: 0, + at: 0, + filter_map_char, + mask: None, + hide_output: false, + } + } + + /// A mask to print in render instead of the actual characters + pub fn mask(mut self, mask: char) -> Self { + self.mask = Some(mask); + self + } + + /// Whether to render nothing, but still keep track of all the characters + pub fn hide_output(mut self) -> Self { + self.hide_output = true; + self + } + + /// A helper that sets mask if mask is some, otherwise hides the output + pub fn password(self, mask: Option) -> Self { + match mask { + Some(mask) => self.mask(mask), + None => self.hide_output(), + } + } + + /// The currently inputted value + pub fn value(&self) -> &str { + &self.value + } + + /// Sets the value + pub fn set_value(&mut self, value: String) { + self.value_len = value.chars().count(); + self.at = self.value_len; + self.value = value; + } + + /// Check whether any character has come to the input + pub fn has_value(&self) -> bool { + self.value.capacity() > 0 + } + + /// Returns None if no characters have been inputted, otherwise returns Some + /// + /// note: it can return Some(""), if a character was added and then deleted. It will only return + /// None when no character was ever received + pub fn finish(self) -> Option { + self.has_value().then(|| self.value) + } + + /// Gets the byte index of a given char index + fn get_byte_i(&self, index: usize) -> usize { + self.value + .char_indices() + .nth(index) + .map(|(i, _)| i) + .unwrap_or_else(|| self.value.len()) + } + + /// Gets the char index of a given byte index + fn get_char_i(&self, byte_i: usize) -> usize { + self.value + .char_indices() + .position(|(i, _)| i == byte_i) + .unwrap_or_else(|| self.value.char_indices().count()) + } + + /// Returns the byte index of the start of the first word to the left (< byte_i) + fn find_word_left(&self, byte_i: usize) -> usize { + self.value[..byte_i] + .trim_end() + .rfind(char::is_whitespace) + .map(|new_byte_i| self.get_char_i(new_byte_i) + 1) + .unwrap_or(0) + } + + /// Returns the byte index of the start of the first word to the right (> byte_i) + fn find_word_right(&self, byte_i: usize) -> usize { + let trimmed = self.value[byte_i..].trim_start(); + + trimmed + .find(char::is_whitespace) + .map(|new_byte_i| self.get_char_i(new_byte_i + self.value.len() - trimmed.len()) + 1) + .unwrap_or(self.value_len) + } +} + +impl Widget for StringInput +where + F: Fn(char) -> Option, +{ + /// Handles characters, backspace, delete, left arrow, right arrow, home and end. + fn handle_key(&mut self, key: event::KeyEvent) -> bool { + match key.code { + event::KeyCode::Left + if self.at != 0 + && key + .modifiers + .intersects(event::KeyModifiers::CONTROL | event::KeyModifiers::ALT) => + { + self.at = self.find_word_left(self.get_byte_i(self.at)); + } + event::KeyCode::Left if self.at != 0 => { + self.at -= 1; + } + + event::KeyCode::Right + if self.at != self.value_len + && key + .modifiers + .intersects(event::KeyModifiers::CONTROL | event::KeyModifiers::ALT) => + { + self.at = self.find_word_right(self.get_byte_i(self.at)); + } + event::KeyCode::Right if self.at != self.value_len => { + self.at += 1; + } + + event::KeyCode::Home if self.at != 0 => { + self.at = 0; + } + event::KeyCode::End if self.at != self.value_len => { + self.at = self.value_len; + } + + event::KeyCode::Char(c) if !key.modifiers.contains(event::KeyModifiers::CONTROL) => { + if let Some(c) = (self.filter_map_char)(c) { + if self.at == self.value_len { + self.value.push(c); + } else { + let byte_i = self.get_byte_i(self.at); + self.value.insert(byte_i, c); + }; + + self.at += 1; + self.value_len += 1; + } else { + return false; + } + } + + event::KeyCode::Backspace if self.at == 0 => {} + event::KeyCode::Backspace if key.modifiers.contains(event::KeyModifiers::ALT) => { + let was_at = self.at; + let byte_i = self.get_byte_i(self.at); + self.at = self.find_word_left(byte_i); + self.value_len -= was_at - self.at; + self.value + .replace_range(self.get_byte_i(self.at)..byte_i, ""); + } + event::KeyCode::Backspace if self.at == self.value_len => { + self.at -= 1; + self.value_len -= 1; + self.value.pop(); + } + event::KeyCode::Backspace => { + self.at -= 1; + let byte_i = self.get_byte_i(self.at); + self.value_len -= 1; + self.value.remove(byte_i); + } + + event::KeyCode::Delete if self.at == self.value_len => {} + event::KeyCode::Delete if key.modifiers.contains(event::KeyModifiers::ALT) => { + let byte_i = self.get_byte_i(self.at); + let next_word = self.find_word_right(byte_i); + self.value_len -= next_word - self.at; + self.value + .replace_range(byte_i..self.get_byte_i(next_word), ""); + } + event::KeyCode::Delete if self.at == self.value_len - 1 => { + self.value_len -= 1; + self.value.pop(); + } + event::KeyCode::Delete => { + let byte_i = self.get_byte_i(self.at); + self.value_len -= 1; + self.value.remove(byte_i); + } + + _ => return false, + } + + true + } + + fn render(&mut self, max_width: usize, w: &mut W) -> crossterm::Result<()> { + if self.hide_output { + return Ok(()); + } + + if max_width <= 3 { + return Err(fmt::Error.into()); + } + + if self.value_len > max_width { + unimplemented!("Big strings"); + } else if let Some(mask) = self.mask { + print_mask(self.value_len, mask, w)?; + } else { + w.write_all(self.value.as_bytes())?; + } + + Ok(()) + } + + fn cursor_pos(&self, prompt_len: u16) -> (u16, u16) { + if self.hide_output { + (prompt_len, 0) + } else { + (prompt_len + self.at as u16, 0) + } + } + + fn height(&self) -> usize { + 0 + } +} + +impl Default for StringInput { + fn default() -> Self { + Self::new(crate::widgets::no_filter) + } +} + +fn print_mask(len: usize, mask: char, w: &mut W) -> io::Result<()> { + let mut buf = [0; 4]; + let mask = mask.encode_utf8(&mut buf[..]); + + for _ in 0..len { + w.write_all(mask.as_bytes())?; + } + + Ok(()) +} diff --git a/ui/src/widget.rs b/ui/src/widget.rs new file mode 100644 index 0000000..50b56fe --- /dev/null +++ b/ui/src/widget.rs @@ -0,0 +1,47 @@ +use std::{fmt, io::Write}; + +use crossterm::event; + +/// A trait to represent renderable objects. +pub trait Widget { + /// Handle a key input. It should return whether key was handled. + #[allow(unused_variables)] + fn handle_key(&mut self, key: event::KeyEvent) -> bool { + false + } + + /// Render to stdout. `max_width` is the number of characters that can be printed in the current + /// line. + fn render(&mut self, max_width: usize, stdout: &mut W) -> crossterm::Result<()>; + + /// The number of rows of the terminal the widget will take when rendered + fn height(&self) -> usize; + + /// The position of the cursor to end at, with (0,0) being the start of the input + #[allow(unused_variables)] + fn cursor_pos(&self, prompt_len: u16) -> (u16, u16) { + (prompt_len, 0) + } +} + +impl> Widget for T { + fn render(&mut self, max_width: usize, w: &mut W) -> crossterm::Result<()> { + let s = self.as_ref(); + + if max_width <= 3 { + return Err(fmt::Error.into()); + } + + if s.chars().count() > max_width { + let byte_i = s.char_indices().nth(max_width - 3).unwrap().0; + w.write_all(s[..byte_i].as_bytes())?; + w.write_all(b"...").map_err(Into::into) + } else { + w.write_all(s.as_bytes()).map_err(Into::into) + } + } + + fn height(&self) -> usize { + 0 + } +}