Compare commits

...

11 Commits

26 changed files with 1045 additions and 96 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"]

122
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.7.0"
version = "0.9.0"
dependencies = [
"bitflags",
"chrono",
@ -468,7 +474,7 @@ dependencies = [
"num-traits",
"palette",
"ratatui",
"strum",
"strum 0.27.1",
"tokio",
"tokio-util",
"unicode-width 0.2.0",
@ -677,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 0.1.13",
"unicode-width 0.2.0",
]
[[package]]
@ -805,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]]
@ -821,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"
@ -834,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",
@ -845,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",
@ -856,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",
@ -999,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.7.0"
version = "0.9.0"
edition = "2021"
description = "🧳 lool » lucode.ar rust common utilities"
authors = ["Lucas Colombo <lucas@lucode.ar>"]
@ -64,26 +64,31 @@ 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]]
@ -95,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

@ -209,3 +209,47 @@ textarea..with_validations(vec![
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

@ -31,7 +31,20 @@ 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

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

@ -1,6 +1,6 @@
use {
super::util::{find_word_start_backward, find_word_start_forward},
crate::tui::widgets::textarea::textarea::widget::Viewport,
crate::tui::widgets::textarea::core::widget::Viewport,
std::cmp,
};

View File

@ -3,7 +3,7 @@ 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)]
#[derive(Clone, Copy, Debug, PartialEq, Hash, Eq, Default)]
pub enum Key {
/// Normal letter key input
Char(char),
@ -38,15 +38,10 @@ pub enum Key {
/// 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,
}
impl Default for Key {
fn default() -> Self {
Key::Null
}
}
/// Backend-agnostic key input type.
///
/// When `crossterm`, `termion`, `termwiz` features are enabled, converting respective key input types into this
@ -104,7 +99,7 @@ impl Input {
ctrl: false,
alt: false,
..
} => Some(c.clone()),
} => Some(*c),
_ => None,
}
}
@ -117,30 +112,27 @@ impl Input {
/// - Char is \n or \r
#[inline]
pub fn is_newline_except_enter(&self) -> bool {
match self {
matches!(
self,
Input {
key: Key::Char('\n' | '\r'),
ctrl: false,
alt: false,
..
}
| Input {
} | Input {
key: Key::Enter,
ctrl: true,
..
}
| Input {
} | Input {
key: Key::Enter,
alt: true,
..
}
| Input {
} | Input {
key: Key::Enter,
shift: true,
..
} => true,
_ => false,
}
)
}
/// Returns `true` if the Input represents a new line (including Enter key).
@ -170,67 +162,67 @@ impl Input {
/// Returns `true` if the Input is a Tab
#[inline]
pub fn is_tab(&self) -> bool {
return self.key == Key::Tab && !self.ctrl && !self.alt;
self.key == Key::Tab && !self.ctrl && !self.alt
}
/// Returns `true` if the Input is Backspace
#[inline]
pub fn is_backspace(&self) -> bool {
return self.key == Key::Backspace && !self.ctrl && !self.alt;
self.key == Key::Backspace && !self.ctrl && !self.alt
}
/// Returns `true` if the Input is Delete
#[inline]
pub fn is_delete(&self) -> bool {
return self.key == Key::Delete && !self.ctrl && !self.alt;
self.key == Key::Delete && !self.ctrl && !self.alt
}
/// Returns `true` if the Input is key down arrow
#[inline]
pub fn is_down(&self) -> bool {
return self.key == Key::Down && !self.ctrl && !self.alt;
self.key == Key::Down && !self.ctrl && !self.alt
}
/// Returns `true` if the Input is key up arrow
#[inline]
pub fn is_up(&self) -> bool {
return self.key == Key::Up && !self.ctrl && !self.alt;
self.key == Key::Up && !self.ctrl && !self.alt
}
/// Returns `true` if the Input is key left arrow
#[inline]
pub fn is_left(&self) -> bool {
return self.key == Key::Left && !self.ctrl && !self.alt;
self.key == Key::Left && !self.ctrl && !self.alt
}
/// Returns `true` if the Input is key right arrow
#[inline]
pub fn is_right(&self) -> bool {
return self.key == Key::Right && !self.ctrl && !self.alt;
self.key == Key::Right && !self.ctrl && !self.alt
}
/// Returns `true` if the Input is key Home
#[inline]
pub fn is_home(&self) -> bool {
return self.key == Key::Home;
self.key == Key::Home
}
/// Returns `true` if the Input is key End
#[inline]
pub fn is_end(&self) -> bool {
return self.key == Key::End;
self.key == Key::End
}
/// Returns `true` if the Input is ctrl+left
#[inline]
pub fn is_ctrl_left(&self) -> bool {
return self.key == Key::Left && self.ctrl && !self.alt;
self.key == Key::Left && self.ctrl && !self.alt
}
/// Returns `true` if the Input is ctrl+right
#[inline]
pub fn is_ctrl_right(&self) -> bool {
return self.key == Key::Right && self.ctrl && !self.alt;
self.key == Key::Right && self.ctrl && !self.alt
}
/// Returns a string representing the kind of key input.

View File

@ -1,4 +1,4 @@
use crate::tui::widgets::textarea::textarea::widget::Viewport;
use crate::tui::widgets::textarea::core::widget::Viewport;
/// Specify how to scroll the textarea.
///

View File

@ -59,5 +59,5 @@ pub fn find_word_start_backward(line: &str, start_col: usize) -> Option<usize> {
}
cur = next;
}
(cur != CharKind::Space).then(|| 0)
(cur != CharKind::Space).then_some(0)
}

View File

@ -512,5 +512,3 @@ impl<'a> TextArea<'a> {
hl.into_spans()
}
}
// Builder-Pattern methods

View File

@ -7,8 +7,10 @@ pub enum ValidationResult {
Invalid(Vec<String>),
}
type ValidatorFnType = Arc<dyn Fn(&str) -> Result<(), String> + Send + Sync>;
#[derive(Clone)]
pub struct ValidatorFn(Arc<dyn Fn(&str) -> Result<(), String> + Send + Sync>);
pub struct ValidatorFn(ValidatorFnType);
impl ValidatorFn {
pub fn new<F>(f: F) -> Self

View File

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

View File

@ -6,11 +6,11 @@ pub(super) mod behaviour {
pub(super) mod util;
}
mod textarea;
mod core;
pub use {
behaviour::input::{Input, Key},
textarea::{
core::{
validation::{validators, ValidationResult},
TextArea,
},