feat(cli): add textarea widget

This commit is contained in:
Lucas Colombo 2024-09-19 18:36:10 -03:00
parent 61f7096dfa
commit cc7bb4a15f
Signed by: lucas
GPG Key ID: EF34786CFEFFAE35
16 changed files with 1973 additions and 4 deletions

11
Cargo.lock generated
View File

@ -471,6 +471,7 @@ dependencies = [
"strum",
"tokio",
"tokio-util",
"unicode-width 0.2.0",
]
[[package]]
@ -692,7 +693,7 @@ dependencies = [
"strum_macros",
"unicode-segmentation",
"unicode-truncate",
"unicode-width",
"unicode-width 0.1.13",
]
[[package]]
@ -886,7 +887,7 @@ checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
dependencies = [
"itertools",
"unicode-segmentation",
"unicode-width",
"unicode-width 0.1.13",
]
[[package]]
@ -895,6 +896,12 @@ version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d"
[[package]]
name = "unicode-width"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
[[package]]
name = "version_check"
version = "0.9.5"

View File

@ -42,6 +42,7 @@ path = "lib/lib.rs"
"tokio?/sync",
"tokio?/time",
]
"cli.tui.widgets" = ["cli.tui", "dep:unicode-width"]
# logging
"logger" = ["dep:log", "dep:glob-match"]
# macros
@ -83,7 +84,7 @@ crossterm = { version="0.28.1", optional = true}
strum = { version="0.26.1", optional = true }
downcast-rs = { version="1.2.1", optional = true}
futures = { version = "0.3.30", optional = true }
unicode-width = { version = "0.2.0", optional = true }
[[example]]
name = "sched"

View File

