Compare commits
1 Commits
639/dymk--
...
640/allow-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb65100e2c |
@@ -1,245 +0,0 @@
|
||||
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]);
|
||||
}
|
||||
@@ -43,6 +43,20 @@ impl Constraint {
|
||||
Constraint::Min(m) => length.max(m),
|
||||
}
|
||||
}
|
||||
|
||||
/// returns the new target padding based on the constraint
|
||||
pub fn apply_for_padding(&self, total_length: u16, current_padding: u16) -> u16 {
|
||||
match *self {
|
||||
Constraint::Percentage(p) => total_length * p / 100,
|
||||
Constraint::Ratio(num, den) => {
|
||||
let r = num * u32::from(total_length) / den;
|
||||
r as u16
|
||||
}
|
||||
Constraint::Length(l) => total_length.min(l),
|
||||
Constraint::Max(m) => current_padding.min(m),
|
||||
Constraint::Min(m) => current_padding.max(m),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::{
|
||||
backend::Backend,
|
||||
buffer::Buffer,
|
||||
layout::Rect,
|
||||
widgets::{InteractiveWidget, StatefulWidget, Widget},
|
||||
widgets::{StatefulWidget, Widget},
|
||||
};
|
||||
use std::io;
|
||||
|
||||
@@ -133,20 +133,6 @@ 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.
|
||||
///
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use crate::{
|
||||
buffer::Buffer,
|
||||
layout::{Corner, Rect},
|
||||
layout::{Constraint, Corner, Rect},
|
||||
style::Style,
|
||||
text::Text,
|
||||
widgets::{Block, StatefulWidget, Widget},
|
||||
@@ -10,6 +12,7 @@ use unicode_width::UnicodeWidthStr;
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ListState {
|
||||
offset: usize,
|
||||
padding: (Option<Constraint>, Option<Constraint>),
|
||||
selected: Option<usize>,
|
||||
}
|
||||
|
||||
@@ -24,6 +27,33 @@ impl ListState {
|
||||
self.offset = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply padding when scrolling selected item into view.
|
||||
///
|
||||
/// The scrolling offset algorithm prioritizes `top_padding_constraint` over `bottom_padding_constraint`.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use tui::layout::Constraint;
|
||||
/// let mut state = tui::widgets::ListState::default();
|
||||
/// state.padding(
|
||||
/// Some(Constraint::Percentage(50)),
|
||||
/// Some(Constraint::Percentage(50)),
|
||||
/// );
|
||||
/// // or
|
||||
/// state.padding(None, Some(Constraint::Length(3)));
|
||||
/// // or
|
||||
/// state.padding(Some(Constraint::Max(6)), Some(Constraint::Min(3)));
|
||||
/// // etc.
|
||||
/// ```
|
||||
pub fn padding(
|
||||
&mut self,
|
||||
top_padding_constraint: Option<Constraint>,
|
||||
bottom_padding_constraint: Option<Constraint>,
|
||||
) {
|
||||
self.padding = (top_padding_constraint, bottom_padding_constraint);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -131,6 +161,7 @@ impl<'a> List<'a> {
|
||||
fn get_items_bounds(
|
||||
&self,
|
||||
selected: Option<usize>,
|
||||
padding: (Option<Constraint>, Option<Constraint>),
|
||||
offset: usize,
|
||||
max_height: usize,
|
||||
) -> (usize, usize) {
|
||||
@@ -147,7 +178,42 @@ impl<'a> List<'a> {
|
||||
}
|
||||
|
||||
let selected = selected.unwrap_or(0).min(self.items.len() - 1);
|
||||
while selected >= end {
|
||||
|
||||
// This function prioritizes the ideal start padding to the ideal end padding
|
||||
let padding_cmp_ideal = |start: usize, end: usize| {
|
||||
let end_cmp_ideal = padding
|
||||
.1
|
||||
.map(|c| {
|
||||
let current_padding = self
|
||||
.items
|
||||
.get((selected + 1)..end)
|
||||
.map(|ir| ir.iter().map(|i| i.height()).sum::<usize>() as u16)
|
||||
.unwrap_or(0);
|
||||
current_padding.cmp(&c.apply_for_padding(max_height as u16, current_padding))
|
||||
})
|
||||
.unwrap_or(Ordering::Equal);
|
||||
let start_cmp_ideal = padding
|
||||
.0
|
||||
.map(|c| {
|
||||
let current_padding = self
|
||||
.items
|
||||
.get(start..selected)
|
||||
.map(|ir| ir.iter().map(|i| i.height()).sum::<usize>() as u16)
|
||||
.unwrap_or(0);
|
||||
current_padding.cmp(&c.apply_for_padding(max_height as u16, current_padding))
|
||||
})
|
||||
.unwrap_or(Ordering::Equal);
|
||||
|
||||
if start_cmp_ideal == Ordering::Equal {
|
||||
end_cmp_ideal.reverse()
|
||||
} else {
|
||||
start_cmp_ideal
|
||||
}
|
||||
};
|
||||
|
||||
while selected >= end
|
||||
|| (padding_cmp_ideal(start, end) == Ordering::Greater && end < self.items.len())
|
||||
{
|
||||
height = height.saturating_add(self.items[end].height());
|
||||
end += 1;
|
||||
while height > max_height {
|
||||
@@ -155,7 +221,7 @@ impl<'a> List<'a> {
|
||||
start += 1;
|
||||
}
|
||||
}
|
||||
while selected < start {
|
||||
while selected < start || (padding_cmp_ideal(start, end) == Ordering::Less && start > 0) {
|
||||
start -= 1;
|
||||
height = height.saturating_add(self.items[start].height());
|
||||
while height > max_height {
|
||||
@@ -190,7 +256,8 @@ impl<'a> StatefulWidget for List<'a> {
|
||||
}
|
||||
let list_height = list_area.height as usize;
|
||||
|
||||
let (start, end) = self.get_items_bounds(state.selected, state.offset, list_height);
|
||||
let (start, end) =
|
||||
self.get_items_bounds(state.selected, state.padding, state.offset, list_height);
|
||||
state.offset = start;
|
||||
|
||||
let highlight_symbol = self.highlight_symbol.unwrap_or("");
|
||||
|
||||
@@ -27,10 +27,6 @@ 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};
|
||||
@@ -42,13 +38,7 @@ 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;
|
||||
|
||||
@@ -192,21 +182,3 @@ 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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use tui::{
|
||||
backend::TestBackend,
|
||||
buffer::Buffer,
|
||||
layout::Rect,
|
||||
layout::{Constraint, Rect},
|
||||
style::{Color, Style},
|
||||
symbols,
|
||||
text::Spans,
|
||||
@@ -198,3 +198,168 @@ fn widgets_list_should_repeat_highlight_symbol() {
|
||||
}
|
||||
terminal.backend().assert_buffer(&expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widgets_list_should_respect_padding() {
|
||||
let backend = TestBackend::new(10, 4);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
let mut state = ListState::default();
|
||||
state.select(Some(4));
|
||||
|
||||
state.padding(None, Some(Constraint::Percentage(50)));
|
||||
terminal
|
||||
.draw(|f| {
|
||||
let size = f.size();
|
||||
let items = vec![
|
||||
ListItem::new("Item 0"),
|
||||
ListItem::new("Item 1"),
|
||||
ListItem::new("Item 2"),
|
||||
ListItem::new("Item 3"),
|
||||
ListItem::new("Item 4"),
|
||||
ListItem::new("Item 5"),
|
||||
ListItem::new("Item 6"),
|
||||
ListItem::new("Item 7"),
|
||||
];
|
||||
let list = List::new(items)
|
||||
.highlight_symbol(">> ")
|
||||
.repeat_highlight_symbol(true);
|
||||
f.render_stateful_widget(list, size, &mut state);
|
||||
})
|
||||
.unwrap();
|
||||
let expected = Buffer::with_lines(vec![" Item 3 ", ">> Item 4 ", " Item 5 ", " Item 6 "]);
|
||||
terminal.backend().assert_buffer(&expected);
|
||||
|
||||
state.padding(
|
||||
Some(Constraint::Percentage(50)),
|
||||
Some(Constraint::Percentage(50)),
|
||||
);
|
||||
terminal
|
||||
.draw(|f| {
|
||||
let size = f.size();
|
||||
let items = vec![
|
||||
ListItem::new("Item 0"),
|
||||
ListItem::new("Item 1"),
|
||||
ListItem::new("Item 2"),
|
||||
ListItem::new("Item 3"),
|
||||
ListItem::new("Item 4"),
|
||||
ListItem::new("Item 5"),
|
||||
ListItem::new("Item 6"),
|
||||
ListItem::new("Item 7"),
|
||||
];
|
||||
let list = List::new(items)
|
||||
.highlight_symbol(">> ")
|
||||
.repeat_highlight_symbol(true);
|
||||
f.render_stateful_widget(list, size, &mut state);
|
||||
})
|
||||
.unwrap();
|
||||
let expected = Buffer::with_lines(vec![" Item 2 ", " Item 3 ", ">> Item 4 ", " Item 5 "]);
|
||||
terminal.backend().assert_buffer(&expected);
|
||||
|
||||
state.padding(Some(Constraint::Max(1)), None);
|
||||
terminal
|
||||
.draw(|f| {
|
||||
let size = f.size();
|
||||
let items = vec![
|
||||
ListItem::new("Item 0"),
|
||||
ListItem::new("Item 1"),
|
||||
ListItem::new("Item 2"),
|
||||
ListItem::new("Item 3"),
|
||||
ListItem::new("Item 4"),
|
||||
ListItem::new("Item 5"),
|
||||
ListItem::new("Item 6"),
|
||||
ListItem::new("Item 7"),
|
||||
];
|
||||
let list = List::new(items)
|
||||
.highlight_symbol(">> ")
|
||||
.repeat_highlight_symbol(true);
|
||||
f.render_stateful_widget(list, size, &mut state);
|
||||
})
|
||||
.unwrap();
|
||||
let expected = Buffer::with_lines(vec![" Item 3 ", ">> Item 4 ", " Item 5 ", " Item 6 "]);
|
||||
terminal.backend().assert_buffer(&expected);
|
||||
|
||||
//Prefers top padding to bottom padding
|
||||
state.padding(Some(Constraint::Length(3)), Some(Constraint::Length(3)));
|
||||
terminal
|
||||
.draw(|f| {
|
||||
let size = f.size();
|
||||
let items = vec![
|
||||
ListItem::new("Item 0"),
|
||||
ListItem::new("Item 1"),
|
||||
ListItem::new("Item 2"),
|
||||
ListItem::new("Item 3"),
|
||||
ListItem::new("Item 4"),
|
||||
ListItem::new("Item 5"),
|
||||
ListItem::new("Item 6"),
|
||||
ListItem::new("Item 7"),
|
||||
];
|
||||
let list = List::new(items)
|
||||
.highlight_symbol(">> ")
|
||||
.repeat_highlight_symbol(true);
|
||||
f.render_stateful_widget(list, size, &mut state);
|
||||
})
|
||||
.unwrap();
|
||||
let expected = Buffer::with_lines(vec![" Item 1 ", " Item 2 ", " Item 3 ", ">> Item 4 "]);
|
||||
terminal.backend().assert_buffer(&expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widgets_list_padding_doesnt_panic_for_offscreen_offset() {
|
||||
let backend = TestBackend::new(10, 1);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
let mut state = ListState::default();
|
||||
state.select(Some(7));
|
||||
|
||||
state.padding(
|
||||
Some(Constraint::Length(0)),
|
||||
Some(Constraint::Percentage(100)),
|
||||
);
|
||||
terminal
|
||||
.draw(|f| {
|
||||
let size = f.size();
|
||||
let items = vec![
|
||||
ListItem::new("Item 0"),
|
||||
ListItem::new("Item 1"),
|
||||
ListItem::new("Item 2"),
|
||||
ListItem::new("Item 3"),
|
||||
ListItem::new("Item 4"),
|
||||
ListItem::new("Item 5"),
|
||||
ListItem::new("Item 6"),
|
||||
ListItem::new("Item 7"),
|
||||
];
|
||||
let list = List::new(items)
|
||||
.highlight_symbol(">> ")
|
||||
.repeat_highlight_symbol(true);
|
||||
f.render_stateful_widget(list, size, &mut state);
|
||||
})
|
||||
.unwrap();
|
||||
let expected = Buffer::with_lines(vec![">> Item 7 "]);
|
||||
terminal.backend().assert_buffer(&expected);
|
||||
|
||||
// now the state.offset is set to 7
|
||||
// we set state.selected to 1
|
||||
// and we just check that it doesnt cause a panic in the padding code
|
||||
|
||||
state.select(Some(1));
|
||||
terminal
|
||||
.draw(|f| {
|
||||
let size = f.size();
|
||||
let items = vec![
|
||||
ListItem::new("Item 0"),
|
||||
ListItem::new("Item 1"),
|
||||
ListItem::new("Item 2"),
|
||||
ListItem::new("Item 3"),
|
||||
ListItem::new("Item 4"),
|
||||
ListItem::new("Item 5"),
|
||||
ListItem::new("Item 6"),
|
||||
ListItem::new("Item 7"),
|
||||
];
|
||||
let list = List::new(items)
|
||||
.highlight_symbol(">> ")
|
||||
.repeat_highlight_symbol(true);
|
||||
f.render_stateful_widget(list, size, &mut state);
|
||||
})
|
||||
.unwrap();
|
||||
let expected = Buffer::with_lines(vec![">> Item 1 "]);
|
||||
terminal.backend().assert_buffer(&expected);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user