use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState},
Frame,
};
struct HelpEntry {
key: &'static str,
description: &'static str,
}
const HELP_SECTIONS: &[(&str, &[HelpEntry])] = &[
(
"General",
&[
HelpEntry {
key: "Q",
description: "Quit the application",
},
HelpEntry {
key: "Ctrl+C",
description: "Quit the application",
},
HelpEntry {
key: "R",
description: "Reset simulation (respawn all balls)",
},
HelpEntry {
key: "Esc",
description: "Close menu / Deselect shape",
},
HelpEntry {
key: "?",
description: "Toggle this help menu",
},
],
),
(
"Menus",
&[
HelpEntry {
key: "O",
description: "Toggle Options menu",
},
HelpEntry {
key: "S",
description: "Toggle Shapes menu",
},
HelpEntry {
key: "C",
description: "Toggle Color Mode",
},
],
),
(
"Ball Spawning",
&[
HelpEntry {
key: "Space",
description: "Spawn balls across full width (hold for more)",
},
HelpEntry {
key: "Click top 1/4",
description: "Spawn balls at cursor (hold for more)",
},
HelpEntry {
key: "Shift+1-6",
description: "Spawn balls at section top (!/@ /#/$/%/^)",
},
],
),
(
"Ball Physics",
&[
HelpEntry {
key: "1-6",
description: "Trigger upward geyser burst at zone",
},
HelpEntry {
key: "Click bottom 3/4",
description: "Apply burst force at cursor",
},
HelpEntry {
key: "Arrow Keys",
description: "Nudge all balls (when no shape selected)",
},
],
),
(
"Shape Controls",
&[
HelpEntry {
key: "Click shape",
description: "Select shape",
},
HelpEntry {
key: "Drag shape",
description: "Move selected shape",
},
HelpEntry {
key: "Double-click",
description: "Delete shape at cursor",
},
HelpEntry {
key: "Z",
description: "Rotate shape clockwise",
},
HelpEntry {
key: "X",
description: "Rotate shape counter-clockwise",
},
HelpEntry {
key: "Arrow/WASD",
description: "Move selected shape",
},
HelpEntry {
key: "N / M",
description: "Cycle shape color forward / backward",
},
HelpEntry {
key: "Right-click",
description: "Cycle shape color forward",
},
HelpEntry {
key: "Delete/Backspace",
description: "Delete selected shape",
},
],
),
(
"Save/Load",
&[
HelpEntry {
key: "Ctrl+S",
description: "Quick save to level.json",
},
HelpEntry {
key: "Ctrl+L",
description: "Quick load from level.json",
},
HelpEntry {
key: "Save button",
description: "Open save file explorer",
},
HelpEntry {
key: "Load button",
description: "Open load file explorer",
},
],
),
(
"Menu Navigation",
&[
HelpEntry {
key: "Up/Down or J/K",
description: "Navigate menu items",
},
HelpEntry {
key: "Left/Right or H/L",
description: "Adjust values",
},
HelpEntry {
key: "Enter",
description: "Edit value directly",
},
],
),
(
"Mouse Controls",
&[
HelpEntry {
key: "Click number bar",
description: "Trigger geyser at zone 1-6",
},
HelpEntry {
key: "Click buttons",
description: "Activate menu buttons",
},
HelpEntry {
key: "Scroll in help",
description: "Scroll help content",
},
],
),
];
#[derive(Debug, Clone)]
pub struct HelpMenu {
pub visible: bool,
scroll_offset: usize,
total_lines: usize,
viewport_height: usize,
}
impl Default for HelpMenu {
fn default() -> Self {
Self::new()
}
}
impl HelpMenu {
pub fn new() -> Self {
let mut total = 0;
for (_, entries) in HELP_SECTIONS {
total += 2;
total += entries.len();
}
Self {
visible: false,
scroll_offset: 0,
total_lines: total,
viewport_height: 10,
}
}
pub fn toggle(&mut self) {
self.visible = !self.visible;
if self.visible {
self.scroll_offset = 0;
}
}
pub fn show(&mut self) {
self.visible = true;
self.scroll_offset = 0;
}
pub fn hide(&mut self) {
self.visible = false;
}
pub fn scroll_up(&mut self, lines: usize) {
self.scroll_offset = self.scroll_offset.saturating_sub(lines);
}
pub fn scroll_down(&mut self, lines: usize) {
let max_offset = self.total_lines.saturating_sub(self.viewport_height);
self.scroll_offset = (self.scroll_offset + lines).min(max_offset);
}
pub fn page_up(&mut self) {
self.scroll_up(self.viewport_height.saturating_sub(2));
}
pub fn page_down(&mut self) {
self.scroll_down(self.viewport_height.saturating_sub(2));
}
pub fn scroll_to_top(&mut self) {
self.scroll_offset = 0;
}
pub fn scroll_to_bottom(&mut self) {
let max_offset = self.total_lines.saturating_sub(self.viewport_height);
self.scroll_offset = max_offset;
}
pub fn handle_scroll(&mut self, delta: i32) {
if delta > 0 {
self.scroll_up(delta.unsigned_abs() as usize);
} else {
self.scroll_down(delta.unsigned_abs() as usize);
}
}
fn build_content(&self) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let header_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
let key_style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
let desc_style = Style::default().fg(Color::White);
for (category, entries) in HELP_SECTIONS {
lines.push(Line::from(vec![Span::styled(
format!(" {} ", category),
header_style,
)]));
for entry in *entries {
let key_padded = format!(" {:16}", entry.key);
lines.push(Line::from(vec![
Span::styled(key_padded, key_style),
Span::styled(entry.description, desc_style),
]));
}
lines.push(Line::from(""));
}
lines
}
pub fn render(&mut self, frame: &mut Frame, area: Rect) {
if !self.visible {
return;
}
let popup_area = centered_rect(70, 80, area);
self.viewport_height = popup_area.height.saturating_sub(2) as usize;
let max_offset = self.total_lines.saturating_sub(self.viewport_height);
self.scroll_offset = self.scroll_offset.min(max_offset);
frame.render_widget(Clear, popup_area);
let content = self.build_content();
let title = format!(
" Help - Scroll: Up/Down/PgUp/PgDn/Home/End ({}/{}) ",
self.scroll_offset + 1,
self.total_lines.saturating_sub(self.viewport_height).max(1)
);
let help_block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta))
.style(Style::default().bg(Color::Black));
let paragraph = Paragraph::new(content)
.block(help_block)
.scroll((self.scroll_offset as u16, 0));
frame.render_widget(paragraph, popup_area);
if self.total_lines > self.viewport_height {
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("^"))
.end_symbol(Some("v"));
let mut scrollbar_state =
ScrollbarState::new(self.total_lines.saturating_sub(self.viewport_height))
.position(self.scroll_offset);
let scrollbar_area = Rect {
x: popup_area.x + popup_area.width - 1,
y: popup_area.y + 1,
width: 1,
height: popup_area.height.saturating_sub(2),
};
frame.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
}
}
pub fn is_click_inside(&self, x: u16, y: u16, area: Rect) -> bool {
let popup_area = centered_rect(70, 80, area);
x >= popup_area.x
&& x < popup_area.x + popup_area.width
&& y >= popup_area.y
&& y < popup_area.y + popup_area.height
}
}
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(40);
let popup_height = popup_height.max(15);
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_help_menu_toggle() {
let mut menu = HelpMenu::new();
assert!(!menu.visible);
menu.toggle();
assert!(menu.visible);
assert_eq!(menu.scroll_offset, 0);
menu.toggle();
assert!(!menu.visible);
}
#[test]
fn test_scroll_bounds() {
let mut menu = HelpMenu::new();
menu.visible = true;
menu.viewport_height = 10;
menu.scroll_up(5);
assert_eq!(menu.scroll_offset, 0);
menu.scroll_down(5);
assert_eq!(menu.scroll_offset, 5);
menu.scroll_up(3);
assert_eq!(menu.scroll_offset, 2);
}
#[test]
fn test_show_hide() {
let mut menu = HelpMenu::new();
menu.scroll_offset = 10;
menu.show();
assert!(menu.visible);
assert_eq!(menu.scroll_offset, 0);
menu.hide();
assert!(!menu.visible);
}
}