use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use crate::ui::{StatusBar, StatusBarButton};
#[derive(Debug, Clone)]
pub enum AppEvent {
Quit,
ToggleOptions,
ToggleShapes,
ToggleColorMode,
ClearShapes,
PlaceSelectedShape,
ShapeMenuClick {
x: u16,
y: u16,
},
ShapeMenuUp,
ShapeMenuDown,
ShapeMenuLeft,
ShapeMenuRight,
CloseShapeMenu,
RotateShapeClockwise,
RotateShapeCounterClockwise,
MoveSelectedShape {
dx: f32,
dy: f32,
},
DeleteSelectedShape,
DeselectShape,
CycleColorForward,
CycleColorBackward,
Reset,
MenuUp,
MenuDown,
MenuIncrease,
MenuDecrease,
CloseMenu,
MenuStartEdit,
MenuEditChar(char),
MenuEditBackspace,
MenuConfirmEdit,
MenuCancelEdit,
SpawnBalls {
x: f32,
y: f32,
},
ApplyBurst {
x: f32,
y: f32,
},
MouseDown {
x: f32,
y: f32,
in_spawn_zone: bool,
},
MouseDrag {
x: f32,
y: f32,
},
MouseUp,
StartShapeDrag {
x: f32,
y: f32,
},
DragShape {
x: f32,
y: f32,
},
EndShapeDrag,
DoubleClick {
x: f32,
y: f32,
},
NumberBurst {
digit: u8,
},
Nudge {
dx: f32,
dy: f32,
},
SpawnAtSection {
digit: u8,
},
SpaceDown,
SpaceUp,
SaveLevel,
LoadLevel,
OpenSaveExplorer,
OpenLoadExplorer,
CloseFileExplorer,
FileExplorerUp,
FileExplorerDown,
FileExplorerConfirm,
FileExplorerChar(char),
FileExplorerBackspace,
ToggleHelp,
CloseHelpMenu,
HelpMenuScrollUp,
HelpMenuScrollDown,
HelpMenuPageUp,
HelpMenuPageDown,
HelpMenuScrollToTop,
HelpMenuScrollToBottom,
Resize {
new_width: u16,
new_height: u16,
delta_width: i32,
delta_height: i32,
},
None,
}
#[derive(Debug, Clone)]
pub struct EventContext {
pub terminal_width: u16,
pub terminal_height: u16,
pub menu_open: bool,
pub menu_editing: bool,
pub shape_menu_open: bool,
pub file_explorer_open: bool,
pub file_explorer_editing: bool,
pub help_menu_open: bool,
pub world_width: f32,
pub world_height: f32,
pub shape_selected: bool,
pub shape_dragging: bool,
}
pub fn handle_key_event(key: KeyEvent, ctx: &EventContext) -> AppEvent {
if ctx.help_menu_open {
return handle_help_menu_key(key);
}
if ctx.file_explorer_open {
return handle_file_explorer_key(key, ctx.file_explorer_editing);
}
if ctx.menu_open {
return handle_menu_key(key, ctx.menu_editing);
}
if ctx.shape_menu_open {
return handle_shape_menu_key(key);
}
const SHAPE_MOVE_SPEED: f32 = 1.0;
const NUDGE_STRENGTH: f32 = 3.0;
match key.code {
KeyCode::Char('q') | KeyCode::Char('Q') => AppEvent::Quit,
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => AppEvent::Quit,
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => AppEvent::SaveLevel,
KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => AppEvent::LoadLevel,
KeyCode::Char('o') | KeyCode::Char('O') => AppEvent::ToggleOptions,
KeyCode::Char('s') | KeyCode::Char('S') => AppEvent::ToggleShapes,
KeyCode::Char('c') | KeyCode::Char('C') => AppEvent::ToggleColorMode,
KeyCode::Char('?') => AppEvent::ToggleHelp,
KeyCode::Char('n') | KeyCode::Char('N') => {
if ctx.shape_selected {
AppEvent::CycleColorForward
} else {
AppEvent::None
}
}
KeyCode::Char('m') | KeyCode::Char('M') => {
if ctx.shape_selected {
AppEvent::CycleColorBackward
} else {
AppEvent::None
}
}
KeyCode::Char('r') | KeyCode::Char('R') => AppEvent::Reset,
KeyCode::Esc => {
if ctx.shape_selected {
AppEvent::DeselectShape
} else {
AppEvent::CloseMenu
}
}
KeyCode::Char('z') => AppEvent::RotateShapeClockwise,
KeyCode::Char('x') => AppEvent::RotateShapeCounterClockwise,
KeyCode::Delete | KeyCode::Backspace => {
if ctx.shape_selected {
AppEvent::DeleteSelectedShape
} else {
AppEvent::None
}
}
KeyCode::Up => {
if ctx.shape_selected {
AppEvent::MoveSelectedShape {
dx: 0.0,
dy: SHAPE_MOVE_SPEED,
}
} else {
AppEvent::Nudge {
dx: 0.0,
dy: NUDGE_STRENGTH,
}
}
}
KeyCode::Down => {
if ctx.shape_selected {
AppEvent::MoveSelectedShape {
dx: 0.0,
dy: -SHAPE_MOVE_SPEED,
}
} else {
AppEvent::Nudge {
dx: 0.0,
dy: -NUDGE_STRENGTH,
}
}
}
KeyCode::Left => {
if ctx.shape_selected {
AppEvent::MoveSelectedShape {
dx: -SHAPE_MOVE_SPEED,
dy: 0.0,
}
} else {
AppEvent::Nudge {
dx: -NUDGE_STRENGTH,
dy: 0.0,
}
}
}
KeyCode::Right => {
if ctx.shape_selected {
AppEvent::MoveSelectedShape {
dx: SHAPE_MOVE_SPEED,
dy: 0.0,
}
} else {
AppEvent::Nudge {
dx: NUDGE_STRENGTH,
dy: 0.0,
}
}
}
KeyCode::Char('w') | KeyCode::Char('W') => {
if ctx.shape_selected {
AppEvent::MoveSelectedShape {
dx: 0.0,
dy: SHAPE_MOVE_SPEED,
}
} else {
AppEvent::None
}
}
KeyCode::Char('a') | KeyCode::Char('A') => {
if ctx.shape_selected {
AppEvent::MoveSelectedShape {
dx: -SHAPE_MOVE_SPEED,
dy: 0.0,
}
} else {
AppEvent::None
}
}
KeyCode::Char('d') | KeyCode::Char('D') => {
if ctx.shape_selected {
AppEvent::MoveSelectedShape {
dx: SHAPE_MOVE_SPEED,
dy: 0.0,
}
} else {
AppEvent::None
}
}
KeyCode::Char(' ') => AppEvent::SpaceDown,
KeyCode::Char('!') => AppEvent::SpawnAtSection { digit: 1 },
KeyCode::Char('@') => AppEvent::SpawnAtSection { digit: 2 },
KeyCode::Char('#') => AppEvent::SpawnAtSection { digit: 3 },
KeyCode::Char('$') => AppEvent::SpawnAtSection { digit: 4 },
KeyCode::Char('%') => AppEvent::SpawnAtSection { digit: 5 },
KeyCode::Char('^') => AppEvent::SpawnAtSection { digit: 6 },
KeyCode::Char('1') => AppEvent::NumberBurst { digit: 1 },
KeyCode::Char('2') => AppEvent::NumberBurst { digit: 2 },
KeyCode::Char('3') => AppEvent::NumberBurst { digit: 3 },
KeyCode::Char('4') => AppEvent::NumberBurst { digit: 4 },
KeyCode::Char('5') => AppEvent::NumberBurst { digit: 5 },
KeyCode::Char('6') => AppEvent::NumberBurst { digit: 6 },
_ => AppEvent::None,
}
}
fn handle_shape_menu_key(key: KeyEvent) -> AppEvent {
match key.code {
KeyCode::Esc => AppEvent::CloseShapeMenu,
KeyCode::Enter => AppEvent::PlaceSelectedShape,
KeyCode::Up | KeyCode::Char('k') => AppEvent::ShapeMenuUp,
KeyCode::Down | KeyCode::Char('j') => AppEvent::ShapeMenuDown,
KeyCode::Left | KeyCode::Char('h') => AppEvent::ShapeMenuLeft,
KeyCode::Right | KeyCode::Char('l') => AppEvent::ShapeMenuRight,
KeyCode::Char('q') | KeyCode::Char('Q') => AppEvent::Quit,
_ => AppEvent::None,
}
}
fn handle_menu_key(key: KeyEvent, editing: bool) -> AppEvent {
if editing {
match key.code {
KeyCode::Esc => AppEvent::MenuCancelEdit,
KeyCode::Enter => AppEvent::MenuConfirmEdit,
KeyCode::Backspace => AppEvent::MenuEditBackspace,
KeyCode::Char(c) if c.is_ascii_digit() || c == '.' => AppEvent::MenuEditChar(c),
_ => AppEvent::None,
}
} else {
match key.code {
KeyCode::Esc => AppEvent::CloseMenu,
KeyCode::Enter => AppEvent::MenuStartEdit,
KeyCode::Up | KeyCode::Char('k') => AppEvent::MenuUp,
KeyCode::Down | KeyCode::Char('j') => AppEvent::MenuDown,
KeyCode::Right | KeyCode::Char('l') => AppEvent::MenuIncrease,
KeyCode::Left | KeyCode::Char('h') => AppEvent::MenuDecrease,
KeyCode::Char('q') | KeyCode::Char('Q') => AppEvent::Quit,
_ => AppEvent::None,
}
}
}
fn handle_file_explorer_key(key: KeyEvent, editing_filename: bool) -> AppEvent {
match key.code {
KeyCode::Esc => AppEvent::CloseFileExplorer,
KeyCode::Enter => AppEvent::FileExplorerConfirm,
KeyCode::Up | KeyCode::Char('k') => AppEvent::FileExplorerUp,
KeyCode::Down | KeyCode::Char('j') => AppEvent::FileExplorerDown,
KeyCode::Backspace if editing_filename => AppEvent::FileExplorerBackspace,
KeyCode::Char(c) if editing_filename => AppEvent::FileExplorerChar(c),
KeyCode::Char('q') | KeyCode::Char('Q') => AppEvent::Quit,
_ => AppEvent::None,
}
}
fn handle_help_menu_key(key: KeyEvent) -> AppEvent {
match key.code {
KeyCode::Esc | KeyCode::Char('?') => AppEvent::CloseHelpMenu,
KeyCode::Up | KeyCode::Char('k') => AppEvent::HelpMenuScrollUp,
KeyCode::Down | KeyCode::Char('j') => AppEvent::HelpMenuScrollDown,
KeyCode::PageUp => AppEvent::HelpMenuPageUp,
KeyCode::PageDown => AppEvent::HelpMenuPageDown,
KeyCode::Home => AppEvent::HelpMenuScrollToTop,
KeyCode::End => AppEvent::HelpMenuScrollToBottom,
KeyCode::Char('q') | KeyCode::Char('Q') => AppEvent::Quit,
_ => AppEvent::None,
}
}
pub fn handle_mouse_event(mouse: MouseEvent, ctx: &EventContext) -> AppEvent {
let MouseEvent {
kind,
column,
row,
modifiers: _,
} = mouse;
let canvas_height = ctx.terminal_height.saturating_sub(StatusBar::HEIGHT);
if matches!(kind, MouseEventKind::Up(MouseButton::Left)) {
return AppEvent::MouseUp;
}
if ctx.help_menu_open {
match kind {
MouseEventKind::ScrollUp => return AppEvent::HelpMenuScrollUp,
MouseEventKind::ScrollDown => return AppEvent::HelpMenuScrollDown,
_ => {}
}
}
if matches!(kind, MouseEventKind::Drag(MouseButton::Left)) {
if row >= canvas_height {
return AppEvent::None;
}
if ctx.menu_open {
return AppEvent::None;
}
if ctx.shape_menu_open {
return AppEvent::None;
}
let (phys_x, phys_y) = terminal_to_physics(
column,
row,
ctx.terminal_width,
canvas_height,
ctx.world_width,
ctx.world_height,
);
return AppEvent::MouseDrag {
x: phys_x,
y: phys_y,
};
}
if matches!(kind, MouseEventKind::Down(MouseButton::Right)) {
if row >= canvas_height {
return AppEvent::None;
}
if ctx.shape_selected {
return AppEvent::CycleColorForward;
}
return AppEvent::None;
}
if !matches!(kind, MouseEventKind::Down(MouseButton::Left)) {
return AppEvent::None;
}
if row >= canvas_height {
let row_in_bar = row - canvas_height;
if let Some(button) = StatusBar::button_at(column, row_in_bar, ctx.terminal_width) {
return match button {
StatusBarButton::Options => AppEvent::ToggleOptions,
StatusBarButton::Shapes => AppEvent::ToggleShapes,
StatusBarButton::Colors => AppEvent::ToggleColorMode,
StatusBarButton::Clear => AppEvent::ClearShapes,
StatusBarButton::Save => AppEvent::OpenSaveExplorer,
StatusBarButton::Load => AppEvent::OpenLoadExplorer,
StatusBarButton::Reset => AppEvent::Reset,
StatusBarButton::Help => AppEvent::ToggleHelp,
StatusBarButton::Quit => AppEvent::Quit,
StatusBarButton::Number(digit) => AppEvent::NumberBurst { digit },
};
}
return AppEvent::None;
}
if ctx.help_menu_open {
return AppEvent::CloseHelpMenu;
}
if ctx.file_explorer_open {
return AppEvent::CloseFileExplorer;
}
if ctx.menu_open {
return AppEvent::CloseMenu;
}
if ctx.shape_menu_open {
return AppEvent::ShapeMenuClick { x: column, y: row };
}
let (phys_x, phys_y) = terminal_to_physics(
column,
row,
ctx.terminal_width,
canvas_height,
ctx.world_width,
ctx.world_height,
);
let spawn_threshold = canvas_height / 4;
let in_spawn_zone = row < spawn_threshold;
AppEvent::MouseDown {
x: phys_x,
y: phys_y,
in_spawn_zone,
}
}
pub fn handle_resize_event(
new_width: u16,
new_height: u16,
old_width: u16,
old_height: u16,
) -> AppEvent {
AppEvent::Resize {
new_width,
new_height,
delta_width: i32::from(new_width) - i32::from(old_width),
delta_height: i32::from(new_height) - i32::from(old_height),
}
}
fn terminal_to_physics(
column: u16,
row: u16,
term_width: u16,
term_height: u16,
world_width: f32,
world_height: f32,
) -> (f32, f32) {
let norm_x = f32::from(column) / f32::from(term_width.max(1));
let norm_y = 1.0 - (f32::from(row) / f32::from(term_height.max(1)));
let phys_x = norm_x * world_width;
let phys_y = norm_y * world_height;
(phys_x, phys_y)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_context() -> EventContext {
EventContext {
terminal_width: 80,
terminal_height: 24,
menu_open: false,
menu_editing: false,
shape_menu_open: false,
file_explorer_open: false,
file_explorer_editing: false,
help_menu_open: false,
world_width: 40.0,
world_height: 22.0,
shape_selected: false,
shape_dragging: false,
}
}
#[test]
fn test_quit_key() {
let ctx = make_context();
let event = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE);
assert!(matches!(handle_key_event(event, &ctx), AppEvent::Quit));
}
#[test]
fn test_options_key() {
let ctx = make_context();
let event = KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE);
assert!(matches!(
handle_key_event(event, &ctx),
AppEvent::ToggleOptions
));
}
#[test]
fn test_menu_navigation() {
let mut ctx = make_context();
ctx.menu_open = true;
let up = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
assert!(matches!(handle_key_event(up, &ctx), AppEvent::MenuUp));
let down = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
assert!(matches!(handle_key_event(down, &ctx), AppEvent::MenuDown));
}
#[test]
fn test_terminal_to_physics_center() {
let (x, y) = terminal_to_physics(40, 11, 80, 22, 40.0, 22.0);
assert!((x - 20.0).abs() < 0.5);
assert!((y - 11.0).abs() < 0.5);
}
}