feat(tui): add quit confirmation dialog and help modal
Quit dialog: centered 26×5 Clear+Block overlay triggered by q/Q, confirms with q, dismisses with Esc. Help modal: centered 32×14 overlay triggered by ?, lists all keyboard shortcuts, dismisses with Esc. Both modals intercept all input while open. Wired into run.rs dispatch. Closes claudbg-1e1c Closes claudbg-1tlk Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>main
parent
06b0c7cc57
commit
523bf4b89c
@ -0,0 +1,179 @@
|
|||||||
|
//! 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 = 14;
|
||||||
|
|
||||||
|
/// 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";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
//! TUI modal overlays.
|
||||||
|
//!
|
||||||
|
//! - [`quit_dialog`] — "Quit?" confirmation dialog
|
||||||
|
//! - [`help_modal`] — keyboard-shortcut reference card
|
||||||
|
|
||||||
|
pub mod help_modal;
|
||||||
|
pub mod quit_dialog;
|
||||||
@ -0,0 +1,190 @@
|
|||||||
|
//! Quit confirmation dialog modal.
|
||||||
|
//!
|
||||||
|
//! Renders a small centered overlay asking the user to confirm quitting.
|
||||||
|
//! Pressing `q`/`Q` confirms; pressing `Esc` dismisses.
|
||||||
|
|
||||||
|
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 = 26;
|
||||||
|
const DIALOG_HEIGHT: u16 = 5;
|
||||||
|
|
||||||
|
/// 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Rendering
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Render the quit confirmation dialog as a centered overlay.
|
||||||
|
pub fn render_quit_dialog(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(" Quit? ")
|
||||||
|
.borders(Borders::ALL);
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(" q = yes Esc = no ")
|
||||||
|
.block(block)
|
||||||
|
.alignment(Alignment::Center);
|
||||||
|
|
||||||
|
f.render_widget(paragraph, dialog_area);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Event handling
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Handle an event while the quit dialog is visible.
|
||||||
|
///
|
||||||
|
/// All keys are consumed (the dialog intercepts input). Returns `true`
|
||||||
|
/// unconditionally so the caller knows the event was handled.
|
||||||
|
pub fn handle_quit_dialog_event(event: Event, state: &mut AppState) -> bool {
|
||||||
|
let Event::Key(key) = event else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
if key.kind != KeyEventKind::Press {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('q') | KeyCode::Char('Q') => {
|
||||||
|
state.should_quit = true;
|
||||||
|
}
|
||||||
|
KeyCode::Esc => {
|
||||||
|
state.show_quit_dialog = 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `q` confirms quit.
|
||||||
|
#[test]
|
||||||
|
fn q_confirms_quit() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.show_quit_dialog = true;
|
||||||
|
let consumed = handle_quit_dialog_event(press(KeyCode::Char('q')), &mut state);
|
||||||
|
assert!(consumed);
|
||||||
|
assert!(state.should_quit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Q` also confirms quit.
|
||||||
|
#[test]
|
||||||
|
fn big_q_confirms_quit() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.show_quit_dialog = true;
|
||||||
|
handle_quit_dialog_event(press(KeyCode::Char('Q')), &mut state);
|
||||||
|
assert!(state.should_quit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Esc` dismisses the dialog without quitting.
|
||||||
|
#[test]
|
||||||
|
fn esc_dismisses_dialog() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.show_quit_dialog = true;
|
||||||
|
let consumed = handle_quit_dialog_event(press(KeyCode::Esc), &mut state);
|
||||||
|
assert!(consumed);
|
||||||
|
assert!(!state.show_quit_dialog);
|
||||||
|
assert!(!state.should_quit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Any other key is consumed but does nothing else.
|
||||||
|
#[test]
|
||||||
|
fn other_key_consumed() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.show_quit_dialog = true;
|
||||||
|
let consumed = handle_quit_dialog_event(press(KeyCode::Char('x')), &mut state);
|
||||||
|
assert!(consumed);
|
||||||
|
assert!(state.show_quit_dialog);
|
||||||
|
assert!(!state.should_quit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Key-release events are consumed (dialog intercepts all input).
|
||||||
|
#[test]
|
||||||
|
fn key_release_consumed() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.show_quit_dialog = true;
|
||||||
|
let consumed = handle_quit_dialog_event(release(KeyCode::Char('q')), &mut state);
|
||||||
|
assert!(consumed);
|
||||||
|
// Release should NOT trigger quit.
|
||||||
|
assert!(!state.should_quit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Non-key events are consumed.
|
||||||
|
#[test]
|
||||||
|
fn non_key_event_consumed() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.show_quit_dialog = true;
|
||||||
|
let consumed = handle_quit_dialog_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(26, 5, area);
|
||||||
|
// x = (80 - 26) / 2 = 27
|
||||||
|
assert_eq!(r.x, 27);
|
||||||
|
// y = (24 - 5) / 2 = 9
|
||||||
|
assert_eq!(r.y, 9);
|
||||||
|
assert_eq!(r.width, 26);
|
||||||
|
assert_eq!(r.height, 5);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue