You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

181 lines
5.2 KiB
Rust

//! 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 = 32;
const DIALOG_HEIGHT: u16 = 15;
/// 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\
\u{2190}/\u{2192} h/l scroll lr\n\
Tab cycle panes\n\
Enter open/select\n\
Esc go back\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);
}
}