@ -30,7 +30,7 @@ This module defines two primary elements:
Together, these elements facilitate the creation of modular and interactive terminal applications.
## Overview
## Framework
### `App` Struct
@ -167,3 +167,45 @@ fn receive_message(&mut self, message: String) {
}
}
```
## Widgets
Apart from the tui framework, this module also provides a set of reusable "ratatui-native" widgets
that can be used. Theese are not components, but Widgets, just like native `Paragraph`, `Block`,
etc.
Right now, the following widgets are available:
### `TextArea`
This is a rip-off of the `TextArea` widget from the
[`tui-rs`](https://github.com/rhysd/tui-textarea) crate, but with less capabilities. In summary,
search, mouse support, copy, paste, and undo/redo functionalities were stripped off.
This implementation also changes the default key bindings to be more similar to the ones used in
the `coco` package (conventional commit cli utility). That is:
- <kbd>Enter</kbd> won't add a new line. **why?** Because that way, we can use the <kbd>Enter</kbd>
key to submit the "form" or cofirm the input.
- Removes all key bindings of stripped functionalities.
#### Vakudation
IN this implementation, the `TextArea` widget also supports validation. The validation is done by
accepting any number of validation functions that will be called every time the validity of the
text-area is checked.
One can add as many validation functions as needed:
```rust
textarea..with_validations(vec![
|input: &str| {
if input.len() > 10 {
Err(format!("Input must be less than 10 characters"))
} else {
Ok(())
}
},
required_validator,
]);
```

View File

@ -29,6 +29,11 @@ pub mod utils {
}
}
#[cfg(feature = "cli.tui.widgets")]
pub mod widgets {
pub mod textarea;
}
// ratatui prelude
pub mod ratatui {
pub use ratatui::{prelude::*, *};

View File

@ -0,0 +1,102 @@
use {
super::util::{find_word_start_backward, find_word_start_forward},
crate::tui::widgets::textarea::textarea::widget::Viewport,
std::cmp,
};
/// Specify how to move the cursor.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CursorMove {
/// Move cursor forward by one character. When the cursor is at the end of line, it moves to the
/// head of next line.
Forward,
/// Move cursor backward by one character. When the cursor is at the head of line, it moves to
/// the end of previous line.
Back,
/// Move cursor up by one line.
Up,
/// Move cursor down by one line.
Down,
/// Move cursor to the head of line. When the cursor is at the head of line, it moves to the end of previous line.
Head,
/// Move cursor to the end of line. When the cursor is at the end of line, it moves to the head of next line.
End,
/// Move cursor forward by one word. Word boundary appears at spaces, punctuations, and others. For example
/// `fn foo(a)` consists of words `fn`, `foo`, `(`, `a`, `)`. When the cursor is at the end of line, it moves to the
/// head of next line.
WordForward,
/// Move cursor backward by one word. Word boundary appears at spaces, punctuations, and others. For example
/// `fn foo(a)` consists of words `fn`, `foo`, `(`, `a`, `)`.When the cursor is at the head of line, it moves to
/// the end of previous line.
WordBack,
/// Move cursor to keep it within the viewport. For example, when a viewport displays line 8 to line 16:
///
/// - cursor at line 4 is moved to line 8
/// - cursor at line 20 is moved to line 16
/// - cursor at line 12 is not moved
///
/// This is useful when you moved a cursor but you don't want to move the viewport.
InViewport,
}
impl CursorMove {
pub(crate) fn next_cursor(
&self,
(row, col): (usize, usize),
lines: &[String],
viewport: &Viewport,
) -> Option<(usize, usize)> {
use CursorMove::*;
fn fit_col(col: usize, line: &str) -> usize {
cmp::min(col, line.chars().count())
}
match self {
Forward if col >= lines[row].chars().count() => {
(row + 1 < lines.len()).then(|| (row + 1, 0))
}
Forward => Some((row, col + 1)),
Back if col == 0 => {
let row = row.checked_sub(1)?;
Some((row, lines[row].chars().count()))
}
Back => Some((row, col - 1)),
Up => {
let row = row.checked_sub(1)?;
Some((row, fit_col(col, &lines[row])))
}
Down => Some((row + 1, fit_col(col, lines.get(row + 1)?))),
Head => Some((row, 0)),
End => Some((row, lines[row].chars().count())),
WordForward => {
if let Some(col) = find_word_start_forward(&lines[row], col) {
Some((row, col))
} else if row + 1 < lines.len() {
Some((row + 1, 0))
} else {
Some((row, lines[row].chars().count()))
}
}
WordBack => {
if let Some(col) = find_word_start_backward(&lines[row], col) {
Some((row, col))
} else if row > 0 {
Some((row - 1, lines[row - 1].chars().count()))
} else {
Some((row, 0))
}
}
InViewport => {
let (row_top, col_top, row_bottom, col_bottom) = viewport.position();
let row = row.clamp(row_top as usize, row_bottom as usize);
let row = cmp::min(row, lines.len() - 1);
let col = col.clamp(col_top as usize, col_bottom as usize);
let col = fit_col(col, &lines[row]);
Some((row, col))
}
}
}
}

View File

@ -0,0 +1,236 @@
use {
super::util::spaces,
ratatui::{
style::Style,
text::{Line, Span},
},
std::{borrow::Cow, cmp::Ordering, iter},
unicode_width::UnicodeWidthChar as _,
};
enum Boundary {
Cursor(Style),
Select(Style),
End,
}
impl Boundary {
fn cmp(&self, other: &Boundary) -> Ordering {
fn rank(b: &Boundary) -> u8 {
match b {
Boundary::Cursor(_) => 3,
Boundary::Select(_) => 1,
Boundary::End => 0,
}
}
rank(self).cmp(&rank(other))
}
fn style(&self) -> Option<Style> {
match self {
Boundary::Cursor(s) => Some(*s),
Boundary::Select(s) => Some(*s),
Boundary::End => None,
}
}
}
struct DisplayTextBuilder {
tab_len: u8,
width: usize,
mask: Option<char>,
}
impl DisplayTextBuilder {
fn new(tab_len: u8, mask: Option<char>) -> Self {
Self {
tab_len,
width: 0,
mask,
}
}
fn build<'s>(&mut self, s: &'s str) -> Cow<'s, str> {
if let Some(ch) = self.mask {
// Note: We don't need to track width on masking text since width of tab character is fixed
let masked = iter::repeat(ch).take(s.chars().count()).collect();
return Cow::Owned(masked);
}
let tab = spaces(self.tab_len);
let mut buf = String::new();
for (i, c) in s.char_indices() {
if c == '\t' {
if buf.is_empty() {
buf.reserve(s.len());
buf.push_str(&s[..i]);
}
if self.tab_len > 0 {
let len = self.tab_len as usize - (self.width % self.tab_len as usize);
buf.push_str(&tab[..len]);
self.width += len;
}
} else {
if !buf.is_empty() {
buf.push(c);
}
self.width += c.width().unwrap_or(0);
}
}
if !buf.is_empty() {
Cow::Owned(buf)
} else {
Cow::Borrowed(s)
}
}
}
pub struct LineHighlighter<'a> {
line: &'a str,
spans: Vec<Span<'a>>,
boundaries: Vec<(Boundary, usize)>, // TODO: Consider smallvec
style_begin: Style,
cursor_at_end: bool,
cursor_style: Style,
tab_len: u8,
mask: Option<char>,
select_at_end: bool,
select_style: Style,
}
impl<'a> LineHighlighter<'a> {
pub fn new(
line: &'a str,
cursor_style: Style,
tab_len: u8,
mask: Option<char>,
select_style: Style,
) -> Self {
Self {
line,
spans: vec![],
boundaries: vec![],
style_begin: Style::default(),
cursor_at_end: false,
cursor_style,
tab_len,
mask,
select_at_end: false,
select_style,
}
}
pub fn cursor_line(&mut self, cursor_col: usize, style: Style) {
if let Some((start, c)) = self.line.char_indices().nth(cursor_col) {
self.boundaries.push((Boundary::Cursor(self.cursor_style), start));
self.boundaries.push((Boundary::End, start + c.len_utf8()));
} else {
self.cursor_at_end = true;
}
self.style_begin = style;
}
#[cfg(feature = "search")]
pub fn search(&mut self, matches: impl Iterator<Item = (usize, usize)>, style: Style) {
for (start, end) in matches {
if start != end {
self.boundaries.push((Boundary::Search(style), start));
self.boundaries.push((Boundary::End, end));
}
}
}
pub fn selection(
&mut self,
current_row: usize,
start_row: usize,
start_off: usize,
end_row: usize,
end_off: usize,
) {
let (start, end) = if current_row == start_row {
if start_row == end_row {
(start_off, end_off)
} else {
self.select_at_end = true;
(start_off, self.line.len())
}
} else if current_row == end_row {
(0, end_off)
} else if start_row < current_row && current_row < end_row {
self.select_at_end = true;
(0, self.line.len())
} else {
return;
};
if start != end {
self.boundaries.push((Boundary::Select(self.select_style), start));
self.boundaries.push((Boundary::End, end));
}
}
pub fn into_spans(self) -> Line<'a> {
let Self {
line,
mut spans,
mut boundaries,
tab_len,
style_begin,
cursor_style,
cursor_at_end,
mask,
select_at_end,
select_style,
} = self;
let mut builder = DisplayTextBuilder::new(tab_len, mask);
if boundaries.is_empty() {
let built = builder.build(line);
if !built.is_empty() {
spans.push(Span::styled(built, style_begin));
}
if cursor_at_end {
spans.push(Span::styled(" ", cursor_style));
} else if select_at_end {
spans.push(Span::styled(" ", select_style));
}
return Line::from(spans);
}
boundaries.sort_unstable_by(|(l, i), (r, j)| match i.cmp(j) {
Ordering::Equal => l.cmp(r),
o => o,
});
let mut style = style_begin;
let mut start = 0;
let mut stack = vec![];
for (next_boundary, end) in boundaries {
if start < end {
spans.push(Span::styled(builder.build(&line[start..end]), style));
}
style = if let Some(s) = next_boundary.style() {
stack.push(style);
s
} else {
stack.pop().unwrap_or(style_begin)
};
start = end;
}
if start != line.len() {
spans.push(Span::styled(builder.build(&line[start..]), style));
}
if cursor_at_end {
spans.push(Span::styled(" ", cursor_style));
} else if select_at_end {
spans.push(Span::styled(" ", select_style));
}
Line::from(spans)
}
}

View File

@ -0,0 +1,391 @@
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
/// Backend-agnostic key input kind.
///
/// This type is marked as `#[non_exhaustive]` since more keys may be supported in the future.
#[derive(Clone, Copy, Debug, PartialEq, Hash, Eq)]
pub enum Key {
/// Normal letter key input
Char(char),
/// F1, F2, F3, ... keys
// F(u8),
/// Backspace key
Backspace,
/// Enter or return key
Enter,
/// Left arrow key
Left,
/// Right arrow key
Right,
/// Up arrow key
Up,
/// Down arrow key
Down,
/// Tab key
Tab,
/// Delete key
Delete,
/// Home key
Home,
/// End key
End,
/// Escape key
Esc,
/// Copy key. This key is supported by termwiz only
Copy,
/// Cut key. This key is supported by termwiz only
Cut,
/// Paste key. This key is supported by termwiz only
Paste,
/// An invalid key input (this key is always ignored by [`TextArea`](crate::TextArea))
Null,
}
impl Default for Key {
fn default() -> Self {
Key::Null
}
}
/// Backend-agnostic key input type.
///
/// When `crossterm`, `termion`, `termwiz` features are enabled, converting respective key input types into this
/// `Input` type is defined.
/// ```no_run
/// use tui_textarea::{TextArea, Input, Key};
/// use crossterm::event::{Event, read};
///
/// let event = read().unwrap();
///
/// // `Input::from` can convert backend-native event into `Input`
/// let input = Input::from(event.clone());
/// // or `Into::into`
/// let input: Input = event.clone().into();
/// // Conversion from `KeyEvent` value is also available
/// if let Event::Key(key) = event {
/// let input = Input::from(key);
/// }
/// ```
///
/// Creating `Input` instance directly can cause backend-agnostic input as follows.
///
/// ```
/// use tui_textarea::{TextArea, Input, Key};
///
/// let mut textarea = TextArea::default();
///
/// // Input Ctrl+A
/// textarea.input(Input {
/// key: Key::Char('a'),
/// ctrl: true,
/// alt: false,
/// shift: false,
/// });
/// ```
#[derive(Debug, Clone, Default, PartialEq, Hash, Eq)]
pub struct Input {
/// Typed key.
pub key: Key,
/// Ctrl modifier key. `true` means Ctrl key was pressed.
pub ctrl: bool,
/// Alt modifier key. `true` means Alt key was pressed.
pub alt: bool,
/// Shift modifier key. `true` means Shift key was pressed.
pub shift: bool,
}
impl Input {
/// Returns Some(char) if the Input is a single char without any modifiers (except Shift for
/// case) and None otherwise.
pub fn maybe_char(&self) -> Option<char> {
match self {
Input {
key: Key::Char(c),
ctrl: false,
alt: false,
..
} => Some(c.clone()),
_ => None,
}
}
/// Returns `true` if the Input represents a new line, except is caused by Enter key without
/// any modifiers. Useful for using Enter as a confirm key instead of a new line key.
/// - Ctrl+Enter key
/// - Alt+Enter key
/// - Shift+Enter key
/// - Char is \n or \r
#[inline]
pub fn is_newline_except_enter(&self) -> bool {
match self {
Input {
key: Key::Char('\n' | '\r'),
ctrl: false,
alt: false,
..
}
| Input {
key: Key::Enter,
ctrl: true,
..
}
| Input {
key: Key::Enter,
alt: true,
..
}
| Input {
key: Key::Enter,
shift: true,
..
} => true,
_ => false,
}
}
/// Returns `true` if the Input represents a new line (including Enter key).
/// - Enter key
///
/// + all conditions of `is_newline_except_enter`
#[inline]
pub fn is_newline(&self) -> bool {
self.is_newline_except_enter() || self.key == Key::Enter
}
/// Returns `true` if the Input is a single char without any modifiers (except Shift for upper
/// case).
#[inline]
pub fn is_char(&self) -> bool {
matches!(
self,
Input {
key: Key::Char(_),
ctrl: false,
alt: false,
..
}
)
}
/// Returns `true` if the Input is a Tab
#[inline]
pub fn is_tab(&self) -> bool {
return self.key == Key::Tab && !self.ctrl && !self.alt;
}
/// Returns `true` if the Input is Backspace
#[inline]
pub fn is_backspace(&self) -> bool {
return self.key == Key::Backspace && !self.ctrl && !self.alt;
}
/// Returns `true` if the Input is Delete
#[inline]
pub fn is_delete(&self) -> bool {
return self.key == Key::Delete && !self.ctrl && !self.alt;
}
/// Returns `true` if the Input is key down arrow
#[inline]
pub fn is_down(&self) -> bool {
return self.key == Key::Down && !self.ctrl && !self.alt;
}
/// Returns `true` if the Input is key up arrow
#[inline]
pub fn is_up(&self) -> bool {
return self.key == Key::Up && !self.ctrl && !self.alt;
}
/// Returns `true` if the Input is key left arrow
#[inline]
pub fn is_left(&self) -> bool {
return self.key == Key::Left && !self.ctrl && !self.alt;
}
/// Returns `true` if the Input is key right arrow
#[inline]
pub fn is_right(&self) -> bool {
return self.key == Key::Right && !self.ctrl && !self.alt;
}
/// Returns `true` if the Input is key Home
#[inline]
pub fn is_home(&self) -> bool {
return self.key == Key::Home;
}
/// Returns `true` if the Input is key End
#[inline]
pub fn is_end(&self) -> bool {
return self.key == Key::End;
}
/// Returns `true` if the Input is ctrl+left
#[inline]
pub fn is_ctrl_left(&self) -> bool {
return self.key == Key::Left && self.ctrl && !self.alt;
}
/// Returns `true` if the Input is ctrl+right
#[inline]
pub fn is_ctrl_right(&self) -> bool {
return self.key == Key::Right && self.ctrl && !self.alt;
}
/// Returns a string representing the kind of key input.
/// e.g ":delete", ":backspace", ":tab", ":enter", "char"
/// or empty string if the key is null.
/// uses the is_* methods to determine the kind of key input.
pub fn kind(&self) -> &str {
match self {
i if i.is_delete() => ":delete",
i if i.is_backspace() => ":backspace",
i if i.is_tab() => ":tab",
i if i.is_newline_except_enter() => ":non-enter-newline",
i if i.is_newline() => ":newline",
i if i.is_char() => ":char",
i if i.is_down() => ":down",
i if i.is_up() => ":up",
i if i.is_left() => ":left",
i if i.is_right() => ":right",
i if i.is_home() => ":home",
i if i.is_end() => ":end",
i if i.is_ctrl_left() => ":word-left",
i if i.is_ctrl_right() => ":word-right",
_ => "",
}
}
}
impl From<Event> for Input {
/// Convert [`crossterm::event::Event`] into [`Input`].
fn from(event: Event) -> Self {
match event {
Event::Key(key) => Self::from(key),
_ => Self::default(),
}
}
}
impl From<KeyCode> for Key {
/// Convert [`crossterm::event::KeyCode`] into [`Key`].
fn from(code: KeyCode) -> Self {
match code {
KeyCode::Char(c) => Key::Char(c),
KeyCode::Backspace => Key::Backspace,
KeyCode::Enter => Key::Enter,
KeyCode::Left => Key::Left,
KeyCode::Right => Key::Right,
KeyCode::Up => Key::Up,
KeyCode::Down => Key::Down,
KeyCode::Tab => Key::Tab,
KeyCode::Delete => Key::Delete,
KeyCode::Home => Key::Home,
KeyCode::End => Key::End,
KeyCode::Esc => Key::Esc,
_ => Key::Null,
}
}
}
impl From<KeyEvent> for Input {
/// Convert [`crossterm::event::KeyEvent`] into [`Input`].
fn from(key: KeyEvent) -> Self {
if key.kind == KeyEventKind::Release {
// On Windows or when `crossterm::event::PushKeyboardEnhancementFlags` is set,
// key release event can be reported. Ignore it. (#14)
return Self::default();
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let alt = key.modifiers.contains(KeyModifiers::ALT);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
let key = Key::from(key.code);
Self {
key,
ctrl,
alt,
shift,
}
}
}
#[cfg(test)]
mod tests {
use {super::*, crossterm::event::KeyEventState};
fn key_event(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
KeyEvent {
code,
modifiers,
kind: KeyEventKind::Press,
state: KeyEventState::empty(),
}
}
#[test]
fn key_to_input() {
for (from, to) in [
(
key_event(KeyCode::Char('a'), KeyModifiers::empty()),
input(Key::Char('a'), false, false, false),
),
(
key_event(KeyCode::Enter, KeyModifiers::empty()),
input(Key::Enter, false, false, false),
),
(
key_event(KeyCode::Left, KeyModifiers::CONTROL),
input(Key::Left, true, false, false),
),
(
key_event(KeyCode::Right, KeyModifiers::SHIFT),
input(Key::Right, false, false, true),
),
(
key_event(KeyCode::Home, KeyModifiers::ALT),
input(Key::Home, false, true, false),
),
(
key_event(KeyCode::NumLock, KeyModifiers::CONTROL),
input(Key::Null, true, false, false),
),
] {
assert_eq!(Input::from(from), to, "{:?} -> {:?}", from, to);
}
}
#[test]
fn event_to_input() {
for (from, to) in [
(
Event::Key(key_event(KeyCode::Char('a'), KeyModifiers::empty())),
input(Key::Char('a'), false, false, false),
),
(Event::FocusGained, input(Key::Null, false, false, false)),
] {
assert_eq!(Input::from(from.clone()), to, "{:?} -> {:?}", from, to);
}
}
// Regression for https://github.com/rhysd/tui-textarea/issues/14
#[test]
fn ignore_key_release_event() {
let mut from = key_event(KeyCode::Char('a'), KeyModifiers::empty());
from.kind = KeyEventKind::Release;
let to = input(Key::Null, false, false, false);
assert_eq!(Input::from(from), to, "{:?} -> {:?}", from, to);
}
#[allow(dead_code)]
pub(crate) fn input(key: Key, ctrl: bool, alt: bool, shift: bool) -> Input {
Input {
key,
ctrl,
alt,
shift,
}
}
}

View File

@ -0,0 +1,182 @@
use crate::tui::widgets::textarea::textarea::widget::Viewport;
/// Specify how to scroll the textarea.
///
/// This type is marked as `#[non_exhaustive]` since more variations may be supported in the future. Note that the cursor will
/// not move until it goes out the viewport. See also: [`TextArea::scroll`]
///
/// [`TextArea::scroll`]: https://docs.rs/tui-textarea/latest/tui_textarea/struct.TextArea.html#method.scroll
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Scrolling {
/// Scroll the textarea by rows (vertically) and columns (horizontally). Passing positive scroll amounts to `rows` and `cols`
/// scolls it to down and right. Negative integers means the opposite directions. `(i16, i16)` pair can be converted into
/// `Scrolling::Delta` where 1st element means rows and 2nd means columns.
///
/// ```
/// # use ratatui::buffer::Buffer;
/// # use ratatui::layout::Rect;
/// # use ratatui::widgets::Widget as _;
/// use tui_textarea::{TextArea, Scrolling};
///
/// // Let's say terminal height is 8.
///
/// // Create textarea with 20 lines "0", "1", "2", "3", ...
/// let mut textarea: TextArea = (0..20).into_iter().map(|i| i.to_string()).collect();
/// # // Call `render` at least once to populate terminal size
/// # let r = Rect { x: 0, y: 0, width: 24, height: 8 };
/// # let mut b = Buffer::empty(r.clone());
/// # textarea.render(r, &mut b);
///
/// // Scroll down by 2 lines.
/// textarea.scroll(Scrolling::Delta{rows: 2, cols: 0});
/// assert_eq!(textarea.cursor(), (2, 0));
///
/// // (1, 0) is converted into Scrolling::Delta{rows: 1, cols: 0}
/// textarea.scroll((1, 0));
/// assert_eq!(textarea.cursor(), (3, 0));
/// ```
Delta { rows: i16, cols: i16 },
/// Scroll down the textarea by one page.
///
/// ```
/// # use ratatui::buffer::Buffer;
/// # use ratatui::layout::Rect;
/// # use ratatui::widgets::Widget as _;
/// use tui_textarea::{TextArea, Scrolling};
///
/// // Let's say terminal height is 8.
///
/// // Create textarea with 20 lines "0", "1", "2", "3", ...
/// let mut textarea: TextArea = (0..20).into_iter().map(|i| i.to_string()).collect();
/// # // Call `render` at least once to populate terminal size
/// # let r = Rect { x: 0, y: 0, width: 24, height: 8 };
/// # let mut b = Buffer::empty(r.clone());
/// # textarea.render(r, &mut b);
///
/// // Scroll down by one page (8 lines)
/// textarea.scroll(Scrolling::PageDown);
/// assert_eq!(textarea.cursor(), (8, 0));
/// textarea.scroll(Scrolling::PageDown);
/// assert_eq!(textarea.cursor(), (16, 0));
/// textarea.scroll(Scrolling::PageDown);
/// assert_eq!(textarea.cursor(), (19, 0)); // Reached bottom of the textarea
/// ```
PageDown,
/// Scroll up the textarea by one page.
///
/// ```
/// # use ratatui::buffer::Buffer;
/// # use ratatui::layout::Rect;
/// # use ratatui::widgets::Widget as _;
/// use tui_textarea::{TextArea, Scrolling, CursorMove};
///
/// // Let's say terminal height is 8.
///
/// // Create textarea with 20 lines "0", "1", "2", "3", ...
/// let mut textarea: TextArea = (0..20).into_iter().map(|i| i.to_string()).collect();
/// # // Call `render` at least once to populate terminal size
/// # let r = Rect { x: 0, y: 0, width: 24, height: 8 };
/// # let mut b = Buffer::empty(r.clone());
/// # textarea.render(r.clone(), &mut b);
///
/// // Go to the last line at first
/// textarea.move_cursor(CursorMove::Bottom);
/// assert_eq!(textarea.cursor(), (19, 0));
/// # // Call `render` to populate terminal size
/// # textarea.render(r.clone(), &mut b);
///
/// // Scroll up by one page (8 lines)
/// textarea.scroll(Scrolling::PageUp);
/// assert_eq!(textarea.cursor(), (11, 0));
/// textarea.scroll(Scrolling::PageUp);
/// assert_eq!(textarea.cursor(), (7, 0)); // Reached top of the textarea
/// ```
PageUp,
/// Scroll down the textarea by half of the page.
///
/// ```
/// # use ratatui::buffer::Buffer;
/// # use ratatui::layout::Rect;
/// # use ratatui::widgets::Widget as _;
/// use tui_textarea::{TextArea, Scrolling};
///
/// // Let's say terminal height is 8.
///
/// // Create textarea with 10 lines "0", "1", "2", "3", ...
/// let mut textarea: TextArea = (0..10).into_iter().map(|i| i.to_string()).collect();
/// # // Call `render` at least once to populate terminal size
/// # let r = Rect { x: 0, y: 0, width: 24, height: 8 };
/// # let mut b = Buffer::empty(r.clone());
/// # textarea.render(r, &mut b);
///
/// // Scroll down by half-page (4 lines)
/// textarea.scroll(Scrolling::HalfPageDown);
/// assert_eq!(textarea.cursor(), (4, 0));
/// textarea.scroll(Scrolling::HalfPageDown);
/// assert_eq!(textarea.cursor(), (8, 0));
/// textarea.scroll(Scrolling::HalfPageDown);
/// assert_eq!(textarea.cursor(), (9, 0)); // Reached bottom of the textarea
/// ```
HalfPageDown,
/// Scroll up the textarea by half of the page.
///
/// ```
/// # use ratatui::buffer::Buffer;
/// # use ratatui::layout::Rect;
/// # use ratatui::widgets::Widget as _;
/// use tui_textarea::{TextArea, Scrolling, CursorMove};
///
/// // Let's say terminal height is 8.
///
/// // Create textarea with 20 lines "0", "1", "2", "3", ...
/// let mut textarea: TextArea = (0..20).into_iter().map(|i| i.to_string()).collect();
/// # // Call `render` at least once to populate terminal size
/// # let r = Rect { x: 0, y: 0, width: 24, height: 8 };
/// # let mut b = Buffer::empty(r.clone());
/// # textarea.render(r.clone(), &mut b);
///
/// // Go to the last line at first
/// textarea.move_cursor(CursorMove::Bottom);
/// assert_eq!(textarea.cursor(), (19, 0));
/// # // Call `render` to populate terminal size
/// # textarea.render(r.clone(), &mut b);
///
/// // Scroll up by half-page (4 lines)
/// textarea.scroll(Scrolling::HalfPageUp);
/// assert_eq!(textarea.cursor(), (15, 0));
/// textarea.scroll(Scrolling::HalfPageUp);
/// assert_eq!(textarea.cursor(), (11, 0));
/// ```
HalfPageUp,
}
impl Scrolling {
pub(crate) fn scroll(self, viewport: &mut Viewport) {
let (rows, cols) = match self {
Self::Delta { rows, cols } => (rows, cols),
Self::PageDown => {
let (_, _, _, height) = viewport.rect();
(height as i16, 0)
}
Self::PageUp => {
let (_, _, _, height) = viewport.rect();
(-(height as i16), 0)
}
Self::HalfPageDown => {
let (_, _, _, height) = viewport.rect();
((height as i16) / 2, 0)
}
Self::HalfPageUp => {
let (_, _, _, height) = viewport.rect();
(-(height as i16) / 2, 0)
}
};
viewport.scroll(rows, cols);
}
}
impl From<(i16, i16)> for Scrolling {
fn from((rows, cols): (i16, i16)) -> Self {
Self::Delta { rows, cols }
}
}

View File

@ -0,0 +1,63 @@
pub fn spaces(size: u8) -> &'static str {
const SPACES: &str = " ";
&SPACES[..size as usize]
}
#[derive(Debug, Clone)]
pub struct Pos {
pub row: usize,
pub col: usize,
pub offset: usize,
}
impl Pos {
pub fn new(row: usize, col: usize, offset: usize) -> Self {
Self { row, col, offset }
}
}
#[derive(PartialEq, Eq, Clone, Copy)]
enum CharKind {
Space,
Punct,
Other,
}
impl CharKind {
fn new(c: char) -> Self {
if c.is_whitespace() {
Self::Space
} else if c.is_ascii_punctuation() {
Self::Punct
} else {
Self::Other
}
}
}
pub fn find_word_start_forward(line: &str, start_col: usize) -> Option<usize> {
let mut it = line.chars().enumerate().skip(start_col);
let mut prev = CharKind::new(it.next()?.1);
for (col, c) in it {
let cur = CharKind::new(c);
if cur != CharKind::Space && prev != cur {
return Some(col);
}
prev = cur;
}
None
}
pub fn find_word_start_backward(line: &str, start_col: usize) -> Option<usize> {
let idx = line.char_indices().nth(start_col).map(|(i, _)| i).unwrap_or(line.len());
let mut it = line[..idx].chars().rev().enumerate();
let mut cur = CharKind::new(it.next()?.1);
for (i, c) in it {
let next = CharKind::new(c);
if cur != CharKind::Space && next != cur {
return Some(start_col - i);
}
cur = next;
}
(cur != CharKind::Space).then(|| 0)
}

