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.
207 lines
5.9 KiB
Rust
207 lines
5.9 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 = 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);
|
|
}
|
|
}
|