This commit is contained in:
parent
c8e2ba3c5c
commit
c6d8389d51
@ -15,3 +15,10 @@ opt-level = 2
|
||||
|
||||
[dependencies.bevy_terminal_display]
|
||||
version = "0.7"
|
||||
|
||||
[dependencies.ratatui]
|
||||
version = "0.29"
|
||||
features = ["unstable-widget-ref"]
|
||||
|
||||
[dependencies]
|
||||
tui-input = "0.11.1"
|
478
src/lib.rs
478
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<E: bevy::prelude::Event> {
|
||||
Button {
|
||||
label: String,
|
||||
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 enabled: Arc<AtomicBool>,
|
||||
pub enabled: bool,
|
||||
pub selected: bool,
|
||||
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 items: Vec<MenuControl<E>>,
|
||||
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>>,
|
||||
}
|
||||
|
||||
@ -41,11 +99,6 @@ impl<E: bevy::prelude::Event> Default for MenuWidget<E> {
|
||||
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<E: bevy::prelude::Event + Clone> TerminalWidget for MenuWidget<E> {
|
||||
frame: &mut bevy_terminal_display::ratatui::Frame,
|
||||
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 {
|
||||
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<E: bevy::prelude::Event + Clone> TerminalWidget for MenuWidget<E> {
|
||||
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<E: bevy::prelude::Event> FromIterator<(&'static str, E)> for MenuWidget<E> {
|
||||
fn from_iter<I: IntoIterator<Item = (&'static str, E)>>(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<E: bevy::prelude::Event> MenuControl<E> {
|
||||
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<E: bevy::prelude::Event> From<&MenuControl<E>> for ListItem<'_> {
|
||||
fn from(value: &MenuControl<E>) -> Self {
|
||||
match &value.data {
|
||||
MenuControlData::Button { label, .. } => ListItem::new(label.clone()),
|
||||
pub enum MenuControlState {
|
||||
None,
|
||||
Default,
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user