View File

@ -0,0 +1,17 @@
pub(super) mod behaviour {
pub(super) mod cursor;
pub(super) mod highlight;
pub(super) mod input;
pub(super) mod scroll;
pub(super) mod util;
}
mod textarea;
pub use {
behaviour::input::{Input, Key},
textarea::{
validation::{validators, ValidationResult},
TextArea,
},
};

View File

@ -0,0 +1,65 @@
use {
super::{validation::ValidatorFn, TextArea},
ratatui::{layout::Alignment, style::Style, widgets::Block},
};
impl<'a> TextArea<'a> {
/// Set the style of textarea. By default, textarea is not styled.
pub fn with_style(mut self, style: Style) -> Self {
self.style = style;
self
}
/// Set the block of textarea. By default, no block is set.
pub fn with_block(mut self, block: Block<'a>) -> Self {
self.block = Some(block);
self
}
/// Set the placeholder text. The text is set in the textarea when no text is input. Setting a
/// non-empty string `""` enables the placeholder. The default value is an empty string so the
/// placeholder is disabled by default. To customize the text style, see
/// [`TextArea::set_placeholder_style`].
pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
self.placeholder = placeholder.into();
self
}
/// Set the style of the placeholder text. The default style is a dark gray text.
pub fn with_placeholder_style(mut self, style: Style) -> Self {
self.placeholder_style = style;
self
}
/// Specify a character masking the text. All characters in the textarea will be replaced by
/// this character. This API is useful for making a kind of credentials form such as a password
/// input.
pub fn with_mask_char(mut self, mask: Option<char>) -> Self {
self.mask = mask;
self
}
/// Set the style of cursor. By default, a cursor is rendered in the reversed color. Setting the
/// same style as cursor line hides a cursor.
pub fn with_cursor_style(mut self, style: Style) -> Self {
self.cursor_style = style;
self
}
/// Set text alignment. When [`Alignment::Center`] or [`Alignment::Right`] is set, line number
/// is automatically disabled because those alignments don't work well with line numbers.
pub fn with_alignment(mut self, alignment: Alignment) -> Self {
self.alignment = alignment;
self
}
pub fn with_validations(
mut self,
validations: impl IntoIterator<
Item = impl Fn(&str) -> Result<(), String> + Send + Sync + 'static,
>,
) -> Self {
self.validators.extend(validations.into_iter().map(ValidatorFn::new));
self
}
}

