From c6d8389d511743eefcfb2fc3c8d585f7a9b04c66 Mon Sep 17 00:00:00 2001 From: Silas Bartha Date: Thu, 3 Apr 2025 20:59:57 -0400 Subject: [PATCH] Dynamic widgets prototype --- Cargo.toml | 9 +- src/lib.rs | 478 ++++++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 422 insertions(+), 65 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e6f9dd4..f664d80 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,4 +14,11 @@ opt-level = 1 opt-level = 2 [dependencies.bevy_terminal_display] -version = "0.7" \ No newline at end of file +version = "0.7" + +[dependencies.ratatui] +version = "0.29" +features = ["unstable-widget-ref"] + +[dependencies] +tui-input = "0.11.1" \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 79ba6f1..18fa2a0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,38 +1,96 @@ -use std::sync::{ - atomic::{AtomicBool, Ordering}, - Arc, -}; - use bevy::prelude::*; use bevy_terminal_display::{ crossterm::event::{Event, KeyCode, KeyEventKind}, input::events::TerminalInputEvent, ratatui::{ layout::Rect, - style::{Color, Modifier, Style}, - widgets::{Block, Borders, Clear, HighlightSpacing, List, ListItem, ListState}, + style::{Color, Modifier, Style, Stylize}, + widgets::{Block, Clear, ListState, Paragraph, Widget}, }, widgets::TerminalWidget, }; +use ratatui::{ + buffer::Buffer, + layout::{Constraint, Direction, Layout}, + widgets::{Gauge, WidgetRef}, +}; +use tui_input::backend::crossterm::EventHandler; +pub use tui_input::Input; pub enum MenuControlData { Button { label: String, event: E, }, + FSlider { + label: String, + min: f32, + max: f32, + step: f32, + value: f32, + unit: Option, + event: fn(f32) -> E, + }, + ISlider { + label: String, + min: i32, + max: i32, + step: i32, + value: i32, + unit: Option, + event: fn(i32) -> E, + }, + TextField { + label: String, + value: Input, + event: fn(String) -> E, + validate: Option bool>, + }, + CheckBox { + label: String, + value: bool, + event: fn(bool) -> E, + }, + Header(String), +} + +pub struct MenuControlStyle { + pub enabled_color: Color, + pub disabled_color: Color, + pub selected_style: Style, +} + +impl Default for MenuControlStyle { + fn default() -> Self { + Self { + enabled_color: Color::White, + disabled_color: Color::DarkGray, + selected_style: Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD), + } + } } pub struct MenuControl { - pub enabled: Arc, + pub enabled: bool, + pub selected: bool, pub data: MenuControlData, + pub style: MenuControlStyle, +} + +impl Default for MenuControl { + fn default() -> Self { + Self { + enabled: true, + selected: false, + data: MenuControlData::Header("".into()), + style: MenuControlStyle::default(), + } + } } pub struct MenuWidget { pub items: Vec>, pub state: ListState, - pub enabled_color: Color, - pub disabled_color: Color, - pub selected_style: Style, pub area_function: Option Rect + Send + Sync>>, } @@ -41,11 +99,6 @@ impl Default for MenuWidget { Self { items: vec![], state: ListState::default().with_selected(Some(0)), - enabled_color: Color::White, - disabled_color: Color::Gray, - selected_style: Style::new() - .add_modifier(Modifier::UNDERLINED) - .add_modifier(Modifier::BOLD), area_function: None, } } @@ -57,34 +110,23 @@ impl TerminalWidget for MenuWidget { frame: &mut bevy_terminal_display::ratatui::Frame, rect: bevy_terminal_display::ratatui::prelude::Rect, ) { - let block = Block::new().borders(Borders::all()); - let items: Vec = self - .items - .iter() - .map(|item| { - ListItem::from(item).style(Style::new().fg( - if item.enabled.load(Ordering::Relaxed) { - self.enabled_color - } else { - self.disabled_color - }, - )) - }) - .collect(); - - let list = List::new(items) - .block(block) - .highlight_style(self.selected_style) - .highlight_symbol("> ") - .highlight_spacing(HighlightSpacing::Always); - let area = if let Some(area) = &self.area_function { area(rect) } else { rect }; frame.render_widget(Clear, area); - frame.render_stateful_widget(list, area, &mut self.state); + let outer_block = Block::bordered(); + let inner_area = outer_block.inner(area); + frame.render_widget(outer_block, area); + let mut next_area = inner_area; + for (i, item) in self.items.iter_mut().enumerate() { + item.selected = self.state.selected() == Some(i); + let height = item.height(); + next_area.height = height; + frame.render_widget_ref(item, next_area); + next_area.y += height; + } } fn handle_events( @@ -97,49 +139,357 @@ impl TerminalWidget for MenuWidget { match key_event.code { KeyCode::Enter => { if let Some(selected) = self.state.selected() { - if let Some(item) = self.items.get(selected) { - if item.enabled.load(Ordering::Relaxed) { - match &item.data { + if let Some(item) = self.items.get_mut(selected) { + if item.enabled { + match &mut item.data { MenuControlData::Button { event, .. } => { commands.send_event(event.clone()); - }, + } + MenuControlData::CheckBox { + ref mut value, + event, + .. + } => { + *value = !*value; + commands.send_event(event(*value)); + } + _ => {} + } + } + } + } + } + KeyCode::Down => { + self.state.select_next(); + if self.state.selected().unwrap() > self.items.len() - 1 { + self.state.select_previous(); + } + 'outer: { + let selected = self.state.selected().unwrap().min(self.items.len() - 1); + if let MenuControlData::Header(_) = + self.items.get(selected).unwrap().data + { + for (i, item) in self.items[selected + 1..self.items.len()] + .iter() + .enumerate() + { + let MenuControlData::Header(_) = item.data else { + self.state.select(Some(selected + 1 + i)); + break 'outer; + }; + } + self.state.select_previous(); + }; + } + } + KeyCode::Up => { + self.state.select_previous(); + 'outer: { + let selected = self.state.selected().unwrap(); + if let MenuControlData::Header(_) = + self.items.get(selected).unwrap().data + { + for (i, item) in self.items[0..selected].iter().enumerate().rev() { + let MenuControlData::Header(_) = item.data else { + self.state.select(Some(i)); + break 'outer; + }; + } + self.state.select_next(); + } + } + } + KeyCode::Left => { + if let Some(selected) = self.state.selected() { + if let Some(item) = self.items.get_mut(selected) { + if item.enabled { + match &mut item.data { + MenuControlData::FSlider { + min, + step, + value, + event, + .. + } => { + *value = (*value - *step).max(*min); + commands.send_event(event(*value)); + } + MenuControlData::ISlider { + max, + step, + value, + event, + .. + } => { + *value = (*value + *step).min(*max); + commands.send_event(event(*value)); + } + _ => {} + } + } + } + } + } + KeyCode::Right => { + if let Some(selected) = self.state.selected() { + if let Some(item) = self.items.get_mut(selected) { + if item.enabled { + match &mut item.data { + MenuControlData::FSlider { + max, + step, + value, + event, + .. + } => { + *value = (*value + *step).min(*max); + commands.send_event(event(*value)); + } + MenuControlData::ISlider { + max, + step, + value, + event, + .. + } => { + *value = (*value + *step).min(*max); + commands.send_event(event(*value)); + } + _ => {} + } + } + } + } + } + _ => { + if let Some(selected) = self.state.selected() { + if let Some(item) = self.items.get_mut(selected) { + if item.enabled { + if let MenuControlData::TextField { + value, + event: e, + validate, + .. + } = &mut item.data + { + value.handle_event(&event.0); + if let Some(validate) = validate { + if validate(value) { + commands.send_event(e(value.value().into())); + } + } } } } } } - KeyCode::Up => self.state.select_previous(), - KeyCode::Down => self.state.select_next(), - _ => (), } } } } } -impl FromIterator<(&'static str, E)> for MenuWidget { - fn from_iter>(iter: I) -> Self { - let items = iter - .into_iter() - .map(|(label, event)| MenuControl { - enabled: Arc::new(true.into()), - data: MenuControlData::Button { - label: label.into(), - event, - }, - }) - .collect(); - Self { - items, - ..Default::default() +impl MenuControl { + fn height(&self) -> u16 { + match &self.data { + MenuControlData::Button { .. } => 1, + MenuControlData::FSlider { .. } => 2, + MenuControlData::ISlider { .. } => 2, + MenuControlData::TextField { .. } => 2, + MenuControlData::CheckBox { .. } => 1, + MenuControlData::Header(_) => 1, } } } -impl From<&MenuControl> for ListItem<'_> { - fn from(value: &MenuControl) -> Self { - match &value.data { - MenuControlData::Button { label, .. } => ListItem::new(label.clone()), +pub enum MenuControlState { + None, + Default, +} + +impl WidgetRef for &mut MenuControl { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + match &self.data { + MenuControlData::Button { label, .. } => { + let mut paragraph = Paragraph::new(format!( + "{}{}", + if self.selected { "> " } else { "" }, + label.clone() + )); + let enabled = self.enabled; + if enabled { + paragraph = paragraph.fg(self.style.enabled_color); + } + if self.selected { + paragraph = paragraph.style(self.style.selected_style); + } + if !enabled { + paragraph = paragraph.fg(self.style.disabled_color); + } + paragraph.render(area, buf); + } + MenuControlData::FSlider { + label, + min, + max, + value, + unit, + .. + } => { + let mut paragraph = Paragraph::new(format!( + "{}{}", + if self.selected { "> " } else { "" }, + label.clone() + )); + let enabled = self.enabled; + if enabled { + paragraph = paragraph.fg(self.style.enabled_color); + } + if self.selected { + paragraph = paragraph.style(self.style.selected_style); + } + if !enabled { + paragraph = paragraph.fg(self.style.disabled_color); + } + let mut area = area; + area.height = 1; + paragraph.render(area, buf); + area.y += 1; + let [l, m, r] = Layout::new( + Direction::Horizontal, + [ + Constraint::Length(3), + Constraint::Fill(0), + Constraint::Length(3), + ], + ) + .areas(area); + let gauge = Gauge::default() + .ratio(f32::inverse_lerp(*min, *max, *value) as f64) + .label(format!("{}{}", *value, unit.clone().unwrap_or_default())); + gauge.render(m, buf); + + let lspan = Paragraph::new(if self.selected { " < " } else { "" }); + lspan.render(l, buf); + + let rspan = Paragraph::new(if self.selected { " > " } else { "" }); + rspan.render(r, buf); + } + MenuControlData::ISlider { + label, + min, + max, + value, + unit, + .. + } => { + let mut paragraph = Paragraph::new(format!( + "{}{}", + if self.selected { "> " } else { "" }, + label.clone() + )); + let enabled = self.enabled; + if enabled { + paragraph = paragraph.fg(self.style.enabled_color); + } + if self.selected { + paragraph = paragraph.style(self.style.selected_style); + } + if !enabled { + paragraph = paragraph.fg(self.style.disabled_color); + } + let mut area = area; + area.height = 1; + paragraph.render(area, buf); + area.y += 1; + let [l, m, r] = Layout::new( + Direction::Horizontal, + [ + Constraint::Length(3), + Constraint::Fill(0), + Constraint::Length(3), + ], + ) + .areas(area); + let gauge = Gauge::default() + .ratio(f32::inverse_lerp(*min as f32, *max as f32, *value as f32) as f64) + .label(format!("{}{}", *value, unit.clone().unwrap_or_default())); + gauge.render(m, buf); + + let lspan = Paragraph::new(if self.selected { " < " } else { "" }); + lspan.render(l, buf); + + let rspan = Paragraph::new(if self.selected { " > " } else { "" }); + rspan.render(r, buf); + } + MenuControlData::TextField { + label, + value, + validate, + .. + } => { + let mut paragraph = Paragraph::new(format!( + "{}{}", + if self.selected { "> " } else { "" }, + label.clone() + )); + let enabled = self.enabled; + if enabled { + paragraph = paragraph.fg(self.style.enabled_color); + } + if self.selected { + paragraph = paragraph.style(self.style.selected_style); + } + if !enabled { + paragraph = paragraph.fg(self.style.disabled_color); + } + let mut area = area; + area.height = 1; + paragraph.render(area, buf); + area.y += 1; + + let width = area.width; + let scroll = value.visual_scroll(width as usize); + let mut input = Paragraph::new(value.value()) + .scroll((0, scroll as u16)) + .add_modifier(Modifier::REVERSED); + if !enabled { + input = input.fg(self.style.disabled_color); + } + if let Some(validate) = validate { + if !validate(value) { + input = input.on_red(); + } + } + input.render(area, buf); + } + MenuControlData::CheckBox { label, value, .. } => { + let mut paragraph = Paragraph::new(format!( + "{}[{}] {label}", + if self.selected { "> " } else { "" }, + if *value { "x" } else { " " } + )); + let enabled = self.enabled; + if enabled { + paragraph = paragraph.fg(self.style.enabled_color); + } + if self.selected { + paragraph = paragraph.style(self.style.selected_style); + } + if !enabled { + paragraph = paragraph.fg(self.style.disabled_color); + } + paragraph.render(area, buf); + } + MenuControlData::Header(label) => { + if label.is_empty() { + Clear.render(area, buf); + } else { + Paragraph::new(label.to_string()) + .underlined() + .render(area, buf); + } + } } } }