Generic ui rendering crate

It provides generic ui elements which can be used to compose the more
complex prompts
This commit is contained in:
Lutetium-Vanadium 2021-04-10 10:19:09 +05:30
parent 0fca4ccc15
commit 0866cfb239
7 changed files with 916 additions and 3 deletions

View File

@ -1,9 +1,13 @@
[package]
name = "inquire-rs"
name = "inquisition"
version = "0.1.0"
authors = ["Lutetium Vanadium <luv.s7000@gmail.com>"]
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]

10
ui/Cargo.toml Normal file
View File

@ -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"

93
ui/src/char_input.rs Normal file
View File

@ -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<F = crate::widgets::FilterMapChar> {
value: Option<char>,
filter_map_char: F,
}
impl<F> CharInput<F>
where
F: Fn(char) -> Option<char>,
{
/// 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<char> {
self.value
}
/// Set the value
pub fn set_value(&mut self, value: Option<char>) {
self.value = value;
}
/// Consumes self, returning the value
pub fn finish(self) -> Option<char> {
self.value
}
}
impl<F> Widget for CharInput<F>
where
F: Fn(char) -> Option<char>,
{
/// 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<W: Write>(&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)
}
}

346
ui/src/lib.rs Normal file
View File

@ -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<char>;
/// Character filter that lets every character through
pub fn no_filter(c: char) -> Option<char> {
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<Validation, Self::ValidateErr> {
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<P> {
prompt: P,
terminal_h: u16,
terminal_w: u16,
base_row: u16,
base_col: u16,
error_row: Option<u16>,
hide_cursor: bool,
}
impl<P: Prompt> Input<P> {
fn adjust_scrollback<W: io::Write>(
&self,
height: usize,
stdout: &mut W,
) -> crossterm::Result<u16> {
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<W: io::Write>(&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<W: io::Write>(&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<W: io::Write>(&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<W: io::Write>(
self,
pressed_enter: bool,
prompt_len: u16,
stdout: &mut W,
) -> crossterm::Result<P::Output> {
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<W: io::Write>(mut self, stdout: &mut W) -> crossterm::Result<P::Output> {
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<P> Input<P> {
/// 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<Self> {
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<W: io::Write>(stdout: &mut W) -> crossterm::Result<Self> {
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);
}
}

148
ui/src/list.rs Normal file
View File

@ -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<W: Write>(
&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<L> {
first_selectable: usize,
last_selectable: usize,
at: usize,
/// The underlying list
pub list: L,
}
impl<L: List> ListPicker<L> {
/// 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<L: List> Widget for ListPicker<L> {
/// 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<W: Write>(&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()
}
}

265
ui/src/string_input.rs Normal file
View File

@ -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<F = crate::widgets::FilterMapChar> {
value: String,
mask: Option<char>,
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<F> StringInput<F> {
/// 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<char>) -> 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<String> {
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<F> Widget for StringInput<F>
where
F: Fn(char) -> Option<char>,
{
/// 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<W: Write>(&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<W: Write>(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(())
}

47
ui/src/widget.rs Normal file
View File

@ -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<W: Write>(&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<T: AsRef<str>> Widget for T {
fn render<W: Write>(&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
}
}