View File

@ -0,0 +1,122 @@
use {
super::TextArea,
crate::tui::widgets::textarea::behaviour::{cursor::CursorMove, scroll::Scrolling},
ratatui::{layout::Alignment, style::Style, widgets::Block},
};
impl<'a> TextArea<'a> {
/// Get the current style of textarea.
pub fn style(&self) -> Style {
self.style
}
/// Remove the block of textarea which was set by [`TextArea::set_block`].
pub fn remove_block(&mut self) {
self.block = None;
}
/// Get the block of textarea if exists.
pub fn block<'s>(&'s self) -> Option<&'s Block<'a>> {
self.block.as_ref()
}
/// Get the placeholder text. An empty string means the placeholder is disabled. The default
/// value is an empty string.
pub fn placeholder_text(&self) -> &'_ str {
self.placeholder.as_str()
}
/// Get the placeholder style. When the placeholder text is empty, it returns `None` since the
/// placeholder is disabled. The default style is a dark gray text.
pub fn placeholder_style(&self) -> Option<Style> {
if self.placeholder.is_empty() {
None
} else {
Some(self.placeholder_style)
}
}
/// Get the style of cursor.
pub fn cursor_style(&self) -> Style {
self.cursor_style
}
/// Get slice of line texts. This method borrows the content, but not moves. Note that the
/// returned slice will never be empty because an empty text means a slice containing one empty
/// line. This is correct since any text file must end with a newline.
pub fn lines(&'a self) -> &'a [String] {
&self.lines
}
/// Convert [`TextArea`] instance into line texts.
pub fn into_lines(self) -> Vec<String> {
self.lines
}
/// Get the current cursor position. 0-base character-wise (row, col) cursor position.
pub fn cursor(&self) -> (usize, usize) {
self.cursor
}
/// Get the current selection range as a pair of the start position and the end position. The
/// range is bounded inclusively below and exclusively above. The positions are 0-base
/// character-wise (row, col) values. The first element of the pair is always smaller than the
/// second one even when it is ahead of the cursor. When no text is selected, this method
/// returns `None`.
pub fn selection_range(&self) -> Option<((usize, usize), (usize, usize))> {
self.selection_start.map(|pos| {
if pos > self.cursor {
(self.cursor, pos)
} else {
(pos, self.cursor)
}
})
}
/// Get current text alignment. The default alignment is [`Alignment::Left`].
pub fn alignment(&self) -> Alignment {
self.alignment
}
/// Check if the textarea has a empty content.
pub fn is_empty(&self) -> bool {
self.lines == [""]
}
/// Get the yanked text. Text is automatically yanked when deleting strings by
/// [`TextArea::delete_line_by_head`], [`TextArea::delete_line_by_end`],
/// [`TextArea::delete_word`], [`TextArea::delete_next_word`], [`TextArea::delete_str`],
/// [`TextArea::copy`], and [`TextArea::cut`]. When multiple lines were yanked, they are always
/// joined with `\n`.
pub fn yank_text(&self) -> String {
self.yank.to_string()
}
/// Set a yanked text. The text can be inserted by [`TextArea::paste`]. `\n` and `\r\n` are
/// recognized as newline but `\r` isn't.
pub fn set_yank_text(&mut self, text: impl Into<String>) {
// `str::lines` is not available since it strips a newline at end
let lines: Vec<_> = text
.into()
.split('\n')
.map(|s| s.strip_suffix('\r').unwrap_or(s).to_string())
.collect();
self.yank = lines.into();
}
/// Scroll the textarea. See [`Scrolling`] for the argument.
/// The cursor will not move until it goes out the viewport. When the cursor position is outside
/// the viewport after scroll, the cursor position will be adjusted to stay in the viewport
/// using the same logic as [`CursorMove::InViewport`].
pub fn scroll(&mut self, scrolling: impl Into<Scrolling>) {
self.scroll_with_shift(scrolling.into(), self.selection_start.is_some());
}
fn scroll_with_shift(&mut self, scrolling: Scrolling, shift: bool) {
if shift && self.selection_start.is_none() {
self.selection_start = Some(self.cursor);
}
scrolling.scroll(&mut self.viewport);
self.move_cursor_with_shift(CursorMove::InViewport, shift);
}
}

