Add TextInput widget, Interaction* types for interactive widgets

pull/639/head
Dylan Knutson 2 years ago
parent a6b25a4877
commit 7f68927467

@ -0,0 +1,245 @@
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use std::{error::Error, io};
use tui::{
backend::{Backend, CrosstermBackend},
layout::{Constraint, Layout},
style::{Color, Modifier, Style},
text::{Span, Spans},
widgets::{
Block, Borders, Cell, InteractiveWidgetState, List, ListItem, Paragraph, Row, Table, TextInput,
TextInputState,
},
Frame, Terminal,
};
fn main() -> Result<(), Box<dyn Error>> {
// setup terminal
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
// create app and run it
let res = run_app(&mut terminal);
// restore terminal
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err)
}
Ok(())
}
const NUM_INPUTS: usize = 3;
#[derive(Default)]
struct App {
input_states: [TextInputState; NUM_INPUTS],
focused_input_idx: Option<usize>,
events: Vec<Event>,
}
impl App {
fn focus_next(&mut self) {
self.focused_input_idx = match self.focused_input_idx {
Some(idx) => {
if idx == (NUM_INPUTS - 1) {
None
} else {
Some(idx + 1)
}
}
None => Some(0),
};
self.set_focused();
}
fn focus_prev(&mut self) {
self.focused_input_idx = match self.focused_input_idx {
Some(idx) => {
if idx == 0 {
None
} else {
Some(idx - 1)
}
}
None => Some(NUM_INPUTS - 1),
};
self.set_focused();
}
fn set_focused(&mut self) {
for input_state in self.input_states.iter_mut() {
input_state.unfocus();
}
if let Some(idx) = self.focused_input_idx {
self.input_states[idx].focus();
}
}
fn focused_input_mut(&mut self) -> Option<&mut TextInputState> {
if let Some(idx) = self.focused_input_idx {
Some(&mut self.input_states[idx])
} else {
None
}
}
}
fn run_app<B: Backend>(terminal: &mut Terminal<B>) -> io::Result<()> {
let mut app = App::default();
loop {
terminal.draw(|f| ui(f, &mut app))?;
let event = event::read()?;
app.events.push(event);
if let Some(state) = app.focused_input_mut() {
if state.handle_event(event).is_consumed() {
continue;
}
}
match event {
Event::Key(key) => match key.code {
KeyCode::Char('q') => return Ok(()),
KeyCode::Tab => app.focus_next(),
KeyCode::BackTab => app.focus_prev(),
_ => {}
},
_ => {}
}
}
}
fn ui<B: Backend>(f: &mut Frame<B>, app: &mut App) {
let layout = Layout::default()
.horizontal_margin(10)
.vertical_margin(2)
.constraints(
[
Constraint::Length(10),
Constraint::Length(14),
Constraint::Length(5),
Constraint::Percentage(100),
]
.as_ref(),
)
.split(f.size());
let info_block = Paragraph::new(vec![
Spans::from(Span::raw("Press 'TAB' to go to the next input")),
Spans::from(Span::raw("Press 'SHIFT+TAB' to go to the previous input")),
Spans::from(Span::raw("Press 'q' to quit when no input is focused")),
Spans::from(Span::raw(
"Supports a subset of readline keyboard shortcuts:",
)),
Spans::from(Span::raw(
" - ctrl+e / ctrl+a to jump to text input end / start",
)),
Spans::from(Span::raw(
" - ctrl+w delete to the start of the current word",
)),
Spans::from(Span::raw(
" - alt+b / alt+f to jump backwards / forwards a word",
)),
Spans::from(Span::raw(" - left / right arrow keys to move the cursor")),
])
.block(Block::default().title("Information").borders(Borders::ALL));
f.render_widget(info_block, layout[0]);
let inputs_block = Block::default().title("Inputs").borders(Borders::ALL);
let inputs_rect = inputs_block.inner(layout[1]);
f.render_widget(inputs_block, layout[1]);
let inputs_layout = Layout::default()
.constraints(
[
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
]
.as_ref(),
)
.split(inputs_rect);
{
let text_input =
TextInput::new().block(Block::default().title("Basic Input").borders(Borders::ALL));
f.render_interactive(text_input, inputs_layout[0], &mut app.input_states[0]);
}
{
let text_input = TextInput::new()
.block(
Block::default()
.title("Has Placeholder")
.borders(Borders::ALL),
)
.placeholder_text("Type something...");
f.render_interactive(text_input, inputs_layout[1], &mut app.input_states[1]);
}
{
let text_input = TextInput::new()
.text_style(Style::default().fg(Color::Yellow))
.block(Block::default().title("Is Followed").borders(Borders::ALL));
f.render_interactive(text_input, inputs_layout[2], &mut app.input_states[2]);
}
{
let text_input = TextInput::new()
.read_only(true)
.text_style(Style::default().fg(Color::LightBlue))
.block(
Block::default()
.title("Follows Above (read only)")
.borders(Borders::ALL),
);
f.render_interactive(text_input, inputs_layout[3], &mut app.input_states[2]);
}
let table = Table::new(
app.input_states
.iter()
.enumerate()
.map(|(idx, input_state)| {
Row::new(vec![
Cell::from(Span::raw(format!("Input {}", idx + 1))),
Cell::from(Span::styled(
input_state.get_value(),
Style::default().add_modifier(Modifier::BOLD),
)),
])
})
.collect::<Vec<_>>(),
)
.widths(&[Constraint::Min(10), Constraint::Percentage(100)])
.block(Block::default().title("Input Values").borders(Borders::ALL));
f.render_widget(table, layout[2]);
let events = List::new(
app.events
.iter()
.rev()
.map(|event| ListItem::new(Span::raw(format!("{:?}", event))))
.collect::<Vec<_>>(),
)
.block(Block::default().title("Events").borders(Borders::ALL));
f.render_widget(events, layout[3]);
}

