Compare commits

...

14 Commits

28 changed files with 3196 additions and 101 deletions

View File

@ -1,5 +1,5 @@
[registries.lugit]
index = "sparse+http://lugit.local/api/packages/lucodear/cargo/"
index = "sparse+https://git.lucode.dev/api/packages/lucas/cargo/"
[registry]
global-credential-providers = ["cargo:token"]

131
Cargo.lock generated
View File

@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
version = 4
[[package]]
name = "addr2line"
@ -82,9 +82,9 @@ dependencies = [
[[package]]
name = "bitflags"
version = "2.6.0"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de"
checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd"
[[package]]
name = "bumpalo"
@ -133,16 +133,16 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "chrono"
version = "0.4.38"
version = "0.4.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401"
checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c"
dependencies = [
"android-tzdata",
"iana-time-zone",
"js-sys",
"num-traits",
"wasm-bindgen",
"windows-targets",
"windows-link",
]
[[package]]
@ -167,9 +167,9 @@ checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f"
[[package]]
name = "croner"
version = "2.0.5"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eba3aaaafb3c313b352ff02626adb2dfaf9663159d339a878f6e5b2f6259a97c"
checksum = "38fd53511eaf0b00a185613875fee58b208dfce016577d0ad4bb548e1c4fb3ee"
dependencies = [
"chrono",
]
@ -202,9 +202,9 @@ dependencies = [
[[package]]
name = "downcast-rs"
version = "1.2.1"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
checksum = "ea8a8b81cacc08888170eef4d13b775126db426d0b348bee9d18c2c1eaf123cf"
[[package]]
name = "either"
@ -240,9 +240,9 @@ checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1"
[[package]]
name = "futures"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0"
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [
"futures-channel",
"futures-core",
@ -255,9 +255,9 @@ dependencies = [
[[package]]
name = "futures-channel"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [
"futures-core",
"futures-sink",
@ -265,15 +265,15 @@ dependencies = [
[[package]]
name = "futures-core"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
[[package]]
name = "futures-executor"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d"
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
dependencies = [
"futures-core",
"futures-task",
@ -282,15 +282,15 @@ dependencies = [
[[package]]
name = "futures-io"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-macro"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac"
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
@ -299,21 +299,21 @@ dependencies = [
[[package]]
name = "futures-sink"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5"
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
[[package]]
name = "futures-task"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004"
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
[[package]]
name = "futures-util"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
"futures-channel",
"futures-core",
@ -390,6 +390,12 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683"
[[package]]
name = "indoc"
version = "2.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd"
[[package]]
name = "instability"
version = "0.3.2"
@ -448,13 +454,13 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.22"
version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
[[package]]
name = "lool"
version = "0.6.1"
version = "0.9.0"
dependencies = [
"bitflags",
"chrono",
@ -468,9 +474,10 @@ dependencies = [
"num-traits",
"palette",
"ratatui",
"strum",
"strum 0.27.1",
"tokio",
"tokio-util",
"unicode-width 0.2.0",
]
[[package]]
@ -676,23 +683,23 @@ checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]]
name = "ratatui"
version = "0.28.1"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d"
checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
dependencies = [
"bitflags",
"cassowary",
"compact_str",
"crossterm",
"indoc",
"instability",
"itertools",
"lru",
"paste",
"strum",
"strum_macros",
"strum 0.26.3",
"unicode-segmentation",
"unicode-truncate",
"unicode-width",
"unicode-width 0.2.0",
]
[[package]]
@ -804,7 +811,16 @@ version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
dependencies = [
"strum_macros",
"strum_macros 0.26.4",
]
[[package]]
name = "strum"
version = "0.27.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32"
dependencies = [
"strum_macros 0.27.1",
]
[[package]]
@ -820,6 +836,19 @@ dependencies = [
"syn",
]
[[package]]
name = "strum_macros"
version = "0.27.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c77a8c5abcaf0f9ce05d62342b7d298c346515365c36b673df4ebe3ced01fde8"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
"syn",
]
[[package]]
name = "syn"
version = "2.0.58"
@ -833,9 +862,9 @@ dependencies = [
[[package]]
name = "tokio"
version = "1.40.0"
version = "1.44.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998"
checksum = "f382da615b842244d4b8738c82ed1275e6c5dd90c459a30941cd07080b06c91a"
dependencies = [
"backtrace",
"pin-project-lite",
@ -844,9 +873,9 @@ dependencies = [
[[package]]
name = "tokio-macros"
version = "2.4.0"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752"
checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
dependencies = [
"proc-macro2",
"quote",
@ -855,9 +884,9 @@ dependencies = [
[[package]]
name = "tokio-util"
version = "0.7.12"
version = "0.7.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61e7c3654c13bcd040d4a03abee2c75b1d14a37b423cf5a813ceae1cc903ec6a"
checksum = "6b9590b93e6fcc1739458317cccd391ad3955e2bde8913edf6f95f9e65a8f034"
dependencies = [
"bytes",
"futures-core",
@ -886,7 +915,7 @@ checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
dependencies = [
"itertools",
"unicode-segmentation",
"unicode-width",
"unicode-width 0.1.13",
]
[[package]]
@ -895,6 +924,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"
@ -992,6 +1027,12 @@ dependencies = [
"windows-targets",
]
[[package]]
name = "windows-link"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38"
[[package]]
name = "windows-sys"
version = "0.52.0"

View File

@ -1,6 +1,6 @@
[package]
name = "lool"
version = "0.6.1"
version = "0.9.0"
edition = "2021"
description = "🧳 lool » lucode.ar rust common utilities"
authors = ["Lucas Colombo <lucas@lucode.ar>"]
@ -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
@ -63,27 +64,32 @@ path = "lib/lib.rs"
"utils" = []
"utils.threads" = ["utils", "macros", "dep:log"]
# tokio
"tokio.rt" = [
"tokio?/rt"
]
[dependencies]
# default
eyre = { version = "0.6.12", default-features = false }
# optional
bitflags = { version = "2.6.0", optional = true }
chrono = { version = "0.4.37", optional = true }
log = { version = "0.4.22", optional = true }
tokio = { version = "1.40.0", optional = true }
croner = { version = "2.0.5", optional = true }
bitflags = { version = "2.9.0", optional = true }
chrono = { version = "0.4.40", optional = true }
log = { version = "0.4.27", optional = true }
tokio = { version = "1.44.1", optional = true }
croner = { version = "2.1.0", optional = true }
num-traits = { version = "0.2.19", optional = true }
glob-match = { version = "0.2.1", optional = true }
tokio-util = { version = "0.7.12", optional = true }
ratatui = { version = "0.28.1", optional = true }
tokio-util = { version = "0.7.14", optional = true }
ratatui = { version = "0.29.0", optional = true }
palette = { version = "0.7.6", optional = true }
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 }
strum = { version="0.27.1", optional = true }
downcast-rs = { version="2.0.1", optional = true}
futures = { version = "0.3.31", optional = true }
unicode-width = { version = "0.2.0", optional = true }
[[example]]
name = "sched"
@ -94,3 +100,18 @@ required-features = ["sched.threads", "sched.rule-recurrence"]
name = "sched_tokio"
path = "examples/sched_tokio.rs"
required-features = ["sched.tokio", "sched.rule-recurrence"]
[[example]]
name = "widget_text_area"
path = "examples/widget_text_area.rs"
required-features = ["cli.tui.widgets", "tokio.rt"]
[[example]]
name = "widget_grid_selector"
path = "examples/widget_grid_selector.rs"
required-features = ["cli.tui.widgets", "tokio.rt"]
[[example]]
name = "widget_switch"
path = "examples/widget_switch.rs"
required-features = ["cli.tui.widgets", "tokio.rt"]

View File

@ -4,7 +4,7 @@
<br>
<br>
<p align="center"><b>𝐥𝐨𝐨𝐥</b> is a tool-box library with common utilities for <b>𝚕𝚞𝚌𝚘𝚍𝚎.𝚊𝚛</b> projects.
<p align="center"><b>𝐥𝐨𝐨𝐥</b> is a tool-box library with common utilities for <b>𝚕𝚞𝚌𝚘𝚍𝚎.dev</b> projects.
</p>
<br>

View File

@ -29,6 +29,16 @@ tasks:
cmds:
- cargo run --example=threadpool --release --features utils.threads
example:gridselector:
desc: 🚀 run lool «example widget_grid_selector»
cmds:
- cargo watch --features=cli.tui.widgets,tokio.rt -c -x "run --example widget_grid_selector"
example:switch:
desc: 🚀 run lool «example widget_switch»
cmds:
- cargo watch --features=cli.tui.widgets,tokio.rt -c -x "run --example widget_switch"
fmt:
desc: 🎨 format lool
cmds:

View File

@ -0,0 +1,187 @@
use std::io;
use {
lool::tui::widgets::gridselector::GridItem,
ratatui::{
layout::{Alignment, Constraint, Direction, Layout},
style::{Modifier, Style, Stylize},
widgets::Paragraph,
},
};
use {
crossterm::{
event::{self, Event, KeyCode, KeyEvent, KeyEventKind},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
},
lool::tui::widgets::gridselector::{GridSelector, GridSelectorState},
ratatui::{layout::Rect, prelude::CrosstermBackend, style::Color, Frame, Terminal},
};
struct Item {
name: String,
emoji: String,
}
impl Item {
fn new(name: &str, emoji: &str) -> Self {
Item {
name: name.to_string(),
emoji: emoji.to_string(),
}
}
}
impl From<Item> for GridItem {
fn from(val: Item) -> Self {
GridItem::new(format!("{} {}", val.name, val.emoji))
}
}
fn main() -> io::Result<()> {
let stdout = io::stdout();
let mut stdout = stdout.lock();
enable_raw_mode()?;
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut term = Terminal::new(backend)?;
// Run the draw loop
run(&mut term)?;
disable_raw_mode()?;
execute!(term.backend_mut(), LeaveAlternateScreen)?;
term.show_cursor()?;
Ok(())
}
fn run<B>(term: &mut Terminal<B>) -> io::Result<()>
where
B: ratatui::backend::Backend,
{
// vec![
// "feat ✨".to_string(),
// "fix".to_string(),
// "chore".to_string(),
// "docs".to_string(),
// "style".to_string(),
// "refactor".to_string(),
// "perf".to_string(),
// "test".to_string(),
// "build".to_string(),
// "ci".to_string(),
// "revert".to_string(),
// "release".to_string(),
// "wip".to_string(),
// ]
let items = vec![
Item::new("feat", ""),
Item::new("fix", "🐛"),
Item::new("chore", "🧹"),
Item::new("docs", "📚"),
Item::new("style", "💅"),
Item::new("refactor", "🔨"),
Item::new("perf", "🐎"),
Item::new("test", "🚨"),
Item::new("build", "👷"),
Item::new("ci", "🔧"),
Item::new("revert", ""),
Item::new("release", "🚀"),
Item::new("wip", "🚧"),
];
let mut grid_state = GridSelectorState::new(items).columns(5);
loop {
term.draw(|f| draw(f, f.area(), &mut grid_state))?;
match event::read()? {
Event::Key(key) => match key {
KeyEvent {
code: KeyCode::Esc, ..
} => break,
KeyEvent {
code: KeyCode::Right,
kind: KeyEventKind::Press,
..
} => grid_state.move_right(),
KeyEvent {
code: KeyCode::Left,
kind: KeyEventKind::Press,
..
} => grid_state.move_left(),
KeyEvent {
code: KeyCode::Up,
kind: KeyEventKind::Press,
..
} => grid_state.move_up(),
KeyEvent {
code: KeyCode::Down,
kind: KeyEventKind::Press,
..
} => grid_state.move_down(),
KeyEvent {
code: KeyCode::Home,
kind: KeyEventKind::Press,
..
} => grid_state.move_to_row_start(),
KeyEvent {
code: KeyCode::End,
kind: KeyEventKind::Press,
..
} => grid_state.move_to_row_end(),
// selection
KeyEvent {
code: KeyCode::Enter,
kind: KeyEventKind::Press,
..
} => grid_state.select(),
_ => false,
},
_ => false,
};
}
Ok(())
}
fn draw(f: &mut Frame<'_>, area: Rect, state: &mut GridSelectorState) {
// vertical layout with 2 rows
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0),
Constraint::Length(2),
Constraint::Length(1),
Constraint::Length(2),
])
.split(area);
f.render_stateful_widget(
GridSelector::default().with_selected_color(Color::Magenta),
layout[0],
state,
);
let selected = state.selected().unwrap_or(GridItem::new("None"));
let hovered = state.hovered().unwrap_or(GridItem::new("None"));
let ps = Paragraph::new(selected)
.alignment(Alignment::Center)
.alignment(Alignment::Center)
.style(Style::default().fg(Color::Magenta));
let ph = Paragraph::new(hovered)
.alignment(Alignment::Center)
.style(Style::default().fg(Color::Blue));
f.render_widget(
Paragraph::new("Hovered/Selected")
.alignment(Alignment::Center)
.add_modifier(Modifier::BOLD),
layout[1],
);
f.render_widget(ph, layout[2]);
f.render_widget(ps, layout[3]);
}

80
examples/widget_switch.rs Normal file
View File

@ -0,0 +1,80 @@
use {
lool::tui::{
ratatui::{
backend::CrosstermBackend,
crossterm::{
event::{self},
execute,
terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
},
},
layout::{Constraint, Layout},
Terminal,
},
widgets::{
switch::Switch,
textarea::{Input, Key},
},
},
ratatui::layout::Flex,
std::io,
};
fn main() -> io::Result<()> {
let stdout = io::stdout();
let mut stdout = stdout.lock();
enable_raw_mode()?;
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut term = Terminal::new(backend)?;
let mut switch_state = false;
loop {
term.draw(|f| {
// Render the textarea
let switch =
Switch::with_status(switch_state).with_color_on(ratatui::style::Color::Blue);
let [horiz] = Layout::horizontal([Constraint::Percentage(100)])
.flex(Flex::Center)
.areas(f.area());
let [verti] = Layout::vertical([Constraint::Length(2)]).flex(Flex::Center).areas(horiz);
let [centered] =
Layout::horizontal([Constraint::Length(14)]).flex(Flex::Center).areas(verti);
f.render_widget(switch, centered);
})?;
match event::read()?.into() {
Input { key: Key::Esc, .. }
| Input {
key: Key::Char('c'),
shift: false,
ctrl: true,
alt: false,
} => break,
Input {
key: Key::Enter,
ctrl: false,
shift: false,
alt: false,
}
| Input {
key: Key::Char(' '),
..
} => {
switch_state = !switch_state;
}
_ => {}
}
}
disable_raw_mode()?;
execute!(term.backend_mut(), LeaveAlternateScreen)?;
term.show_cursor()?;
Ok(())
}

View File

@ -0,0 +1,77 @@
use {
lool::tui::{
ratatui::{
backend::CrosstermBackend,
crossterm::{
event::{self},
execute,
terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
},
},
layout::{Constraint, Direction, Layout},
Terminal,
},
widgets::textarea::{Input, Key, TextArea},
},
ratatui::widgets::{Block, BorderType, Borders, Padding, Paragraph},
std::{cmp, io},
};
fn main() -> io::Result<()> {
let stdout = io::stdout();
let mut stdout = stdout.lock();
enable_raw_mode()?;
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut term = Terminal::new(backend)?;
let mut textarea = TextArea::default().with_block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(" Your name ")
.padding(Padding::horizontal(1)),
);
loop {
term.draw(|f| {
const MIN_HEIGHT: usize = 1;
let height = cmp::max(textarea.lines().len(), MIN_HEIGHT) as u16 + 2;
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(height), Constraint::Min(1)])
.split(f.area());
// Render the textarea
f.render_widget(&textarea, layout[0]);
f.render_widget(Paragraph::new("Press <Esc> to exit"), layout[1]);
})?;
match event::read()?.into() {
Input { key: Key::Esc, .. }
| Input {
key: Key::Char('c'),
shift: false,
ctrl: true,
alt: false,
}
| Input {
key: Key::Enter,
ctrl: false,
shift: false,
alt: false,
} => break,
input => {
textarea.input(input);
}
}
}
disable_raw_mode()?;
execute!(term.backend_mut(), LeaveAlternateScreen)?;
term.show_cursor()?;
println!("Lines: {:?}", textarea.lines());
Ok(())
}

View File

@ -19,6 +19,237 @@ This crate is for internal use. It's only published privately.
cargo add lool --registry=lugit --features cli cli.tui
```
# Usage
# `lool::cli::tui` Framework
Pending.
This module provides a small framework for building async terminal user interfaces (TUIs) using a
component-based architecture, using the `ratatui` library.
This module defines two primary elements:
- the `App` struct,
- and the `Component` trait.
Together, these elements facilitate the creation of modular and interactive terminal applications.
## Framework
### `App` Struct
The `App` struct represents the main application and is responsible of (among other things):
- **Tick Rate and Frame Rate**: Controls the update frequency of the application.
- **Component Management**: Manages a collection of components that make up the user interface.
- **Event Handling**: Processes user inputs and dispatches actions to the appropriate components.
- **Lifecycle Management**: Handles the start, suspension, and termination of the application.
### `Component` Trait
The `Component` trait represents a visual and interactive element of the user interface.
Components can be nested, allowing for a hierarchical structure where each component can have child
components. This trait provides several methods for handling events, updating state, and rendering:
- **Event Handling**: Methods like `handle_frame_event` and `handle_paste_event` allow components
to respond to different types of events.
- **State Management**: Methods like `update` and `receive_message` enable components to update
their state based on actions or messages.
- **Initialization**: The `init` method allows components to perform setup tasks when they are first
created.
- **Rendering**: The `draw` method is responsible for rendering the component within a specified
area. All components must implement this method to display their content.
## How It Works
### Component-Based Architecture
The framework uses a component-based architecture, where the user interface is composed of multiple
components. Each component can have child components, forming a tree-like structure. This design
promotes modularity and reusability, making it easier to manage complex user interfaces in a
structured and standardized way.
### Interaction Between `App` and `Component`
- **Initialization**: The `App` initializes all components and sets up the necessary event channels.
- **Event Dispatching**: The `App` listens for user inputs and dispatches actions to the relevant
components.
- **State Updates**: Components update their state based on the actions they receive and can
propagate these updates to their child components.
- **Rendering**: Components handle their own rendering logic, allowing for a flexible and
customizable user interface.
Usually, the `App` is provided with a root component that represents the main component of the
application.
From the Main/Root component, the application can be built by nesting child components as needed in
a tree-like structure. Example:
```txt
App
└── RootComponent
└── Router
├── Home
│ ├── Header
│ └── Content
├── About
│ ├── Header
│ └── Content
└── Contact
├── Header
└── ContactForm
```
In this example, the `RootComponent` is the main component of the application and contains a
`Router`, which is another component that manages the routing logic. The `Router` component has
three child components: `Home`, `About`, and `Contact` and will render the appropriate component
depending on the current route.
Then, heach "route" component (`Home`, `About`, `Contact`) can have its own child components, such
as `Header`, `Content`, and `ContactForm` and use them to build the final user interface.
The `RootComponent` will call the `draw` method of the `Router` component, which will in turn call
the `draw` method of the current route component (`Home`, `About`, or `Contact`), and so on.
The `draw` chain will propagate down the component tree, allowing each component to render its
content. The `App` starts the draw chain a few times per second. The amount of draw calls per second
is controlled by the `frame_rate` of the `App`:
```rust
let mut app = App::new(...).frame_rate(24); // 24 frames per second
```
Some tasks might be too expensive to be performed on every frame. In these cases, the `App` alsp
defines a `tick_rate` that controls how often the `handle_tick_event` method of the components is
called.
The tick event is often used to update the state of the components, while the frame event is used to
render the components in the terminal.
For example, a tick rate of 1 means that the `handle_tick_event` method of the components will be
called once per second. And a component might use this event to update its state, run background
tasks, or perform other operations that don't need to be done on every frame.
```rust
let mut app = App::new(...).tick_rate(10); // 10 ticks per second
```
### Component Communication
Components can communicate with each other using messages. The `Component` trait defines the
following methods:
- `register_action_handler`: registers a mpsc sender, to send messages to the bus.
- `receive_message`: receives a message from the bus.
At the start of the application, the `App` will call the `register_action_handler` method of each
component, and they can store the sender to send messages to the bus.
When a component wants to send a message to another component, it can use the sender it received
during the registration process.
```rust
self.bus.send("an:action".to_string());
```
For simplicity, the messages are just strings, but one can serialize more complex data structures
into strings if needed in any format like JSON, TOML or even a custom format that just suits the
our needs:
```rust
self.bus.send(format!("task:{}:state='{}',date='{}'", task_id, state, date));
```
Then we can receive the message in the `receive_message` method of another component:
```rust
fn receive_message(&mut self, message: String) {
if message.starts_with("task:") {
// 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:
- <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,
]);
```
## `GridSelector` Widget
A selector restful widget that can be used to select items from a list. The items are displayed in a
grid, and the user can navigate through them using, for example, the arrow keys.
### Example
See the [`widget_grid_selector.rs`](/examples/widget_grid_selector.rs) example for a full
demonstration of how to use the `GridSelector` widget.
In summary, the widget uses a `GridSelectorState` to keep track of the items and the state of the
widget.
The `GridSelectorState` takes a list of `GridItem`, which is a basic struct that encapsulates a
`String` value.
The `GridSelectorState::new` method accepts a list of any type that can be converted to a
`GridItem`:
```rust
pub fn new<I, T>(items: I) -> Self
where
I: IntoIterator<Item = T>,
T: Into<GridItem>, // Accept anything that can be converted into GridItem
{
...
}
}
```
As a part of this library, the `Into<GridItem>` trait is implemented for `String`, `&str`.
The example at [`widget_grid_selector.rs`](/examples/widget_grid_selector.rs) demonstrates how to
implement the `Into<GridItem>` trait for a custom type.
## `Switch` Widget
A simple stateless switch widget that can be used to show visual feedback of a boolean state.
### Example
See the [`widget_switch.rs`](/examples/widget_switch.rs) example for a full demonstration of how to
use the `Switch` widget.

View File

@ -261,6 +261,7 @@ pub trait Component: Downcast {
///
/// # Returns
/// * `Option<&Box<dyn Component>>` - A reference to the child component or none.
#[allow(clippy::borrowed_box)]
fn child(&mut self, name: &str) -> Option<&Box<dyn Component>> {
if let Some(children) = self.get_children() {
children.get(name)

View File

@ -28,6 +28,14 @@ fn io() -> IO {
}
pub type Frame<'a> = ratatui::Frame<'a>;
/// The Tui struct represents a terminal user interface.
///
/// It encapsulates [ratatui::Terminal] adding extra functionality:
/// - [Tui::start] and [Tui::stop] to start and stop the event loop
/// - [Tui::enter] and [Tui::exit] to enter and exit the crossterm terminal
/// [raw mode](https://docs.rs/crossterm/0.28.1/crossterm/terminal/index.html#raw-mode)
/// - Mapping of crossterm events to [Event]s
/// - Emits [Event::Tick] and [Event::Render] events at a specified rate
pub struct Tui {
pub terminal: ratatui::Terminal<Backend<IO>>,
pub task: JoinHandle<()>,
@ -63,26 +71,43 @@ impl Tui {
})
}
/// Sets the tick rate for the Tui. The tick rate is the number of times per second that the
/// Tui will emit a [Event::Tick] event. The default tick rate is 4 ticks per second.
///
/// The tick is different from the render rate, which is the number of times per second that
/// the application will be drawn to the screen. The tick rate is useful for updating the
/// application state, performing calculations, run background tasks, and other operations that
/// do not require a per-frame operation.
///
/// Tick rate will usually be lower than the frame rate.
pub fn tick_rate(mut self, tick_rate: f64) -> Self {
self.tick_rate = tick_rate;
self
}
/// Sets the frame rate for the Tui. The frame rate is the number of times per second that the
/// Tui will emit a [Event::Render] event. The default frame rate is 60 frames per second.
///
/// The frame rate is the rate at which the application will be drawn to the screen (by calling
/// the `draw` method of each component).
pub fn frame_rate(mut self, frame_rate: f64) -> Self {
self.frame_rate = frame_rate;
self
}
/// Sets whether the Tui should capture mouse events. The default is false.
pub fn mouse(mut self, mouse: bool) -> Self {
self.mouse = mouse;
self
}
/// Sets whether the Tui should capture paste events. The default is false.
pub fn paste(mut self, paste: bool) -> Self {
self.paste = paste;
self
}
/// Starts the Tui event loop.
pub fn start(&mut self) {
let tick_delay = std::time::Duration::from_secs_f64(1.0 / self.tick_rate);
let render_delay = std::time::Duration::from_secs_f64(1.0 / self.frame_rate);
@ -146,6 +171,7 @@ impl Tui {
});
}
/// Stops the Tui event loop.
pub fn stop(&self) -> Result<()> {
self.cancel();
let mut counter = 0;
@ -163,6 +189,7 @@ impl Tui {
Ok(())
}
/// Enables cross-term raw mode and enters the alternate screen.
pub fn enter(&mut self) -> Result<()> {
crossterm::terminal::enable_raw_mode()?;
crossterm::execute!(io(), EnterAlternateScreen, cursor::Hide)?;
@ -176,6 +203,7 @@ impl Tui {
Ok(())
}
/// Disables cross-term raw mode and exits the alternate screen.
pub fn exit(&mut self) -> Result<()> {
self.stop()?;
if crossterm::terminal::is_raw_mode_enabled()? {
@ -205,6 +233,7 @@ impl Tui {
Ok(())
}
/// Returns the next event from the event channel.
pub async fn next(&mut self) -> Option<Event> {
self.event_rx.recv().await
}
@ -214,18 +243,21 @@ impl Deref for Tui {
type Target = ratatui::Terminal<Backend<IO>>;
fn deref(&self) -> &Self::Target {
// deref Tui as Terminal
&self.terminal
}
}
impl DerefMut for Tui {
fn deref_mut(&mut self) -> &mut Self::Target {
// deref Tui as Terminal mutably
&mut self.terminal
}
}
impl Drop for Tui {
fn drop(&mut self) {
// Ensure that the terminal is cleaned up when the Tui is dropped
self.exit().unwrap();
}
}

View File

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

View File

@ -0,0 +1,68 @@
//! {`GridSelector`} widget
//!
//! This module contains the [`BoxSelector`] ratatui widget.
//!
//! The grid selector is a stateful widget that allows the user to select an item from a list of
//! items displayed in a grid. The user can navigate the grid with the arrow keys, and select the
//! currently hovered item with the `Enter` key.
//!
//! The [`GridSelector`] widget uses a [`GridSelectorState`] to be able to keep its state between
//! renders.
use {super::GridSelectorState, ratatui::style::Color};
pub struct GridSelector {
color: Color,
hovered_color: Color,
selected_color: Color,
}
impl Default for GridSelector {
fn default() -> Self {
Self {
color: Color::Reset,
hovered_color: Color::Blue,
selected_color: Color::Green,
}
}
}
// imlementation of the build pattern for the GridSelector to set the colors
impl GridSelector {
/// Set the color of the items in the grid.
pub fn with_color(mut self, color: Color) -> Self {
self.color = color;
self
}
/// Set the color of the hovered item in the grid.
pub fn with_hovered_color(mut self, color: Color) -> Self {
self.hovered_color = color;
self
}
/// Set the color of the selected item in the grid.
pub fn with_selected_color(mut self, color: Color) -> Self {
self.selected_color = color;
self
}
pub(crate) fn get_color(&self, for_idx: usize, state: &GridSelectorState) -> Color {
let mut color = self.color;
if let Some(hovered_idx) = state.hovered {
if for_idx == hovered_idx {
color = self.hovered_color;
}
}
if let Some(selected_index) = state.selected {
if for_idx == selected_index {
color = self.selected_color;
}
}
color
}
}

View File

@ -0,0 +1,272 @@
//! # Grid Selector State
//!
//! This module contains the [`GridSelectorState`] for the `GridSelector` widget.
//!
//! The state is used to keep track of the items, the selected item, and the hovered item and
//! encapsulates the navigation logic for the grid selector.
use ratatui::text::Text;
// If Text has a lifetime parameter, specify it in the implementation.
#[derive(Clone, Debug)]
pub struct GridItem(String);
impl GridItem {
// accept both String and &str
pub fn new<S>(value: S) -> Self
where
S: Into<String>,
{
GridItem(value.into())
}
}
// convert Label into &str
impl AsRef<str> for GridItem {
fn as_ref(&self) -> &str {
&self.0
}
}
// convert Label into String
impl From<GridItem> for String {
fn from(val: GridItem) -> Self {
val.0.clone()
}
}
// Specify the lifetime for the implementation
impl<'a> From<GridItem> for Text<'a> {
fn from(val: GridItem) -> Self {
Text::from(val.0)
}
}
// implement a way to convert String into Label
impl From<String> for GridItem {
fn from(value: String) -> Self {
GridItem(value)
}
}
// implement a way to convert &str into Label
impl<'a> From<&'a str> for GridItem {
fn from(value: &'a str) -> Self {
GridItem(value.to_string())
}
}
/// State for the [`GridSelector`] widget.
///
/// This state is used to keep track of the items, the selected item, and the hovered item.
#[derive(Debug, Clone)]
pub struct GridSelectorState {
pub items: Vec<GridItem>,
pub selected: Option<usize>,
pub hovered: Option<usize>,
pub(crate) columns: usize,
}
impl GridSelectorState {
/// Create a new [`GridSelectorState`] with the given items.
pub fn new<I, T>(items: I) -> Self
where
I: IntoIterator<Item = T>,
T: Into<GridItem>, // Accept anything that can be converted into Label
{
let items: Vec<GridItem> = items.into_iter().map(Into::into).collect();
Self {
items,
selected: None,
hovered: Some(0),
columns: 5,
}
}
/// builder method to set the number of columns
pub fn columns(mut self, columns: usize) -> Self {
self.columns = columns;
self
}
/// Get the selected item.
pub fn selected(&self) -> Option<GridItem> {
self.selected.map(|i| self.items[i].clone())
}
/// Get the index of the selected item.
pub fn selected_index(&self) -> Option<usize> {
self.selected
}
/// Get the hovered item.
pub fn hovered(&self) -> Option<GridItem> {
self.hovered.map(|i| self.items[i].clone())
}
/// Move the hovered item right +1
///
/// Returns `true` if the hovered item was moved, `false` otherwise.
pub fn move_right(&mut self) -> bool {
self.hovered = if let Some(hovered) = self.hovered {
let next = hovered + 1;
if next < self.items.len() {
Some(next)
} else {
Some(0)
}
} else {
Some(0)
};
true
}
/// Move the hovered item left -1
///
/// Returns `true` if the hovered item was moved,
/// `false` otherwise.
pub fn move_left(&mut self) -> bool {
self.hovered = if let Some(hovered) = self.hovered {
if hovered > 0 {
Some(hovered - 1)
} else {
Some(self.items.len() - 1)
}
} else {
Some(0)
};
true
}
/// Move the hovered item down by one row.
///
/// Returns `true` if the hovered item was moved, `false` otherwise.
pub fn move_down(&mut self) -> bool {
if let Some(hovered) = self.hovered {
let items_per_row = self.columns;
let num_items = self.items.len();
let current_row = hovered / items_per_row;
let next_row_start = (current_row + 1) * items_per_row;
let last_item_index = num_items - 1;
// If we are in the last row, we can't go down
if next_row_start > last_item_index {
return false;
}
let mut next_index = std::cmp::min(hovered + items_per_row, last_item_index);
// Handle the case where the next row has fewer items
let next_row_count = std::cmp::min(items_per_row, last_item_index - next_row_start + 1);
// check if both the next_row_count and the self.columns are odd numbers (3,5,7, etc)
// and next_row_count is less than self.columns
if next_row_count % 2 != 0 && items_per_row % 2 != 0 && next_row_count < items_per_row {
let shift = (items_per_row - next_row_count) / 2;
// if we are in the shifted range (left or right) of the row, adjust the hovered
// index accordingly
if hovered % items_per_row >= shift
&& hovered % items_per_row < items_per_row - shift
{
next_index = hovered + items_per_row - shift;
} else {
// if we are in the left part of the shifted range, set next to the first item
// in the last row and if we are in the right part of the shifted range, set
// next to the last item in the last row
next_index = if hovered % items_per_row < shift {
next_row_start
} else {
last_item_index
};
}
}
self.hovered = Some(std::cmp::min(next_index, last_item_index));
return true;
}
false
}
/// Move the hovered item up by one row.
///
/// Returns `true` if the hovered item was moved, `false` otherwise.
pub fn move_up(&mut self) -> bool {
if let Some(hovered) = self.hovered {
let row_number = hovered / self.columns;
// If we are in the first row, we can't go up
if row_number == 0 {
return false;
}
let mut next_index = hovered.saturating_sub(self.columns);
// Handle case where the current index is in the last row
// let is_last_row = hovered >= self.items.len().saturating_sub(self.columns);
let last_row_start = (self.items.len() / self.columns) * self.columns;
let is_last_row = hovered >= last_row_start;
if is_last_row {
let last_row_count = self.items.len() % self.columns;
// If the last_row_count and self.columns are odd numbers (3,5,7, etc)
// and last_row_count is less than self.columns we need to adjust the next index
// to go to the cell just above the current hovered cell
if last_row_count % 2 != 0 && self.columns % 2 != 0 && last_row_count < self.columns
{
let shift = (self.columns - last_row_count) / 2;
next_index = hovered.saturating_sub(self.columns - shift);
}
}
// Ensure next_index stays within bounds
self.hovered = Some(std::cmp::min(next_index, self.items.len() - 1));
return true;
}
false
}
/// Move the hovered item to the first item in the current row.
///
/// Returns `true` if the hovered item was moved, `false` otherwise.
pub fn move_to_row_start(&mut self) -> bool {
if let Some(hovered) = self.hovered {
let row_start = (hovered / self.columns) * self.columns;
self.hovered = Some(row_start);
true
} else {
false
}
}
/// Move the hovered item to the last item in the current row.
///
/// Returns `true` if the hovered item was moved, `false` otherwise.
pub fn move_to_row_end(&mut self) -> bool {
if let Some(hovered) = self.hovered {
let row_end = std::cmp::min(
(hovered / self.columns + 1) * self.columns - 1,
self.items.len() - 1,
);
self.hovered = Some(row_end);
true
} else {
false
}
}
/// Select the hovered item.
///
/// Select the hovered item. Returns `true` if the hovered item was selected, `false` otherwise.
pub fn select(&mut self) -> bool {
if let Some(hovered) = self.hovered {
self.selected = Some(hovered);
true
} else {
false
}
}
}

View File

@ -0,0 +1,63 @@
use {
super::{GridSelector, GridSelectorState},
ratatui::{
buffer::Buffer,
layout::{Alignment, Constraint, Direction, Flex, Layout, Rect},
style::Style,
widgets::{Block, Borders, Paragraph, StatefulWidget, Widget},
},
std::rc::Rc,
unicode_width::UnicodeWidthStr,
};
impl StatefulWidget for GridSelector {
type State = GridSelectorState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut GridSelectorState) {
let rows_layout = rows_layout(state, area);
let largest_item = largest_item(state);
for (i, row) in rows_layout.iter().enumerate() {
let row_items = state.items.iter().skip(i * state.columns).take(state.columns);
let columns_layout = columns_layout(row, row_items.len(), largest_item);
for (j, item) in row_items.enumerate() {
let main_index = i * state.columns + j;
let color = self.get_color(main_index, state);
let type_block =
Block::default().borders(Borders::ALL).border_style(Style::default().fg(color));
Paragraph::new(item.clone())
.style(Style::default().fg(color))
.alignment(Alignment::Left)
.block(type_block)
.render(columns_layout[j], buf);
}
}
}
}
fn rows_layout(state: &GridSelectorState, area: Rect) -> Rc<[Rect]> {
let row_count = (state.items.len() as f32 / state.columns as f32).ceil() as usize;
Layout::default()
.direction(Direction::Vertical)
.constraints((0..row_count).map(|_| Constraint::Length(3)).collect::<Vec<_>>())
.spacing(0)
.split(area)
}
fn columns_layout(row: &Rect, row_item_count: usize, largest_item: u16) -> Rc<[Rect]> {
Layout::default()
.direction(Direction::Horizontal)
.flex(Flex::Center)
.constraints(
(0..row_item_count).map(|_| Constraint::Length(largest_item + 3)).collect::<Vec<_>>(),
)
.split(*row)
}
fn largest_item(state: &GridSelectorState) -> u16 {
state.items.iter().map(|item| UnicodeWidthStr::width(item.as_ref())).max().unwrap_or(0) as u16
}

View File

@ -0,0 +1,88 @@
use ratatui::{
buffer::Buffer,
layout::{Constraint, Layout, Rect},
style::{Color, Style},
widgets::{Block, Widget},
};
/// A switch widget
///
/// This widget is used to show visual confirmation of a boolean state
pub struct Switch {
/// The state of the switch
state: bool,
/// The color of the "on" state (`Green` by default)
color_on: Color,
/// The color of the "off" state (`DarkGray` by default)
color_off: Color,
/// The color of the switch itself (`White` by default)
color_switch: Color,
}
impl Switch {
pub fn with_status(state: bool) -> Self {
Switch {
state,
color_on: Color::Green,
color_off: Color::DarkGray,
color_switch: Color::White,
}
}
pub fn with_color_on(mut self, color: Color) -> Self {
self.color_on = color;
self
}
pub fn with_color_off(mut self, color: Color) -> Self {
self.color_off = color;
self
}
pub fn with_color_switch(mut self, color: Color) -> Self {
self.color_switch = color;
self
}
pub fn get_layout(&self, area: Rect) -> (Rect, Rect) {
let main = Layout::default()
.direction(ratatui::layout::Direction::Vertical)
.constraints([Constraint::Length(2)])
.split(area);
let layout = Layout::default()
.direction(ratatui::layout::Direction::Horizontal)
.constraints([Constraint::Length(7), Constraint::Length(7)])
.split(main[0]);
(layout[0], layout[1])
}
fn get_left_color(&self) -> Color {
if self.state {
self.color_on
} else {
self.color_switch
}
}
fn get_right_color(&self) -> Color {
if self.state {
self.color_switch
} else {
self.color_off
}
}
}
impl Widget for Switch {
fn render(self, area: Rect, buf: &mut Buffer) {
let (left, right) = self.get_layout(area);
let left_block = Block::default().style(Style::default().bg(self.get_left_color()));
let right_block = Block::default().style(Style::default().bg(self.get_right_color()));
left_block.render(left, buf);
right_block.render(right, buf);
}
}

View File

@ -0,0 +1,102 @@
use {
super::util::{find_word_start_backward, find_word_start_forward},
crate::tui::widgets::textarea::core::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,383 @@
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, Default)]
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))
#[default]
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),
_ => 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 {
matches!(
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,
..
}
)
}
/// 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 {
self.key == Key::Tab && !self.ctrl && !self.alt
}
/// Returns `true` if the Input is Backspace
#[inline]
pub fn is_backspace(&self) -> bool {
self.key == Key::Backspace && !self.ctrl && !self.alt
}
/// Returns `true` if the Input is Delete
#[inline]
pub fn is_delete(&self) -> bool {
self.key == Key::Delete && !self.ctrl && !self.alt
}
/// Returns `true` if the Input is key down arrow
#[inline]
pub fn is_down(&self) -> bool {
self.key == Key::Down && !self.ctrl && !self.alt
}
/// Returns `true` if the Input is key up arrow
#[inline]
pub fn is_up(&self) -> bool {
self.key == Key::Up && !self.ctrl && !self.alt
}
/// Returns `true` if the Input is key left arrow
#[inline]
pub fn is_left(&self) -> bool {
self.key == Key::Left && !self.ctrl && !self.alt
}
/// Returns `true` if the Input is key right arrow
#[inline]
pub fn is_right(&self) -> bool {
self.key == Key::Right && !self.ctrl && !self.alt
}
/// Returns `true` if the Input is key Home
#[inline]
pub fn is_home(&self) -> bool {
self.key == Key::Home
}
/// Returns `true` if the Input is key End
#[inline]
pub fn is_end(&self) -> bool {
self.key == Key::End
}
/// Returns `true` if the Input is ctrl+left
#[inline]
pub fn is_ctrl_left(&self) -> bool {
self.key == Key::Left && self.ctrl && !self.alt
}
/// Returns `true` if the Input is ctrl+right
#[inline]
pub fn is_ctrl_right(&self) -> bool {
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::core::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_some(0)
}

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,514 @@
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()
}
}

View File

@ -0,0 +1,58 @@
pub mod validators;
use {super::TextArea, std::sync::Arc};
pub enum ValidationResult {
Valid,
Invalid(Vec<String>),
}
type ValidatorFnType = Arc<dyn Fn(&str) -> Result<(), String> + Send + Sync>;
#[derive(Clone)]
pub struct ValidatorFn(ValidatorFnType);
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("This field is required".to_string())
} 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);
}
}

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 core;
pub use {
behaviour::input::{Input, Key},
core::{
validation::{validators, ValidationResult},
TextArea,
},
};