View File

@ -0,0 +1,516 @@
pub mod builder;
pub mod getset;
pub mod validation;
pub mod widget;
use {
super::behaviour::{
cursor::CursorMove,
highlight::LineHighlighter,
input::Input,
util::{spaces, Pos},
},
ratatui::{
layout::Alignment,
style::{Color, Modifier, Style},
text::Line,
widgets::Block,
},
std::{
cmp::Ordering,
fmt::{self, Debug},
},
unicode_width::UnicodeWidthChar as _,
validation::ValidatorFn,
widget::Viewport,
};
#[derive(Debug, Clone)]
enum YankText {
Piece(String),
Chunk(Vec<String>),
}
impl Default for YankText {
fn default() -> Self {
Self::Piece(String::new())
}
}
impl From<String> for YankText {
fn from(s: String) -> Self {
Self::Piece(s)
}
}
impl From<Vec<String>> for YankText {
fn from(mut c: Vec<String>) -> Self {
match c.len() {
0 => Self::default(),
1 => Self::Piece(c.remove(0)),
_ => Self::Chunk(c),
}
}
}
impl fmt::Display for YankText {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Piece(s) => write!(f, "{}", s),
Self::Chunk(ss) => write!(f, "{}", ss.join("\n")),
}
}
}
/// A type to manage state of textarea. These are some important methods:
#[derive(Clone, Debug)]
pub struct TextArea<'a> {
pub(crate) viewport: Viewport,
pub(crate) cursor_style: Style,
pub(crate) placeholder: String,
pub(crate) placeholder_style: Style,
lines: Vec<String>,
block: Option<Block<'a>>,
style: Style,
cursor: (usize, usize), // 0-base
tab_len: u8,
cursor_line_style: Style,
yank: YankText,
alignment: Alignment,
mask: Option<char>,
selection_start: Option<(usize, usize)>,
select_style: Style,
validators: Vec<ValidatorFn>,
}
impl<'a, I> From<I> for TextArea<'a>
where
I: IntoIterator,
I::Item: Into<String>,
{
/// Convert any iterator whose elements can be converted into [`String`] into [`TextArea`]. Each
/// [`String`] element is handled as line. Ensure that the strings don't contain any newlines.
/// This method is useful to create [`TextArea`] from [`std::str::Lines`].
///
/// ```
/// use tui::widgets::TextArea;
/// let textarea = TextArea::from(["hello", "world"]);
/// ```
fn from(i: I) -> Self {
Self::new(i.into_iter().map(|s| s.into()).collect::<Vec<String>>())
}
}
impl<'a> Default for TextArea<'a> {
/// Create [`TextArea`] instance with empty text content.
fn default() -> Self {
Self::new(vec![String::new()])
}
}
impl<'a> TextArea<'a> {
/// Create [`TextArea`] instance with given lines.
pub fn new(mut lines: Vec<String>) -> Self {
if lines.is_empty() {
lines.push(String::new());
}
Self {
lines,
block: None,
style: Style::default(),
cursor: (0, 0),
tab_len: 2,
cursor_line_style: Style::default(),
viewport: Viewport::default(),
cursor_style: Style::default().add_modifier(Modifier::REVERSED),
yank: YankText::default(),
alignment: Alignment::Left,
placeholder: String::new(),
placeholder_style: Style::default().fg(Color::DarkGray),
mask: None,
selection_start: None,
select_style: Style::default().bg(Color::LightBlue),
validators: Vec::new(),
}
}
/// Handle a key input with default key mappings.
/// Recieves any `impl Into<Input>`, e.g. `crossterm`'s key event types. [`Input`] so this
/// method can take the event values directly. This method returns if the input modified text
/// contents or not in the textarea.
pub fn input(&mut self, input: impl Into<Input>) -> bool {
let input = input.into();
let modified = match input.kind() {
":char" => {
if let Some(c) = input.maybe_char() {
self.insert_char(c);
true
} else {
false
}
}
":non-enter-newline" => self.insert_newline(),
":tab" => self.insert_tab(),
":backspace" => self.delete_char(),
":delete" => self.delete_next_char(),
":down" => self.move_cursor_with_shift(CursorMove::Down, input.shift),
":up" => self.move_cursor_with_shift(CursorMove::Up, input.shift),
":right" => self.move_cursor_with_shift(CursorMove::Forward, input.shift),
":left" => self.move_cursor_with_shift(CursorMove::Back, input.shift),
":home" => self.move_cursor_with_shift(CursorMove::Head, input.shift),
":end" => self.move_cursor_with_shift(CursorMove::End, input.shift),
":word-right" => self.move_cursor_with_shift(CursorMove::WordForward, input.shift),
":word-left" => self.move_cursor_with_shift(CursorMove::WordBack, input.shift),
_ => false,
};
// Check invariants
debug_assert!(!self.lines.is_empty(), "no line after {:?}", input);
let (r, c) = self.cursor;
debug_assert!(
self.lines.len() > r,
"cursor {:?} exceeds max lines {} after {:?}",
self.cursor,
self.lines.len(),
input,
);
debug_assert!(
self.lines[r].chars().count() >= c,
"cursor {:?} exceeds max col {} at line {:?} after {:?}",
self.cursor,
self.lines[r].chars().count(),
self.lines[r],
input,
);
modified
}
/// Insert a single character at current cursor position.
pub fn insert_char(&mut self, c: char) {
if c == '\n' || c == '\r' {
self.insert_newline();
return;
}
self.delete_selection(false);
let (row, col) = self.cursor;
let line = &mut self.lines[row];
let i = line.char_indices().nth(col).map(|(i, _)| i).unwrap_or(line.len());
line.insert(i, c);
self.cursor.1 += 1;
}
/// Insert a string at current cursor position. This method returns if some text was inserted or
/// not in the textarea. Both `\n` and `\r\n` are recognized as newlines but `\r` isn't.
pub fn insert_str<S: AsRef<str>>(&mut self, s: S) -> bool {
let modified = self.delete_selection(false);
let mut lines: Vec<_> =
s.as_ref().split('\n').map(|s| s.strip_suffix('\r').unwrap_or(s).to_string()).collect();
match lines.len() {
0 => modified,
1 => self.insert_piece(lines.remove(0)),
_ => self.insert_chunk(lines),
}
}
fn insert_chunk(&mut self, chunk: Vec<String>) -> bool {
debug_assert!(chunk.len() > 1, "Chunk size must be > 1: {:?}", chunk);
let (row, _col) = self.cursor;
let (row, col) = (
row + chunk.len() - 1,
chunk[chunk.len() - 1].chars().count(),
);
self.cursor = (row, col);
true
}
fn insert_piece(&mut self, s: String) -> bool {
if s.is_empty() {
return false;
}
let (row, col) = self.cursor;
let line = &mut self.lines[row];
debug_assert!(
!s.contains('\n'),
"string given to TextArea::insert_piece must not contain newline: {:?}",
line,
);
let i = line.char_indices().nth(col).map(|(i, _)| i).unwrap_or(line.len());
line.insert_str(i, &s);
self.cursor.1 += s.chars().count();
true
}
fn delete_range(&mut self, start: Pos, end: Pos, should_yank: bool) {
self.cursor = (start.row, start.col);
if start.row == end.row {
let removed =
self.lines[start.row].drain(start.offset..end.offset).as_str().to_string();
if should_yank {
self.yank = removed.clone().into();
}
return;
}
let mut deleted = vec![self.lines[start.row].drain(start.offset..).as_str().to_string()];
deleted.extend(self.lines.drain(start.row + 1..end.row));
if start.row + 1 < self.lines.len() {
let mut last_line = self.lines.remove(start.row + 1);
self.lines[start.row].push_str(&last_line[end.offset..]);
last_line.truncate(end.offset);
deleted.push(last_line);
}
if should_yank {
self.yank = YankText::Chunk(deleted.clone());
}
}
/// Delete a string from the current cursor position. The `chars` parameter means number of
/// characters, not a byte length of the string. Newlines at the end of lines are counted in the
/// number. This method returns if some text was deleted or not.
pub fn delete_str(&mut self, chars: usize) -> bool {
if self.delete_selection(false) {
return true;
}
if chars == 0 {
return false;
}
let (start_row, start_col) = self.cursor;
let mut remaining = chars;
let mut find_end = move |line: &str| {
let mut col = 0usize;
for (i, _) in line.char_indices() {
if remaining == 0 {
return Some((i, col));
}
col += 1;
remaining -= 1;
}
if remaining == 0 {
Some((line.len(), col))
} else {
remaining -= 1;
None
}
};
let line = &self.lines[start_row];
let start_offset =
{ line.char_indices().nth(start_col).map(|(i, _)| i).unwrap_or(line.len()) };
// First line
if let Some((offset_delta, _col_delta)) = find_end(&line[start_offset..]) {
let end_offset = start_offset + offset_delta;
let removed =
self.lines[start_row].drain(start_offset..end_offset).as_str().to_string();
self.yank = removed.clone().into();
return true;
}
let mut r = start_row + 1;
let mut offset = 0;
let mut col = 0;
while r < self.lines.len() {
let line = &self.lines[r];
if let Some((o, c)) = find_end(line) {
offset = o;
col = c;
break;
}
r += 1;
}
let start = Pos::new(start_row, start_col, start_offset);
let end = Pos::new(r, col, offset);
self.delete_range(start, end, true);
true
}
/// Insert a tab at current cursor position. Note that this method does nothing when the tab
/// length is 0. This method returns if a tab string was inserted or not in the textarea.
pub fn insert_tab(&mut self) -> bool {
let modified = self.delete_selection(false);
if self.tab_len == 0 {
return modified;
}
let (row, col) = self.cursor;
let width: usize = self.lines[row].chars().take(col).map(|c| c.width().unwrap_or(0)).sum();
let len = self.tab_len - (width % self.tab_len as usize) as u8;
self.insert_piece(spaces(len).to_string())
}
/// Insert a newline at current cursor position.
pub fn insert_newline(&mut self) -> bool {
self.delete_selection(false);
let (row, col) = self.cursor;
let line = &mut self.lines[row];
let offset = line.char_indices().nth(col).map(|(i, _)| i).unwrap_or(line.len());
let next_line = line[offset..].to_string();
line.truncate(offset);
self.lines.insert(row + 1, next_line);
self.cursor = (row + 1, 0);
true
}
/// Delete a newline from **head** of current cursor line. This method returns if a newline was
/// deleted or not in the textarea. When some text is selected, it is deleted instead.
pub fn delete_newline(&mut self) -> bool {
if self.delete_selection(false) {
return true;
}
let (row, _) = self.cursor;
if row == 0 {
return false;
}
let line = self.lines.remove(row);
let prev_line = &mut self.lines[row - 1];
self.cursor = (row - 1, prev_line.chars().count());
prev_line.push_str(&line);
true
}
/// Delete one character before cursor. When the cursor is at head of line, the newline before
/// the cursor will be removed. This method returns if some text was deleted or not in the
/// textarea. When some text is selected, it is deleted instead.
pub fn delete_char(&mut self) -> bool {
if self.delete_selection(false) {
return true;
}
let (row, col) = self.cursor;
if col == 0 {
return self.delete_newline();
}
let line = &mut self.lines[row];
if let Some((offset, _c)) = line.char_indices().nth(col - 1) {
line.remove(offset);
self.cursor.1 -= 1;
true
} else {
false
}
}
/// Delete one character next to cursor. When the cursor is at end of line, the newline next to
/// the cursor will be removed. This method returns if a character was deleted or not in the
/// textarea.
pub fn delete_next_char(&mut self) -> bool {
if self.delete_selection(false) {
return true;
}
let before = self.cursor;
self.move_cursor_with_shift(CursorMove::Forward, false);
if before == self.cursor {
return false; // Cursor didn't move, meant no character at next of cursor.
}
self.delete_char()
}
/// Start text selection at the cursor position. If text selection is already ongoing, the start
/// position is reset.
pub fn start_selection(&mut self) {
self.selection_start = Some(self.cursor);
}
/// Stop the current text selection. This method does nothing if text selection is not ongoing.
pub fn cancel_selection(&mut self) {
self.selection_start = None;
}
fn line_offset(&self, row: usize, col: usize) -> usize {
let line = self.lines.get(row).unwrap_or(&self.lines[self.lines.len() - 1]);
line.char_indices().nth(col).map(|(i, _)| i).unwrap_or(line.len())
}
/// Set the style used for text selection. The default style is light blue.
pub fn set_selection_style(&mut self, style: Style) {
self.select_style = style;
}
/// Get the style used for text selection.
pub fn selection_style(&mut self) -> Style {
self.select_style
}
fn selection_positions(&self) -> Option<(Pos, Pos)> {
let (sr, sc) = self.selection_start?;
let (er, ec) = self.cursor;
let (so, eo) = (self.line_offset(sr, sc), self.line_offset(er, ec));
let s = Pos::new(sr, sc, so);
let e = Pos::new(er, ec, eo);
match (sr, so).cmp(&(er, eo)) {
Ordering::Less => Some((s, e)),
Ordering::Equal => None,
Ordering::Greater => Some((e, s)),
}
}
fn take_selection_positions(&mut self) -> Option<(Pos, Pos)> {
let range = self.selection_positions();
self.cancel_selection();
range
}
fn delete_selection(&mut self, should_yank: bool) -> bool {
if let Some((s, e)) = self.take_selection_positions() {
self.delete_range(s, e, should_yank);
return true;
}
false
}
fn move_cursor_with_shift(&mut self, m: CursorMove, shift: bool) -> bool {
if let Some(cursor) = m.next_cursor(self.cursor, &self.lines, &self.viewport) {
if shift {
if self.selection_start.is_none() {
self.start_selection();
}
} else {
self.cancel_selection();
}
self.cursor = cursor;
}
false
}
pub(crate) fn line_spans<'b>(&'b self, line: &'b str, row: usize) -> Line<'b> {
let mut hl = LineHighlighter::new(
line,
self.cursor_style,
self.tab_len,
self.mask,
self.select_style,
);
if row == self.cursor.0 {
hl.cursor_line(self.cursor.1, self.cursor_line_style);
}
if let Some((start, end)) = self.selection_positions() {
hl.selection(row, start.row, start.offset, end.row, end.offset);
}
hl.into_spans()
}
}
// Builder-Pattern methods