@ -2,7 +2,7 @@ use crate::{
backend::Backend,
buffer::Buffer,
layout::Rect,
widgets::{StatefulWidget, Widget},
widgets::{InteractiveWidget, StatefulWidget, Widget},
};
use std::io;
@ -133,6 +133,20 @@ where
widget.render(area, self.terminal.current_buffer_mut(), state);
}
pub fn render_interactive<W>(&mut self, widget: W, area: Rect, state: &W::State)
where
W: InteractiveWidget,
{
widget.render(area, self, state);
}
pub fn render_interactive_mut<W>(&mut self, widget: W, area: Rect, state: &mut W::State)
where
W: InteractiveWidget,
{
widget.render_mut(area, self, state);
}
/// After drawing this frame, make the cursor visible and put it at the specified (x, y)
/// coordinates. If this method is not called, the cursor will be hidden.
///

@ -0,0 +1,25 @@
use crossterm::event::Event;
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum InteractionOutcome {
Consumed,
Bubble,
}
impl InteractionOutcome {
pub fn is_consumed(&self) -> bool {
matches!(self, InteractionOutcome::Consumed)
}
pub fn is_bubble(&self) -> bool {
matches!(self, InteractionOutcome::Bubble)
}
}
pub trait InteractiveWidgetState {
fn handle_event(&mut self, _event: Event) -> InteractionOutcome {
InteractionOutcome::Bubble
}
fn is_focused(&self) -> bool;
fn focus(&mut self);
fn unfocus(&mut self);
}

@ -27,6 +27,10 @@ mod reflow;
mod sparkline;
mod table;
mod tabs;
mod text_input;
#[cfg(feature = "crossterm")]
mod crossterm_interactive_widget;
pub use self::barchart::BarChart;
pub use self::block::{Block, BorderType};
@ -38,7 +42,13 @@ pub use self::paragraph::{Paragraph, Wrap};
pub use self::sparkline::Sparkline;
pub use self::table::{Cell, Row, Table, TableState};
pub use self::tabs::Tabs;
pub use self::text_input::{TextInput, TextInputState};
#[cfg(feature = "crossterm")]
pub use self::crossterm_interactive_widget::{InteractiveWidgetState, InteractionOutcome};
use crate::backend::Backend;
use crate::Frame;
use crate::{buffer::Buffer, layout::Rect};
use bitflags::bitflags;
@ -182,3 +192,21 @@ pub trait StatefulWidget {
type State;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State);
}
pub trait InteractiveWidget {
type State;
fn render<'a, B: Backend + 'a>(
self,
area: Rect,
frame: &mut Frame<'a, B>,
state: &Self::State,
);
fn render_mut<'a, B: Backend + 'a>(
self,
area: Rect,
frame: &mut Frame<'a, B>,
state: &mut Self::State,
);
}

