Generic ui rendering crate
It provides generic ui elements which can be used to compose the more complex prompts
This commit is contained in:
parent
0fca4ccc15
commit
0866cfb239
10
Cargo.toml
10
Cargo.toml
|
@ -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
10
ui/Cargo.toml
Normal 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
93
ui/src/char_input.rs
Normal 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
346
ui/src/lib.rs
Normal 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
148
ui/src/list.rs
Normal 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
265
ui/src/string_input.rs
Normal 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
47
ui/src/widget.rs
Normal 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
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user