View File

@ -0,0 +1,56 @@
pub mod validators;
use {super::TextArea, std::sync::Arc};
pub enum ValidationResult {
Valid,
Invalid(Vec<String>),
}
#[derive(Clone)]
pub struct ValidatorFn(Arc<dyn Fn(&str) -> Result<(), String> + Send + Sync>);
impl ValidatorFn {
pub fn new<F>(f: F) -> Self
where
F: Fn(&str) -> Result<(), String> + Send + Sync + 'static,
{
ValidatorFn(Arc::new(f))
}
// Method to call the inner function
pub fn call(&self, arg: &str) -> Result<(), String> {
(self.0)(arg)
}
}
impl std::fmt::Debug for ValidatorFn {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "CloneableFn {{ ... }}")
}
}
impl<'a> TextArea<'a> {
pub fn validate(&self) -> ValidationResult {
let lines = self.lines().join("\n");
let mut errors = Vec::new();
// For each validation function, call it and collect the errors
for validation in &self.validators {
match validation.call(&lines) {
Ok(_) => {}
Err(err) => errors.push(err),
}
}
if errors.is_empty() {
ValidationResult::Valid
} else {
ValidationResult::Invalid(errors)
}
}
pub fn is_valid(&self) -> bool {
matches!(self.validate(), ValidationResult::Valid)
}
}