@ -0,0 +1,315 @@
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
use crate::widgets::{InteractiveWidgetState, InteractionOutcome, TextInputState};
impl InteractiveWidgetState for TextInputState {
fn handle_event(&mut self, event: Event) -> InteractionOutcome {
if !self.is_focused() {
return InteractionOutcome::Bubble;
}
match event {
Event::Key(key) => self.handle_key(key),
_ => InteractionOutcome::Bubble,
}
}
fn is_focused(&self) -> bool {
self.is_focused()
}
fn focus(&mut self) {
self.focus()
}
fn unfocus(&mut self) {
self.unfocus()
}
}
impl TextInputState {
// used in tests
#[allow(dead_code)]
fn up_to_cursor(&self) -> &str {
&self.value[0..self.cursor_pos as usize]
}
fn handle_key(&mut self, key: KeyEvent) -> InteractionOutcome {
if key.modifiers == KeyModifiers::ALT || key.modifiers == KeyModifiers::CONTROL {
self.handle_modifiers(key.modifiers, key.code)
} else {
self.handle_plain(key.code)
}
}
fn word_boundary_idx_under_cursor(&self, scan_backwards: bool) -> usize {
let value_as_chars = self.get_value().chars().collect::<Vec<_>>();
let mut char_pairs: Vec<(usize, &[char])> = value_as_chars
.windows(2) // work in doubles
.enumerate() // idx of the first char
.collect();
if scan_backwards {
char_pairs = char_pairs
.into_iter()
.take(self.cursor_pos.saturating_sub(1))
.rev()
.collect();
} else {
char_pairs = char_pairs.into_iter().skip(self.cursor_pos).collect()
}
if let Some((idx, _chars)) = char_pairs.iter().find(|(_, chars)| {
// find a boundary where we go from non-whitespace to whitespace
match (chars[0].is_whitespace(), chars[1].is_whitespace()) {
(true, true) => false,
(true, false) => scan_backwards,
(false, true) => !scan_backwards,
(false, false) => false,
}
}) {
// println!("bounry at {}: '{}{}'", idx, _chars[0], _chars[1]);
if scan_backwards {
idx + 1
} else {
idx + 2
}
} else {
// no whitespace boundary found, remove to start of string
if scan_backwards {
0
} else {
self.value.len()
}
}
}
fn handle_modifiers(&mut self, modifiers: KeyModifiers, code: KeyCode) -> InteractionOutcome {
match (modifiers, code) {
// delete to current word start
(KeyModifiers::CONTROL, KeyCode::Char('w')) => {
// find the first boundary going from non-whitespace to whitespace,
// going backwards from the cursor position
// println!("up to cursor ({}): '{}'", self.cursor_pos, self.up_to_cursor());
let remove_to = self.cursor_pos as usize;
let remove_from = self.word_boundary_idx_under_cursor(true);
// println!("removing span '{}'", &self.value.as_str()[remove_from..remove_to]);
// and collect everything that isn't between [remove_from..remove_to)
self.cursor_pos = remove_from;
self.value = self
.value
.chars()
.take(remove_from)
.chain(self.value.chars().skip(remove_to))
.collect();
}
// jump to end of line
(KeyModifiers::CONTROL, KeyCode::Char('e')) => {
self.cursor_pos = self.value.len();
}
// jump to start of line
(KeyModifiers::CONTROL, KeyCode::Char('a')) => {
self.cursor_pos = 0;
}
// jump back a word
(KeyModifiers::ALT, KeyCode::Char('b')) => {
self.cursor_pos = self.word_boundary_idx_under_cursor(true);
}
// jump forward a word
(KeyModifiers::ALT, KeyCode::Char('f')) => {
self.cursor_pos = self.word_boundary_idx_under_cursor(false);
}
_ => return InteractionOutcome::Bubble,
}
InteractionOutcome::Consumed
}
fn handle_plain(&mut self, code: KeyCode) -> InteractionOutcome {
match code {
KeyCode::Backspace => {
if self.cursor_pos > 0 {
self.cursor_pos -= 1;
self.value.remove(self.cursor_pos as usize);
}
}
KeyCode::Char(c) => {
self.value.insert(self.cursor_pos as usize, c);
self.cursor_pos += 1;
}
KeyCode::Left => {
if self.cursor_pos > 0 {
self.cursor_pos -= 1;
}
}
KeyCode::Right => {
if self.cursor_pos < self.value.len() {
self.cursor_pos += 1;
}
}
_ => return InteractionOutcome::Bubble,
};
InteractionOutcome::Consumed
}
}
#[cfg(test)]
mod test {
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
use crate::widgets::{InteractiveWidgetState, InteractionOutcome, TextInputState};
macro_rules! assert_consumed {
($expr:expr) => {
assert_eq!(InteractionOutcome::Consumed, $expr)
};
}
#[test]
fn test_basics() {
let mut state = TextInputState::default();
// don't change when not focused
assert_eq!(InteractionOutcome::Bubble, state.handle_event(plain('a')));
assert_eq!("", state.get_value());
assert_eq!(0, state.cursor_pos);
state.focus();
assert_consumed!(state.handle_event(code(KeyCode::Left)));
assert_eq!(0, state.cursor_pos);
assert_consumed!(state.handle_event(code(KeyCode::Right)));
assert_eq!(0, state.cursor_pos);
assert_consumed!(state.handle_event(plain('a')));
assert_eq!("a", state.get_value());
assert_eq!(1, state.cursor_pos);
// build up a multi-char value
state.handle_event(plain('s'));
state.handle_event(plain('d'));
state.handle_event(plain('f'));
assert_eq!("asdf", state.get_value());
assert_eq!(4, state.cursor_pos);
// remove from end
state.handle_event(bksp());
assert_eq!("asd", state.get_value());
assert_eq!(3, state.cursor_pos);
// move cursor to middle
assert_eq!("asd", state.up_to_cursor());
state.handle_event(code(KeyCode::Left));
assert_eq!("as", state.up_to_cursor());
assert_eq!(2, state.cursor_pos);
assert_eq!("asd", state.get_value());
// remove from middle
state.handle_event(bksp());
assert_eq!(1, state.cursor_pos);
assert_eq!("ad", state.get_value());
}
#[test]
fn test_ctrl_w_works() {
let mut state = TextInputState::default();
state.focus();
// ctrl+w word removal, from the end of a word
state.set_value("foo bar baz smaz");
state.set_cursor(18);
assert_consumed!(state.handle_event(ctrl('w')));
assert_eq!("foo bar baz ", state.get_value());
assert_eq!(14, state.cursor_pos);
// remove runs of trailing whitespace + word
state.handle_event(ctrl('w'));
assert_eq!("foo bar ", state.get_value());
assert_eq!(8, state.cursor_pos);
// remove from middle of word
state.handle_event(code(KeyCode::Left));
state.handle_event(code(KeyCode::Left));
assert_eq!("foo ba", state.up_to_cursor());
state.handle_event(ctrl('w'));
assert_eq!("foo r ", state.get_value());
assert_eq!(4, state.cursor_pos);
// remove at start of word
state.handle_event(ctrl('w'));
assert_eq!("r ", state.get_value());
assert_eq!(0, state.cursor_pos);
// remove when buffer is empty
state.set_value("");
assert_eq!(0, state.cursor_pos);
assert_consumed!(state.handle_event(ctrl('w')));
}
#[test]
fn test_cursor_movement() {
let mut state = TextInputState::default();
state.focus();
state.set_value("foo bar baz");
state.set_cursor(0);
assert_consumed!(state.handle_event(ctrl('e')));
assert_eq!("foo bar baz", state.get_value());
assert_eq!(11, state.cursor_pos);
assert_consumed!(state.handle_event(ctrl('a')));
assert_eq!("foo bar baz", state.get_value());
assert_eq!(0, state.cursor_pos);
assert_consumed!(state.handle_event(alt('f')));
assert_eq!("foo bar baz", state.get_value());
assert_eq!(4, state.cursor_pos);
state.handle_event(alt('f'));
assert_eq!("foo bar baz", state.get_value());
assert_eq!(8, state.cursor_pos);
state.handle_event(alt('f'));
assert_eq!("foo bar baz", state.get_value());
assert_eq!(11, state.cursor_pos);
assert_consumed!(state.handle_event(alt('b')));
assert_eq!("foo bar baz", state.get_value());
assert_eq!(8, state.cursor_pos);
state.handle_event(alt('b'));
assert_eq!("foo bar baz", state.get_value());
assert_eq!(4, state.cursor_pos);
}
// helper macros + functions
fn ctrl(c: char) -> Event {
Event::Key(KeyEvent {
code: KeyCode::Char(c),
modifiers: KeyModifiers::CONTROL,
})
}
fn alt(c: char) -> Event {
Event::Key(KeyEvent {
code: KeyCode::Char(c),
modifiers: KeyModifiers::ALT,
})
}
fn plain(c: char) -> Event {
Event::Key(KeyEvent {
code: KeyCode::Char(c),
modifiers: KeyModifiers::NONE,
})
}
fn code(code: KeyCode) -> Event {
Event::Key(KeyEvent {
code,
modifiers: KeyModifiers::NONE,
})
}
fn bksp() -> Event {
code(KeyCode::Backspace)
}
}

