//! Keyboard-shortcut help modal. //! //! Renders a static reference card as a centered overlay. //! Any key (including `Esc`) closes the modal. use ratatui::Frame; use ratatui::crossterm::event::{Event, KeyCode, KeyEventKind}; use ratatui::layout::{Alignment, Rect}; use ratatui::widgets::{Block, Borders, Clear, Paragraph}; use crate::tui::state::AppState; // --------------------------------------------------------------------------- // Geometry helpers // --------------------------------------------------------------------------- /// Dialog dimensions. const DIALOG_WIDTH: u16 = 36; const DIALOG_HEIGHT: u16 = 36; /// Compute a centered [`Rect`] of the given size within `area`. fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { let x = area.x + area.width.saturating_sub(width) / 2; let y = area.y + area.height.saturating_sub(height) / 2; Rect { x, y, width: width.min(area.width), height: height.min(area.height), } } // --------------------------------------------------------------------------- // Static help content // --------------------------------------------------------------------------- const HELP_TEXT: &str = "\ Navigation\n\ \u{2191}/\u{2193} k/j scroll up/dn\n\ Spc/PgDn page down\n\ \u{21e7}Spc/PgUp page up\n\ \u{2190}/\u{2192} h/l scroll lr\n\ Home/gg jump to top\n\ End/G jump to bottom\n\ Tab cycle panes\n\ Enter open/select\n\ Esc go back\n\ \n\ Filter (sessions)\n\ t / / open filter\n\ Enter apply & close\n\ Esc clear input\n\ \n\ Filter fields\n\ model:haiku substring\n\ project:foo substring\n\ id:abc substring\n\ agents>0 numeric\n\ messages<50 numeric (alias: msgs)\n\ in>5000 input tokens\n\ out<1000 output tokens\n\ tokens>50000 total tokens\n\ date>2026-01 date\n\ \n\ Search (transcript)\n\ t / / open search\n\ n / N next/prev match\n\ Enter apply & close\n\ Esc clear & close\n\ \n\ Global\n\ q/Q quit\n\ ? this help\n\ c toggle color"; // --------------------------------------------------------------------------- // Rendering // --------------------------------------------------------------------------- /// Render the help overlay as a centered dialog. pub fn render_help_modal(f: &mut Frame, area: Rect) { let dialog_area = centered_rect(DIALOG_WIDTH, DIALOG_HEIGHT, area); // Clear the background behind the dialog. f.render_widget(Clear, dialog_area); let block = Block::default() .title(" Keyboard Shortcuts ") .borders(Borders::ALL); let paragraph = Paragraph::new(HELP_TEXT) .block(block) .alignment(Alignment::Left); f.render_widget(paragraph, dialog_area); } // --------------------------------------------------------------------------- // Event handling // --------------------------------------------------------------------------- /// Handle an event while the help modal is visible. /// /// `Esc` closes the modal; all other keys are also consumed so the modal /// intercepts all input while open. Always returns `true`. pub fn handle_help_modal_event(event: Event, state: &mut AppState) -> bool { let Event::Key(key) = event else { return true; }; if key.kind != KeyEventKind::Press { return true; } if key.code == KeyCode::Esc { state.show_help = false; } true } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use ratatui::crossterm::event::{KeyEvent, KeyEventState, KeyModifiers}; use super::*; fn press(code: KeyCode) -> Event { Event::Key(KeyEvent { code, modifiers: KeyModifiers::NONE, kind: KeyEventKind::Press, state: KeyEventState::NONE, }) } fn release(code: KeyCode) -> Event { Event::Key(KeyEvent { code, modifiers: KeyModifiers::NONE, kind: KeyEventKind::Release, state: KeyEventState::NONE, }) } /// `Esc` closes the help modal. #[test] fn esc_closes_help() { let mut state = AppState::new(); state.show_help = true; let consumed = handle_help_modal_event(press(KeyCode::Esc), &mut state); assert!(consumed); assert!(!state.show_help); } /// Any other key is consumed but leaves the modal open. #[test] fn other_key_consumed_modal_stays_open() { let mut state = AppState::new(); state.show_help = true; let consumed = handle_help_modal_event(press(KeyCode::Char('x')), &mut state); assert!(consumed); assert!(state.show_help); } /// Key-release events are consumed (modal intercepts all input). #[test] fn key_release_consumed() { let mut state = AppState::new(); state.show_help = true; let consumed = handle_help_modal_event(release(KeyCode::Esc), &mut state); assert!(consumed); // Release does NOT close the modal. assert!(state.show_help); } /// Non-key events are consumed. #[test] fn non_key_event_consumed() { let mut state = AppState::new(); state.show_help = true; let consumed = handle_help_modal_event(Event::FocusGained, &mut state); assert!(consumed); } /// `centered_rect` places the dialog in the middle of the area. #[test] fn centered_rect_centers_correctly() { let area = Rect { x: 0, y: 0, width: 80, height: 24, }; let r = centered_rect(32, 14, area); // x = (80 - 32) / 2 = 24 assert_eq!(r.x, 24); // y = (24 - 14) / 2 = 5 assert_eq!(r.y, 5); assert_eq!(r.width, 32); assert_eq!(r.height, 14); } }