From 2644f68cab8eff601cf076930d8df8e0722950dc Mon Sep 17 00:00:00 2001 From: Lucas Colombo Date: Fri, 20 Sep 2024 20:47:30 -0300 Subject: [PATCH] =?UTF-8?q?feat(cli):=20=E2=9C=A8=20grid=20selector=20widg?= =?UTF-8?q?et?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.toml | 15 ++ Taskfile.yaml | 5 + examples/widget_grid_selector.rs | 187 +++++++++++++ lib/cli/tui/README.md | 37 ++- lib/cli/tui/mod.rs | 8 + lib/cli/tui/widgets/gridselector/selector.rs | 68 +++++ lib/cli/tui/widgets/gridselector/state.rs | 264 +++++++++++++++++++ lib/cli/tui/widgets/gridselector/widget.rs | 63 +++++ 8 files changed, 646 insertions(+), 1 deletion(-) create mode 100644 examples/widget_grid_selector.rs create mode 100644 lib/cli/tui/widgets/gridselector/selector.rs create mode 100644 lib/cli/tui/widgets/gridselector/state.rs create mode 100644 lib/cli/tui/widgets/gridselector/widget.rs diff --git a/Cargo.toml b/Cargo.toml index 02dc0ea..ae8e299 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,6 +64,11 @@ path = "lib/lib.rs" "utils" = [] "utils.threads" = ["utils", "macros", "dep:log"] +# tokio +"tokio.rt" = [ + "tokio?/rt" +] + [dependencies] # default @@ -95,3 +100,13 @@ 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"] \ No newline at end of file diff --git a/Taskfile.yaml b/Taskfile.yaml index 572212a..4460995 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -29,6 +29,11 @@ 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" + fmt: desc: ๐ŸŽจ format lool cmds: diff --git a/examples/widget_grid_selector.rs b/examples/widget_grid_selector.rs new file mode 100644 index 0000000..e8a1652 --- /dev/null +++ b/examples/widget_grid_selector.rs @@ -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 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(term: &mut Terminal) -> 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]); +} diff --git a/lib/cli/tui/README.md b/lib/cli/tui/README.md index dc12938..99dcdf6 100644 --- a/lib/cli/tui/README.md +++ b/lib/cli/tui/README.md @@ -208,4 +208,39 @@ textarea..with_validations(vec![ }, required_validator, ]); -``` \ No newline at end of file +``` + +## `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(items: I) -> Self + where + I: IntoIterator, + T: Into, // Accept anything that can be converted into GridItem + { + ... + } +} +``` + +As a part of this library, the `Into` trait is implemented for `String`, `&str`. + +The example at [`widget_grid_selector.rs`](/examples/widget_grid_selector.rs) demonstrates how to +implement the `Into` trait for a custom type. diff --git a/lib/cli/tui/mod.rs b/lib/cli/tui/mod.rs index aa0a686..946db2f 100644 --- a/lib/cli/tui/mod.rs +++ b/lib/cli/tui/mod.rs @@ -31,6 +31,14 @@ 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; } diff --git a/lib/cli/tui/widgets/gridselector/selector.rs b/lib/cli/tui/widgets/gridselector/selector.rs new file mode 100644 index 0000000..322f652 --- /dev/null +++ b/lib/cli/tui/widgets/gridselector/selector.rs @@ -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 + } +} diff --git a/lib/cli/tui/widgets/gridselector/state.rs b/lib/cli/tui/widgets/gridselector/state.rs new file mode 100644 index 0000000..6fcd8e4 --- /dev/null +++ b/lib/cli/tui/widgets/gridselector/state.rs @@ -0,0 +1,264 @@ +//! # 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 { + // pub fn new(value: String) -> Self { + // Label(value) + // } + + // accept both String and &str + pub fn new(value: S) -> Self + where + S: Into, + { + GridItem(value.into()) + } +} + +// implement a way to convert Label into &str +impl AsRef for GridItem { + fn as_ref(&self) -> &str { + &self.0 + } +} + +// Specify the lifetime for the implementation +impl<'a> From for Text<'a> { + fn from(val: GridItem) -> Self { + Text::from(val.0) + } +} + +// implement a way to convert String into Label +impl From 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, + pub selected: Option, + pub hovered: Option, + pub(crate) columns: usize, +} + +impl GridSelectorState { + /// Create a new [`GridSelectorState`] with the given items. + pub fn new(items: I) -> Self + where + I: IntoIterator, + T: Into, // Accept anything that can be converted into Label + { + let items: Vec = 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 { + self.selected.map(|i| self.items[i].clone()) + } + + /// Get the hovered item. + pub fn hovered(&self) -> Option { + 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 + } + } +} diff --git a/lib/cli/tui/widgets/gridselector/widget.rs b/lib/cli/tui/widgets/gridselector/widget.rs new file mode 100644 index 0000000..c4b2319 --- /dev/null +++ b/lib/cli/tui/widgets/gridselector/widget.rs @@ -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::>()) + .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::>(), + ) + .split(*row) +} + +fn largest_item(state: &GridSelectorState) -> u16 { + state.items.iter().map(|item| UnicodeWidthStr::width(item.as_ref())).max().unwrap_or(0) as u16 +}