@ -0,0 +1,204 @@
use std::borrow::Cow;
use crate::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Span, Text},
widgets::Block,
};
use super::{InteractiveWidget, Paragraph};
#[cfg(feature = "crossterm")]
mod crossterm_interactive;
#[derive(Debug, Clone)]
pub struct TextInput<'a> {
// Block to draw the text input inside (convenience function) - default: None
optional_block: Option<Block<'a>>,
// Placeholder text - what's shown if the state value is "" - default: None
placeholder: Option<Text<'a>>,
// Render as a read-only input - that is, it will not be focused - default: false
is_read_only: bool,
// Style to render the widget when focused - default: Bold style
focused_style: Style,
// Style to apply to displayed text - overriden by focused_style when focused
text_style: Style,
}
impl<'a> TextInput<'a> {
pub fn new() -> TextInput<'a> {
Default::default()
}
pub fn block(mut self, block: Block<'a>) -> TextInput<'a> {
self.optional_block = Some(block);
self
}
pub fn read_only(mut self, read_only: bool) -> TextInput<'a> {
self.is_read_only = read_only;
self
}
pub fn placeholder_text<T>(mut self, placeholder_text: T) -> TextInput<'a>
where
T: Into<Cow<'a, str>>,
{
self.placeholder = Some(
Span::styled(
placeholder_text,
Style::default()
.fg(Color::Black)
.add_modifier(Modifier::ITALIC),
)
.into(),
);
self
}
pub fn placeholder(mut self, placeholder: Text<'a>) -> TextInput<'a> {
self.placeholder = Some(placeholder);
self
}
pub fn focused_style(mut self, style: Style) -> TextInput<'a> {
self.focused_style = style;
self
}
pub fn text_style(mut self, style: Style) -> TextInput<'a> {
self.text_style = style;
self
}
}
impl<'a> Default for TextInput<'a> {
fn default() -> Self {
Self {
optional_block: Default::default(),
placeholder: Default::default(),
is_read_only: false,
focused_style: Style::default().add_modifier(Modifier::BOLD),
text_style: Default::default(),
}
}
}
#[derive(Debug, Clone)]
pub struct TextInputState {
// Underlying value of the text input field
value: String,
// Position in the text input to insert / remove text from
cursor_pos: usize,
// Is the input focused?
is_focused: bool,
// Can the input take focus?
can_take_focus: bool,
}
impl TextInputState {
pub fn with_value(value: &str) -> TextInputState {
TextInputState {
value: value.to_string(),
cursor_pos: value.len(),
..Default::default()
}
}
pub fn can_take_focus(&mut self, can_take_focus: bool) {
self.can_take_focus = can_take_focus;
if !can_take_focus {
self.unfocus();
}
}
pub fn is_focused(&self) -> bool {
self.can_take_focus && self.is_focused
}
pub fn focus(&mut self) {
if self.can_take_focus {
self.is_focused = true;
}
}
pub fn unfocus(&mut self) {
self.is_focused = false;
}
pub fn set_value(&mut self, val: &str) {
self.value = val.to_string();
self.cursor_pos = std::cmp::min(self.cursor_pos, self.value.len());
}
pub fn set_cursor(&mut self, pos: usize) {
self.cursor_pos = pos;
}
pub fn get_value(&self) -> &String {
&self.value
}
}
impl Default for TextInputState {
fn default() -> Self {
Self {
value: Default::default(),
is_focused: false,
cursor_pos: 0,
can_take_focus: true,
}
}
}
impl<'a> InteractiveWidget for TextInput<'a> {
type State = TextInputState;
fn render<'b, B: crate::backend::Backend + 'b>(
mut self,
area: Rect,
frame: &mut crate::Frame<'b, B>,
state: &Self::State,
) {
let is_focused = !self.is_read_only && state.is_focused;
let area = if let Some(block) = self.optional_block.take() {
let block = if is_focused {
block.style(self.focused_style)
} else {
block
};
let inner = block.inner(area);
frame.render_widget(block, area);
inner
} else {
area
};
let contents = if state.get_value().is_empty() {
match self.placeholder {
Some(placeholder) => placeholder,
None => "".into(),
}
} else {
let value = state.get_value();
if is_focused {
Span::styled(value, self.focused_style).into()
} else {
Span::styled(value, self.text_style).into()
}
};
let paragraph = Paragraph::new(contents);
frame.render_widget(paragraph, area);
if is_focused {
frame.set_cursor(area.x + (state.cursor_pos as u16), area.y);
}
}
fn render_mut<'b, B: crate::backend::Backend + 'b>(
self,
area: Rect,
frame: &mut crate::Frame<'b, B>,
state: &mut Self::State,
) {
self.render(area, frame, state);
}
}
Loading…
Cancel
Save