From 7f689274677dff2fd5769e04b9c2032c2ee10054 Mon Sep 17 00:00:00 2001 From: Dylan Knutson Date: Wed, 6 Jul 2022 10:24:04 -0700 Subject: [PATCH] Add TextInput widget, Interaction* types for interactive widgets --- examples/text_input.rs | 245 ++++++++++++++ src/terminal.rs | 16 +- src/widgets/crossterm_interactive_widget.rs | 25 ++ src/widgets/mod.rs | 28 ++ .../text_input/crossterm_interactive.rs | 315 ++++++++++++++++++ src/widgets/text_input/mod.rs | 204 ++++++++++++ 6 files changed, 832 insertions(+), 1 deletion(-) create mode 100644 examples/text_input.rs create mode 100644 src/widgets/crossterm_interactive_widget.rs create mode 100644 src/widgets/text_input/crossterm_interactive.rs create mode 100644 src/widgets/text_input/mod.rs diff --git a/examples/text_input.rs b/examples/text_input.rs new file mode 100644 index 0000000..c4ca3b0 --- /dev/null +++ b/examples/text_input.rs @@ -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> { + // 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, + events: Vec, +} + +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(terminal: &mut Terminal) -> 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(f: &mut Frame, 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::>(), + ) + .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::>(), + ) + .block(Block::default().title("Events").borders(Borders::ALL)); + f.render_widget(events, layout[3]); +} diff --git a/src/terminal.rs b/src/terminal.rs index 3a1d37f..de65b42 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -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(&mut self, widget: W, area: Rect, state: &W::State) + where + W: InteractiveWidget, + { + widget.render(area, self, state); + } + + pub fn render_interactive_mut(&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. /// diff --git a/src/widgets/crossterm_interactive_widget.rs b/src/widgets/crossterm_interactive_widget.rs new file mode 100644 index 0000000..a91d42d --- /dev/null +++ b/src/widgets/crossterm_interactive_widget.rs @@ -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); +} diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs index 8b005ec..4b017d9 100644 --- a/src/widgets/mod.rs +++ b/src/widgets/mod.rs @@ -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, + ); +} diff --git a/src/widgets/text_input/crossterm_interactive.rs b/src/widgets/text_input/crossterm_interactive.rs new file mode 100644 index 0000000..2332f1c --- /dev/null +++ b/src/widgets/text_input/crossterm_interactive.rs @@ -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::>(); + 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) + } +} diff --git a/src/widgets/text_input/mod.rs b/src/widgets/text_input/mod.rs new file mode 100644 index 0000000..28da950 --- /dev/null +++ b/src/widgets/text_input/mod.rs @@ -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>, + // Placeholder text - what's shown if the state value is "" - default: None + placeholder: Option>, + // 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(mut self, placeholder_text: T) -> TextInput<'a> + where + T: Into>, + { + 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); + } +}