use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem},
Frame,
};
#[derive(Debug, Clone)]
pub enum MenuValue {
Integer(i32),
Float(f32),
Boolean(bool),
}
impl MenuValue {
pub fn display(&self) -> String {
match self {
MenuValue::Integer(v) => v.to_string(),
MenuValue::Float(v) => format!("{:.2}", v),
MenuValue::Boolean(v) => {
if *v {
"ON".to_string()
} else {
"OFF".to_string()
}
}
}
}
pub fn adjust(&mut self, direction: f32, step: f32, min: Option<f32>, max: Option<f32>) {
match self {
MenuValue::Integer(v) => {
let new_val = *v + (step * direction) as i32;
if let (Some(min), Some(max)) = (min, max) {
*v = new_val.clamp(min as i32, max as i32);
} else {
*v = new_val;
}
}
MenuValue::Float(f) => {
let new_val = *f + step * direction;
if let (Some(min), Some(max)) = (min, max) {
*f = new_val.clamp(min, max);
} else {
*f = new_val;
}
}
MenuValue::Boolean(b) => {
*b = !*b;
}
}
}
}
#[derive(Debug, Clone)]
pub struct MenuItem {
pub label: &'static str,
pub value: MenuValue,
pub min: Option<f32>,
pub max: Option<f32>,
pub step: f32,
}
impl MenuItem {
pub fn integer(label: &'static str, value: i32, min: i32, max: i32, step: i32) -> Self {
Self {
label,
value: MenuValue::Integer(value),
min: Some(min as f32),
max: Some(max as f32),
step: step as f32,
}
}
pub fn float(label: &'static str, value: f32, min: f32, max: f32, step: f32) -> Self {
Self {
label,
value: MenuValue::Float(value),
min: Some(min),
max: Some(max),
step,
}
}
pub fn boolean(label: &'static str, value: bool) -> Self {
Self {
label,
value: MenuValue::Boolean(value),
min: None,
max: None,
step: 1.0,
}
}
}
#[derive(Debug, Clone)]
pub struct OptionsMenu {
pub visible: bool,
pub selected_index: usize,
pub items: Vec<MenuItem>,
editing_text: Option<String>,
original_value: Option<MenuValue>,
color_mode: bool,
}
impl Default for OptionsMenu {
fn default() -> Self {
Self {
visible: false,
selected_index: 0,
items: vec![
MenuItem::boolean("Frame Cap", false),
MenuItem::boolean("Show FPS", true),
MenuItem::integer("Ball Count", 5000, 100, 15000, 100),
MenuItem::integer("Gravity %", 100, 0, 500, 10),
MenuItem::integer("Force %", 100, 10, 500, 10),
MenuItem::integer("Friction %", 100, 0, 500, 10),
MenuItem::integer("Spawn Color %", 80, 0, 100, 5),
],
editing_text: None,
original_value: None,
color_mode: false,
}
}
}
impl OptionsMenu {
pub fn toggle(&mut self) {
self.visible = !self.visible;
}
pub fn show(&mut self) {
self.visible = true;
}
pub fn hide(&mut self) {
self.visible = false;
}
pub fn select_previous(&mut self) {
if self.selected_index > 0 {
self.selected_index -= 1;
}
}
pub fn select_next(&mut self) {
if self.selected_index < self.items.len().saturating_sub(1) {
self.selected_index += 1;
}
}
pub fn increase_value(&mut self) {
if let Some(item) = self.items.get_mut(self.selected_index) {
item.value.adjust(1.0, item.step, item.min, item.max);
}
}
pub fn decrease_value(&mut self) {
if let Some(item) = self.items.get_mut(self.selected_index) {
item.value.adjust(-1.0, item.step, item.min, item.max);
}
}
pub fn is_editing(&self) -> bool {
self.editing_text.is_some()
}
pub fn start_editing(&mut self) {
if let Some(item) = self.items.get(self.selected_index) {
match &item.value {
MenuValue::Integer(_) | MenuValue::Float(_) => {
self.original_value = Some(item.value.clone());
self.editing_text = Some(String::new());
}
MenuValue::Boolean(_) => {
}
}
}
}
pub fn handle_edit_char(&mut self, c: char) {
if let Some(ref mut text) = self.editing_text {
if c.is_ascii_digit() || (c == '.' && !text.contains('.')) {
text.push(c);
}
}
}
pub fn handle_edit_backspace(&mut self) {
if let Some(ref mut text) = self.editing_text {
text.pop();
}
}
pub fn confirm_edit(&mut self) -> bool {
let Some(text) = self.editing_text.take() else {
return false;
};
let Some(item) = self.items.get_mut(self.selected_index) else {
self.original_value = None;
return false;
};
if text.is_empty() {
if let Some(original) = self.original_value.take() {
item.value = original;
}
return false;
}
let success = match &mut item.value {
MenuValue::Integer(v) => {
if let Ok(parsed) = text.parse::<i32>() {
let clamped = if let (Some(min), Some(max)) = (item.min, item.max) {
parsed.clamp(min as i32, max as i32)
} else {
parsed
};
*v = clamped;
true
} else {
if let Some(original) = self.original_value.take() {
item.value = original;
}
false
}
}
MenuValue::Float(f) => {
if let Ok(parsed) = text.parse::<f32>() {
let clamped = if let (Some(min), Some(max)) = (item.min, item.max) {
parsed.clamp(min, max)
} else {
parsed
};
*f = clamped;
true
} else {
if let Some(original) = self.original_value.take() {
item.value = original;
}
false
}
}
MenuValue::Boolean(_) => false,
};
self.original_value = None;
success
}
pub fn cancel_edit(&mut self) {
self.editing_text = None;
if let Some(original) = self.original_value.take() {
if let Some(item) = self.items.get_mut(self.selected_index) {
item.value = original;
}
}
}
pub fn editing_text(&self) -> Option<&str> {
self.editing_text.as_deref()
}
pub fn get_value(&self, label: &str) -> Option<&MenuValue> {
self.items
.iter()
.find(|item| item.label == label)
.map(|item| &item.value)
}
pub fn set_value(&mut self, label: &str, value: MenuValue) {
if let Some(item) = self.items.iter_mut().find(|i| i.label == label) {
item.value = value;
}
}
pub fn fps_cap_enabled(&self) -> bool {
match self.get_value("Frame Cap") {
Some(MenuValue::Boolean(v)) => *v,
_ => false,
}
}
pub fn show_fps(&self) -> bool {
match self.get_value("Show FPS") {
Some(MenuValue::Boolean(v)) => *v,
_ => false,
}
}
pub fn ball_count(&self) -> usize {
match self.get_value("Ball Count") {
Some(MenuValue::Integer(v)) => (*v).max(1) as usize,
_ => 2000,
}
}
pub fn set_ball_count(&mut self, count: usize) {
if let Some(item) = self.items.iter_mut().find(|i| i.label == "Ball Count") {
let clamped = (count as i32).clamp(
item.min.unwrap_or(100.0) as i32,
item.max.unwrap_or(15000.0) as i32,
);
item.value = MenuValue::Integer(clamped);
}
}
pub fn gravity(&self) -> f32 {
let percent = match self.get_value("Gravity %") {
Some(MenuValue::Integer(v)) => *v as f32,
_ => 100.0,
};
9.81 * (percent / 100.0)
}
pub fn gravity_percent(&self) -> i32 {
match self.get_value("Gravity %") {
Some(MenuValue::Integer(v)) => *v,
_ => 100,
}
}
pub fn friction(&self) -> f32 {
let percent = match self.get_value("Friction %") {
Some(MenuValue::Integer(v)) => *v as f32,
_ => 100.0,
};
0.30 * (percent / 100.0)
}
pub fn friction_percent(&self) -> i32 {
match self.get_value("Friction %") {
Some(MenuValue::Integer(v)) => *v,
_ => 100,
}
}
pub fn force_percent(&self) -> f32 {
let percent = match self.get_value("Force %") {
Some(MenuValue::Integer(v)) => *v as f32,
_ => 100.0,
};
percent / 100.0
}
pub fn color_mode(&self) -> bool {
self.color_mode
}
pub fn set_color_mode(&mut self, enabled: bool) {
self.color_mode = enabled;
}
pub fn toggle_color_mode(&mut self) {
self.color_mode = !self.color_mode;
}
pub fn spawn_color_percent(&self) -> i32 {
match self.get_value("Spawn Color %") {
Some(MenuValue::Integer(v)) => *v,
_ => 50,
}
}
pub fn render(&self, frame: &mut Frame, area: Rect) {
if !self.visible {
return;
}
let popup_area = centered_rect(50, 40, area);
frame.render_widget(Clear, popup_area);
let items: Vec<ListItem> = self
.items
.iter()
.enumerate()
.map(|(i, item)| {
let is_selected = i == self.selected_index;
let is_editing = is_selected && self.editing_text.is_some();
let style = if is_selected {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let value_str = if is_editing {
format!("{}_", self.editing_text.as_deref().unwrap_or(""))
} else {
item.value.display()
};
let value_style = if is_editing {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else {
style.fg(Color::Cyan)
};
let content = Line::from(vec![
Span::styled(format!("{}: ", item.label), style),
Span::styled(value_str, value_style),
]);
ListItem::new(content)
})
.collect();
let title = if self.is_editing() {
" Enter value, Enter to confirm, Esc to cancel "
} else {
" Options (Enter to edit, Arrows to adjust) "
};
let menu_block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.style(Style::default().bg(Color::Black));
let menu_list = List::new(items).block(menu_block);
frame.render_widget(menu_list, popup_area);
}
}
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let popup_width = area.width * percent_x / 100;
let popup_height = area.height * percent_y / 100;
let popup_width = popup_width.max(30);
let popup_height = popup_height.max(10);
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length((area.height.saturating_sub(popup_height)) / 2),
Constraint::Length(popup_height),
Constraint::Min(0),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length((area.width.saturating_sub(popup_width)) / 2),
Constraint::Length(popup_width),
Constraint::Min(0),
])
.split(vertical[1])[1]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_menu_values() {
let menu = OptionsMenu::default();
assert!(!menu.fps_cap_enabled());
assert!(menu.show_fps());
assert_eq!(menu.ball_count(), 5000);
assert!((menu.gravity() - 9.81).abs() < 0.01);
assert!((menu.friction() - 0.30).abs() < 0.01);
assert_eq!(menu.gravity_percent(), 100);
assert_eq!(menu.friction_percent(), 100);
assert!((menu.force_percent() - 1.0).abs() < 0.01);
assert!(!menu.color_mode());
}
#[test]
fn test_menu_navigation() {
let mut menu = OptionsMenu::default();
assert_eq!(menu.selected_index, 0);
menu.select_next();
assert_eq!(menu.selected_index, 1);
menu.select_previous();
assert_eq!(menu.selected_index, 0);
menu.select_previous(); assert_eq!(menu.selected_index, 0);
}
#[test]
fn test_value_adjustment() {
let mut menu = OptionsMenu::default();
assert!(!menu.fps_cap_enabled());
menu.increase_value();
assert!(menu.fps_cap_enabled());
menu.decrease_value();
assert!(!menu.fps_cap_enabled());
}
#[test]
fn test_toggle_visibility() {
let mut menu = OptionsMenu::default();
assert!(!menu.visible);
menu.toggle();
assert!(menu.visible);
menu.toggle();
assert!(!menu.visible);
}
}