use std::time::Duration;
use crossterm::event::{
self, Event as CrosstermEvent, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent,
MouseEventKind,
};
use rs_poker::holdem::PreflopScenario;
use crate::tui::{
screens::chart_viewer::{ChartViewerRects, ChartViewerState, render_chart_viewer},
terminal::{self, Tui},
widgets::hand_grid::cell_size,
};
pub struct ChartApp {
pub state: ChartViewerState,
pub should_quit: bool,
rects: Option<ChartViewerRects>,
}
impl ChartApp {
pub fn new(state: ChartViewerState) -> Self {
Self {
state,
should_quit: false,
rects: None,
}
}
pub fn handle_key(&mut self, key: KeyEvent) {
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
self.should_quit = true;
return;
}
match key.code {
KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true,
KeyCode::Char('j') | KeyCode::Down => self.state.move_hover(1, 0),
KeyCode::Char('k') | KeyCode::Up => self.state.move_hover(-1, 0),
KeyCode::Char('h') | KeyCode::Left => self.state.move_hover(0, -1),
KeyCode::Char('l') | KeyCode::Right => self.state.move_hover(0, 1),
KeyCode::Tab => self.state.next_seat(),
KeyCode::BackTab => self.state.prev_seat(),
KeyCode::Char(']') => self.state.next_seat(),
KeyCode::Char('[') => self.state.prev_seat(),
KeyCode::Char('r') => self.state.set_scenario(PreflopScenario::Rfi),
KeyCode::Char('o') => self.state.set_scenario(PreflopScenario::VsOpen),
KeyCode::Char('t') => self.state.set_scenario(PreflopScenario::Vs3Bet),
KeyCode::Char('f') => self.state.set_scenario(PreflopScenario::Vs4Bet),
KeyCode::Char('s') => self.state.next_scenario(),
KeyCode::Char('S') => self.state.prev_scenario(),
KeyCode::Char(c) if c.is_ascii_digit() && c != '0' => {
let seat = (c as u8 - b'1') as usize;
if seat < self.state.num_seats {
self.state.set_seat(seat);
}
}
_ => {}
}
}
pub fn handle_mouse(&mut self, mouse: MouseEvent) {
let Some(rects) = &self.rects else {
return;
};
match mouse.kind {
MouseEventKind::Down(MouseButton::Left) => {
for (seat, rect) in rects.seat_tabs.iter().enumerate() {
if rect.contains((mouse.column, mouse.row).into()) {
self.state.set_seat(seat);
return;
}
}
for (scenario, rect) in &rects.scenario_tabs {
if rect.contains((mouse.column, mouse.row).into()) {
self.state.set_scenario(*scenario);
return;
}
}
if let Some((row, col)) = grid_hit(&rects.grid, mouse.column, mouse.row) {
self.state.hover = (row, col);
}
}
MouseEventKind::Drag(MouseButton::Left) => {
if let Some((row, col)) = grid_hit(&rects.grid, mouse.column, mouse.row) {
self.state.hover = (row, col);
}
}
MouseEventKind::ScrollUp
if rects
.seat_tabs
.iter()
.any(|r| r.contains((mouse.column, mouse.row).into())) =>
{
self.state.prev_seat();
}
MouseEventKind::ScrollDown
if rects
.seat_tabs
.iter()
.any(|r| r.contains((mouse.column, mouse.row).into())) =>
{
self.state.next_seat();
}
_ => {}
}
}
}
fn grid_hit(grid: &ratatui::layout::Rect, col: u16, row: u16) -> Option<(usize, usize)> {
let (cell_w, cell_h) = cell_size(*grid);
if col < grid.x + 2 || row < grid.y + 1 {
return None;
}
let c = ((col - grid.x - 2) / cell_w) as usize;
let r = ((row - grid.y - 1) / cell_h) as usize;
if r < 13 && c < 13 { Some((r, c)) } else { None }
}
pub fn run_chart_app(app: &mut ChartApp) -> std::io::Result<()> {
let mut terminal = terminal::setup_terminal()?;
let result = event_loop(&mut terminal, app);
terminal::restore_terminal(&mut terminal)?;
result
}
fn event_loop(terminal: &mut Tui, app: &mut ChartApp) -> std::io::Result<()> {
loop {
let mut new_rects = None;
terminal.draw(|frame| {
new_rects = Some(render_chart_viewer(frame, &app.state));
})?;
app.rects = new_rects;
if app.should_quit {
break;
}
if event::poll(Duration::from_millis(100))? {
match event::read()? {
CrosstermEvent::Key(key) => app.handle_key(key),
CrosstermEvent::Mouse(mouse) => app.handle_mouse(mouse),
CrosstermEvent::Resize(_, _) => {}
_ => {}
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyModifiers;
use rs_poker::arena::cfr::{PositionCharts, PreflopChartConfig};
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn app() -> ChartApp {
let config = PreflopChartConfig::with_single_position(PositionCharts::default());
ChartApp::new(ChartViewerState::new(config, "test".into(), 6, false))
}
#[test]
fn q_quits() {
let mut a = app();
a.handle_key(key(KeyCode::Char('q')));
assert!(a.should_quit);
}
#[test]
fn ctrl_c_quits() {
let mut a = app();
a.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
assert!(a.should_quit);
}
#[test]
fn hjkl_moves_hover() {
let mut a = app();
a.state.hover = (5, 5);
a.handle_key(key(KeyCode::Char('j')));
assert_eq!(a.state.hover, (6, 5));
a.handle_key(key(KeyCode::Char('k')));
assert_eq!(a.state.hover, (5, 5));
a.handle_key(key(KeyCode::Char('l')));
assert_eq!(a.state.hover, (5, 6));
a.handle_key(key(KeyCode::Char('h')));
assert_eq!(a.state.hover, (5, 5));
}
#[test]
fn digit_keys_jump_to_seat() {
let mut a = app();
a.handle_key(key(KeyCode::Char('3')));
assert_eq!(a.state.current_seat, 2);
a.handle_key(key(KeyCode::Char('1')));
assert_eq!(a.state.current_seat, 0);
}
#[test]
fn tab_cycles_seats() {
let mut a = app();
a.state.set_seat(5);
a.handle_key(key(KeyCode::Tab));
assert_eq!(a.state.current_seat, 0);
}
#[test]
fn digit_outside_range_is_ignored() {
let mut a = app();
a.state.set_seat(2);
a.handle_key(key(KeyCode::Char('9')));
assert_eq!(a.state.current_seat, 2);
}
#[test]
fn grid_hit_inside_cell() {
let grid = ratatui::layout::Rect::new(0, 0, 50, 16);
assert_eq!(grid_hit(&grid, 2, 1), Some((0, 0)));
assert_eq!(grid_hit(&grid, 6, 1), Some((0, 1)));
assert_eq!(grid_hit(&grid, 0, 0), None);
assert_eq!(grid_hit(&grid, 1, 1), None);
}
#[test]
fn grid_hit_scales_with_responsive_cells() {
let grid = ratatui::layout::Rect::new(0, 0, 130, 40);
let (cw, ch) = super::cell_size(grid);
let x = 2 + 5 * cw + cw / 2;
let y = 1 + 2 * ch + ch / 2;
assert_eq!(grid_hit(&grid, x, y), Some((2, 5)));
assert_eq!(grid_hit(&grid, 2, 1), Some((0, 0)));
}
}