diff --git a/.beans/claudbg-1e1c--tui-quit-confirmation-dialog.md b/.beans/claudbg-1e1c--tui-quit-confirmation-dialog.md index 9061838..e626a51 100644 --- a/.beans/claudbg-1e1c--tui-quit-confirmation-dialog.md +++ b/.beans/claudbg-1e1c--tui-quit-confirmation-dialog.md @@ -1,11 +1,11 @@ --- # claudbg-1e1c title: 'TUI: quit confirmation dialog' -status: todo +status: in-progress type: task priority: normal created_at: 2026-03-30T04:47:46Z -updated_at: 2026-03-30T04:49:03Z +updated_at: 2026-03-30T16:50:26Z parent: claudbg-i6l2 blocked_by: - claudbg-ut9q diff --git a/.beans/claudbg-1tlk--tui-help-modal-listing-all-keyboard-shortcuts.md b/.beans/claudbg-1tlk--tui-help-modal-listing-all-keyboard-shortcuts.md index 3b32507..b1cda2a 100644 --- a/.beans/claudbg-1tlk--tui-help-modal-listing-all-keyboard-shortcuts.md +++ b/.beans/claudbg-1tlk--tui-help-modal-listing-all-keyboard-shortcuts.md @@ -1,11 +1,11 @@ --- # claudbg-1tlk title: 'TUI: help modal listing all keyboard shortcuts' -status: todo +status: in-progress type: task priority: normal created_at: 2026-03-30T04:48:38Z -updated_at: 2026-03-30T04:49:03Z +updated_at: 2026-03-30T16:50:26Z parent: claudbg-i6l2 blocked_by: - claudbg-ut9q diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 60c0911..6269a76 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -3,7 +3,9 @@ //! - [`state`] — application state model (pure data, no I/O) //! - [`run`] — terminal setup, main event loop, and teardown //! - [`screens`] — per-screen rendering and event-handling logic +//! - [`modals`] — overlay dialogs (quit confirmation, help) +pub mod modals; pub mod run; pub mod screens; pub mod state; diff --git a/src/tui/modals/help_modal.rs b/src/tui/modals/help_modal.rs new file mode 100644 index 0000000..70d92b3 --- /dev/null +++ b/src/tui/modals/help_modal.rs @@ -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); + } +} diff --git a/src/tui/modals/mod.rs b/src/tui/modals/mod.rs new file mode 100644 index 0000000..dc7c7ba --- /dev/null +++ b/src/tui/modals/mod.rs @@ -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; diff --git a/src/tui/modals/quit_dialog.rs b/src/tui/modals/quit_dialog.rs new file mode 100644 index 0000000..96c1668 --- /dev/null +++ b/src/tui/modals/quit_dialog.rs @@ -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); + } +} diff --git a/src/tui/run.rs b/src/tui/run.rs index d0b2dbd..704a441 100644 --- a/src/tui/run.rs +++ b/src/tui/run.rs @@ -21,6 +21,8 @@ use ratatui::Terminal; use ratatui::backend::CrosstermBackend; use crate::error::Result; +use crate::tui::modals::help_modal::{handle_help_modal_event, render_help_modal}; +use crate::tui::modals::quit_dialog::{handle_quit_dialog_event, render_quit_dialog}; use crate::tui::screens::session_list::{handle_session_list_event, render_session_list}; use crate::tui::state::{AppState, Screen}; @@ -81,6 +83,8 @@ fn install_panic_hook() { // --------------------------------------------------------------------------- /// Draw a single frame, dispatching to the appropriate screen renderer. +/// +/// Modals are rendered on top of the base screen when active. fn render(frame: &mut ratatui::Frame, state: &AppState) { let area = frame.area(); match &state.screen { @@ -94,6 +98,13 @@ fn render(frame: &mut ratatui::Frame, state: &AppState) { frame.render_widget(block, area); } } + + // Overlay modals on top of the active screen. + if state.show_quit_dialog { + render_quit_dialog(frame, area); + } else if state.show_help { + render_help_modal(frame, area); + } } // --------------------------------------------------------------------------- @@ -102,9 +113,21 @@ fn render(frame: &mut ratatui::Frame, state: &AppState) { /// Handle a single crossterm [`Event`], mutating `state` as needed. /// -/// Dispatches to the appropriate screen handler first; falls back to global -/// keybindings for any event not consumed by the screen. +/// Modal dialogs are checked first and intercept all input while open. +/// If no modal is active, dispatches to the appropriate screen handler, +/// then falls back to global keybindings for any event not consumed by +/// the screen. fn handle_event(event: Event, state: &mut AppState) { + // Modal dialogs intercept all input while visible. + if state.show_quit_dialog { + handle_quit_dialog_event(event, state); + return; + } + if state.show_help { + handle_help_modal_event(event, state); + return; + } + let consumed = match &state.screen { Screen::SessionList => handle_session_list_event(event.clone(), state), // Future screens will add their own handlers here. @@ -178,15 +201,25 @@ mod tests { }) } - /// Pressing `q` sets `should_quit`. + /// Pressing `q` on the session-list screen shows the quit dialog. #[test] - fn q_sets_should_quit() { + fn q_on_session_list_shows_quit_dialog() { let mut state = AppState::new(); handle_event(key_press(KeyCode::Char('q')), &mut state); + assert!(state.show_quit_dialog); + assert!(!state.should_quit); + } + + /// Pressing `q` on the transcript screen (global fallback) sets `should_quit`. + #[test] + fn q_on_transcript_sets_should_quit() { + let mut state = AppState::new(); + state.enter_transcript("some-session"); + handle_event(key_press(KeyCode::Char('q')), &mut state); assert!(state.should_quit); } - /// Pressing `Esc` calls `go_back` (returns to SessionList). + /// Pressing `Esc` on the transcript screen calls `go_back` (returns to SessionList). #[test] fn esc_goes_back() { let mut state = AppState::new(); diff --git a/src/tui/screens/session_list.rs b/src/tui/screens/session_list.rs index 4edd7be..1a72fbc 100644 --- a/src/tui/screens/session_list.rs +++ b/src/tui/screens/session_list.rs @@ -152,9 +152,9 @@ pub fn handle_session_list_event(event: Event, state: &mut AppState) -> bool { } true } - // Quit. + // Quit — show confirmation dialog rather than exiting immediately. KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => { - state.should_quit = true; + state.show_quit_dialog = true; true } // Help overlay. @@ -315,25 +315,28 @@ mod tests { } #[test] - fn q_sets_should_quit() { + fn q_shows_quit_dialog() { let mut state = AppState::new(); let consumed = handle_session_list_event(press(KeyCode::Char('q')), &mut state); assert!(consumed); - assert!(state.should_quit); + assert!(state.show_quit_dialog); + assert!(!state.should_quit); } #[test] - fn big_q_sets_should_quit() { + fn big_q_shows_quit_dialog() { let mut state = AppState::new(); handle_session_list_event(press(KeyCode::Char('Q')), &mut state); - assert!(state.should_quit); + assert!(state.show_quit_dialog); + assert!(!state.should_quit); } #[test] - fn esc_sets_should_quit() { + fn esc_shows_quit_dialog() { let mut state = AppState::new(); handle_session_list_event(press(KeyCode::Esc), &mut state); - assert!(state.should_quit); + assert!(state.show_quit_dialog); + assert!(!state.should_quit); } #[test]