diff --git a/.cargo/config.toml b/.cargo/config.toml deleted file mode 100644 index 62d8a86..0000000 --- a/.cargo/config.toml +++ /dev/null @@ -1,2 +0,0 @@ -[profile.dev.package."*"] -opt-level = 2 diff --git a/.gitignore b/.gitignore index 96ef6c0..5a0ca02 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target Cargo.lock +.cargo \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index c0d16e1..f664d80 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,5 +7,18 @@ edition = "2021" version = "0.15" default-features = false +[profile.dev] +opt-level = 1 + +[profile.dev.package."*"] +opt-level = 2 + [dependencies.bevy_terminal_display] -git = "https://git.soaos.dev/soaos/bevy_terminal_display" +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 6547fe6..18fa2a0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,24 +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} + layout::Rect, + 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 struct MenuWidget { - pub items: Vec>, - pub state: ListState, +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: 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 area_function: Option Rect + Send + Sync>>, } @@ -27,56 +99,34 @@ 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, } } } -pub struct MenuOption { - pub enabled: Arc, - pub label: String, - pub event: E, -} - impl TerminalWidget for MenuWidget { fn render( &mut self, 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( @@ -89,41 +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) { - commands.send_event(item.event.clone()); + 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::Up => self.state.select_previous(), - KeyCode::Down => self.state.select_next(), - _ => (), + } + 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())); + } + } + } + } + } + } + } } } } } } -impl FromIterator<(&'static str, E)> for MenuWidget { - fn from_iter>(iter: I) -> Self { - let items = iter - .into_iter() - .map(|(label, event)| MenuOption { - enabled: Arc::new(true.into()), - 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<&MenuOption> for ListItem<'_> { - fn from(value: &MenuOption) -> Self { - ListItem::new(value.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); + } + } + } } }