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.

216 lines
6.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 = 40;
const DIALOG_HEIGHT: u16 = 48;
/// 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\
/ move cursor\n\
Home/End jump to start/end\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 (msgs=alias)\n\
in>5000 input tokens\n\
out<1000 output tokens\n\
tokens>50000 total tokens\n\
date>2026-01 date (YYYY-MM)\n\
model:* non-empty (wildcard)\n\
Combine: expr AND expr\n\
expr OR expr\n\
\n\
Search (transcript)\n\
t / / open search\n\
/ move cursor\n\
Home/End jump to start/end\n\
n / N next/prev match\n\
Enter apply & close\n\
Esc clear & close\n\
\n\
Global\n\
q/Q quit (confirm)\n\
Ctrl+C quit immediately\n\
? this help\n\
c toggle color\n\
Ctrl+L redraw screen";
// ---------------------------------------------------------------------------
// 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);
}
}