View File

@ -0,0 +1,7 @@
pub fn required_validator(input: &str) -> Result<(), String> {
if input.is_empty() {
Err(format!("This field is required"))
} else {
Ok(())
}
}

View File

@ -0,0 +1,157 @@
use {
super::TextArea,
ratatui::{
buffer::Buffer,
layout::Rect,
text::{Line, Span, Text},
widgets::{Paragraph, Widget},
},
std::{
cmp,
sync::atomic::{AtomicU64, Ordering},
},
};
// &mut 'a (u16, u16, u16, u16) is not available since `render` method takes immutable reference of
// TextArea instance. In the case, the TextArea instance cannot be accessed from any other objects
// since it is mutablly borrowed.
//
// `ratatui::Frame::render_stateful_widget` would be an assumed way to render a stateful widget. But
// at this point we stick with using `ratatui::Frame::render_widget` because it is simpler API.
// Users don't need to manage states of textarea instances separately.
// https://docs.rs/ratatui/latest/ratatui/terminal/struct.Frame.html#method.render_stateful_widget
#[derive(Default, Debug)]
pub struct Viewport(AtomicU64);
impl Clone for Viewport {
fn clone(&self) -> Self {
let u = self.0.load(Ordering::Relaxed);
Viewport(AtomicU64::new(u))
}
}
impl Viewport {
pub fn scroll_top(&self) -> (u16, u16) {
let u = self.0.load(Ordering::Relaxed);
((u >> 16) as u16, u as u16)
}
pub fn rect(&self) -> (u16, u16, u16, u16) {
let u = self.0.load(Ordering::Relaxed);
let width = (u >> 48) as u16;
let height = (u >> 32) as u16;
let row = (u >> 16) as u16;
let col = u as u16;
(row, col, width, height)
}
pub fn position(&self) -> (u16, u16, u16, u16) {
let (row_top, col_top, width, height) = self.rect();
let row_bottom = row_top.saturating_add(height).saturating_sub(1);
let col_bottom = col_top.saturating_add(width).saturating_sub(1);
(
row_top,
col_top,
cmp::max(row_top, row_bottom),
cmp::max(col_top, col_bottom),
)
}
fn store(&self, row: u16, col: u16, width: u16, height: u16) {
// Pack four u16 values into one u64 value
let u =
((width as u64) << 48) | ((height as u64) << 32) | ((row as u64) << 16) | col as u64;
self.0.store(u, Ordering::Relaxed);
}
pub fn scroll(&mut self, rows: i16, cols: i16) {
fn apply_scroll(pos: u16, delta: i16) -> u16 {
if delta >= 0 {
pos.saturating_add(delta as u16)
} else {
pos.saturating_sub(-delta as u16)
}
}
let u = self.0.get_mut();
let row = apply_scroll((*u >> 16) as u16, rows);
let col = apply_scroll(*u as u16, cols);
*u = (*u & 0xffff_ffff_0000_0000) | ((row as u64) << 16) | (col as u64);
}
}
#[inline]
fn next_scroll_top(prev_top: u16, cursor: u16, len: u16) -> u16 {
if cursor < prev_top {
cursor
} else if prev_top + len <= cursor {
cursor + 1 - len
} else {
prev_top
}
}
impl<'a> TextArea<'a> {
fn text_widget(&'a self, top_row: usize, height: usize) -> Text<'a> {
let lines_len = self.lines().len();
let bottom_row = cmp::min(top_row + height, lines_len);
let mut lines = Vec::with_capacity(bottom_row - top_row);
for (i, line) in self.lines()[top_row..bottom_row].iter().enumerate() {
lines.push(self.line_spans(line.as_str(), top_row + i));
}
Text::from(lines)
}
fn placeholder_widget(&'a self) -> Text<'a> {
let cursor = Span::styled(" ", self.cursor_style);
let text = Span::raw(self.placeholder.as_str());
Text::from(Line::from(vec![cursor, text]))
}
fn scroll_top_row(&self, prev_top: u16, height: u16) -> u16 {
next_scroll_top(prev_top, self.cursor().0 as u16, height)
}
fn scroll_top_col(&self, prev_top: u16, width: u16) -> u16 {
let cursor = self.cursor().1 as u16;
next_scroll_top(prev_top, cursor, width)
}
}
impl Widget for &TextArea<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let Rect { width, height, .. } =
if let Some(b) = self.block() { b.inner(area) } else { area };
let (top_row, top_col) = self.viewport.scroll_top();
let top_row = self.scroll_top_row(top_row, height);
let top_col = self.scroll_top_col(top_col, width);
let (text, style) = if !self.placeholder.is_empty() && self.is_empty() {
(self.placeholder_widget(), self.placeholder_style)
} else {
(self.text_widget(top_row as _, height as _), self.style())
};
// To get fine control over the text color and the surrrounding block they have to be
// rendered separately / see https://github.com/ratatui-org/ratatui/issues/144
let mut text_area = area;
let mut inner = Paragraph::new(text).style(style).alignment(self.alignment());
if let Some(b) = self.block() {
text_area = b.inner(area);
// ratatui does not need `clone()` call because `Block` implements `WidgetRef` and `&T`
// implements `Widget` where `T: WidgetRef`. So `b.render` internally calls
// `b.render_ref` and it doesn't move out `self`.
b.render(area, buf)
}
if top_col != 0 {
inner = inner.scroll((0, top_col));
}
// Store scroll top position for rendering on the next tick
self.viewport.store(top_row, top_col, width, height);
inner.render(text_area, buf);
}
}