Dynamic widgets prototype
All checks were successful
Build / Build (push) Successful in 1h14m31s

This commit is contained in:
Silas Bartha 2025-04-03 20:59:57 -04:00
parent c8e2ba3c5c
commit c6d8389d51
2 changed files with 422 additions and 65 deletions

View File

@ -15,3 +15,10 @@ opt-level = 2
[dependencies.bevy_terminal_display] [dependencies.bevy_terminal_display]
version = "0.7" version = "0.7"
[dependencies.ratatui]
version = "0.29"
features = ["unstable-widget-ref"]
[dependencies]
tui-input = "0.11.1"

View File

@ -1,38 +1,96 @@
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use bevy::prelude::*; use bevy::prelude::*;
use bevy_terminal_display::{ use bevy_terminal_display::{
crossterm::event::{Event, KeyCode, KeyEventKind}, crossterm::event::{Event, KeyCode, KeyEventKind},
input::events::TerminalInputEvent, input::events::TerminalInputEvent,
ratatui::{ ratatui::{
layout::Rect, layout::Rect,
style::{Color, Modifier, Style}, style::{Color, Modifier, Style, Stylize},
widgets::{Block, Borders, Clear, HighlightSpacing, List, ListItem, ListState}, widgets::{Block, Clear, ListState, Paragraph, Widget},
}, },
widgets::TerminalWidget, 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<E: bevy::prelude::Event> { pub enum MenuControlData<E: bevy::prelude::Event> {
Button { Button {
label: String, label: String,
event: E, event: E,
}, },
FSlider {
label: String,
min: f32,
max: f32,
step: f32,
value: f32,
unit: Option<String>,
event: fn(f32) -> E,
},
ISlider {
label: String,
min: i32,
max: i32,
step: i32,
value: i32,
unit: Option<String>,
event: fn(i32) -> E,
},
TextField {
label: String,
value: Input,
event: fn(String) -> E,
validate: Option<fn(&Input) -> 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<E: bevy::prelude::Event> { pub struct MenuControl<E: bevy::prelude::Event> {
pub enabled: Arc<AtomicBool>, pub enabled: bool,
pub selected: bool,
pub data: MenuControlData<E>, pub data: MenuControlData<E>,
pub style: MenuControlStyle,
}
impl<E: bevy::prelude::Event> Default for MenuControl<E> {
fn default() -> Self {
Self {
enabled: true,
selected: false,
data: MenuControlData::Header("".into()),
style: MenuControlStyle::default(),
}
}
} }
pub struct MenuWidget<E: bevy::prelude::Event> { pub struct MenuWidget<E: bevy::prelude::Event> {
pub items: Vec<MenuControl<E>>, pub items: Vec<MenuControl<E>>,
pub state: ListState, pub state: ListState,
pub enabled_color: Color,
pub disabled_color: Color,
pub selected_style: Style,
pub area_function: Option<Box<dyn Fn(Rect) -> Rect + Send + Sync>>, pub area_function: Option<Box<dyn Fn(Rect) -> Rect + Send + Sync>>,
} }
@ -41,11 +99,6 @@ impl<E: bevy::prelude::Event> Default for MenuWidget<E> {
Self { Self {
items: vec![], items: vec![],
state: ListState::default().with_selected(Some(0)), 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, area_function: None,
} }
} }
@ -57,34 +110,23 @@ impl<E: bevy::prelude::Event + Clone> TerminalWidget for MenuWidget<E> {
frame: &mut bevy_terminal_display::ratatui::Frame, frame: &mut bevy_terminal_display::ratatui::Frame,
rect: bevy_terminal_display::ratatui::prelude::Rect, rect: bevy_terminal_display::ratatui::prelude::Rect,
) { ) {
let block = Block::new().borders(Borders::all());
let items: Vec<ListItem> = 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 { let area = if let Some(area) = &self.area_function {
area(rect) area(rect)
} else { } else {
rect rect
}; };
frame.render_widget(Clear, area); 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( fn handle_events(
@ -97,49 +139,357 @@ impl<E: bevy::prelude::Event + Clone> TerminalWidget for MenuWidget<E> {
match key_event.code { match key_event.code {
KeyCode::Enter => { KeyCode::Enter => {
if let Some(selected) = self.state.selected() { if let Some(selected) = self.state.selected() {
if let Some(item) = self.items.get(selected) { if let Some(item) = self.items.get_mut(selected) {
if item.enabled.load(Ordering::Relaxed) { if item.enabled {
match &item.data { match &mut item.data {
MenuControlData::Button { event, .. } => { MenuControlData::Button { event, .. } => {
commands.send_event(event.clone()); 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<E: bevy::prelude::Event> FromIterator<(&'static str, E)> for MenuWidget<E> { impl<E: bevy::prelude::Event> MenuControl<E> {
fn from_iter<I: IntoIterator<Item = (&'static str, E)>>(iter: I) -> Self { fn height(&self) -> u16 {
let items = iter match &self.data {
.into_iter() MenuControlData::Button { .. } => 1,
.map(|(label, event)| MenuControl { MenuControlData::FSlider { .. } => 2,
enabled: Arc::new(true.into()), MenuControlData::ISlider { .. } => 2,
data: MenuControlData::Button { MenuControlData::TextField { .. } => 2,
label: label.into(), MenuControlData::CheckBox { .. } => 1,
event, MenuControlData::Header(_) => 1,
},
})
.collect();
Self {
items,
..Default::default()
} }
} }
} }
impl<E: bevy::prelude::Event> From<&MenuControl<E>> for ListItem<'_> { pub enum MenuControlState {
fn from(value: &MenuControl<E>) -> Self { None,
match &value.data { Default,
MenuControlData::Button { label, .. } => ListItem::new(label.clone()), }
impl<E: bevy::prelude::Event> WidgetRef for &mut MenuControl<E> {
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);
}
}
} }
} }
} }