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