diff --git a/Cargo.lock b/Cargo.lock
index 181f3e3..aaa57c6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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"
diff --git a/Cargo.toml b/Cargo.toml
index 9c462ae..4cb9edc 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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"
diff --git a/lib/cli/tui/README.md b/lib/cli/tui/README.md
index ea69d58..dc12938 100644
--- a/lib/cli/tui/README.md
+++ b/lib/cli/tui/README.md
@@ -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
@@ -166,4 +166,46 @@ fn receive_message(&mut self, message: String) {
// Parse the message and do whatever is needed with the data
}
}
+```
+
+## 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:
+
+- Enter won't add a new line. **why?** Because that way, we can use the Enter
+ 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,
+]);
```
\ No newline at end of file
diff --git a/lib/cli/tui/mod.rs b/lib/cli/tui/mod.rs
index e4c3f67..aa0a686 100644
--- a/lib/cli/tui/mod.rs
+++ b/lib/cli/tui/mod.rs
@@ -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::*, *};
diff --git a/lib/cli/tui/widgets/textarea/behaviour/cursor.rs b/lib/cli/tui/widgets/textarea/behaviour/cursor.rs
new file mode 100644
index 0000000..156be0f
--- /dev/null
+++ b/lib/cli/tui/widgets/textarea/behaviour/cursor.rs
@@ -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))
+ }
+ }
+ }
+}
diff --git a/lib/cli/tui/widgets/textarea/behaviour/highlight.rs b/lib/cli/tui/widgets/textarea/behaviour/highlight.rs
new file mode 100644
index 0000000..056ba72
--- /dev/null
+++ b/lib/cli/tui/widgets/textarea/behaviour/highlight.rs
@@ -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