use crossterm::event::{KeyCode, KeyEvent};
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Wrap};
use super::popup::{centered, popup_frame};
use crate::tui::theme::{C_DIM, C_ERROR, C_WARN, C_WHITE};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ConfirmOutcome {
Pending,
Yes,
No,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct Confirm {
pub(crate) title: String,
pub(crate) body: Vec<Line<'static>>,
pub(crate) yes_label: &'static str,
pub(crate) no_label: &'static str,
pub(crate) focus_yes: bool,
pub(crate) danger: bool,
}
impl Confirm {
pub(crate) fn new(
title: impl Into<String>,
body: Vec<Line<'static>>,
yes_label: &'static str,
no_label: &'static str,
) -> Self {
Self {
title: title.into(),
body,
yes_label,
no_label,
focus_yes: false,
danger: false,
}
}
pub(crate) fn danger(mut self) -> Self {
self.danger = true;
self
}
pub(crate) fn handle(&mut self, key: &KeyEvent) -> ConfirmOutcome {
match key.code {
KeyCode::Left
| KeyCode::Right
| KeyCode::Tab
| KeyCode::BackTab
| KeyCode::Char('h')
| KeyCode::Char('l') => {
self.focus_yes = !self.focus_yes;
ConfirmOutcome::Pending
}
KeyCode::Enter => {
if self.focus_yes {
ConfirmOutcome::Yes
} else {
ConfirmOutcome::No
}
}
KeyCode::Char('y') | KeyCode::Char('Y') => ConfirmOutcome::Yes,
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => ConfirmOutcome::No,
_ => ConfirmOutcome::Pending,
}
}
pub(crate) fn draw(&self, frame: &mut Frame, area: Rect) {
let accent = if self.danger { C_ERROR } else { C_WARN };
let popup = centered(60, 50, area);
let inner = popup_frame(frame, popup, &self.title, accent);
let mut lines = self.body.clone();
lines.push(Line::from(""));
lines.push(self.button_row(accent));
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
}
fn button_row(&self, accent: ratatui::style::Color) -> Line<'static> {
let focused = Style::default()
.fg(accent)
.add_modifier(Modifier::BOLD | Modifier::REVERSED);
let blurred = Style::default().fg(C_WHITE);
let (no_style, yes_style) = if self.focus_yes {
(blurred, focused)
} else {
(focused, blurred)
};
Line::from(vec![
Span::styled(format!("[ {} ]", self.no_label), no_style),
Span::styled(" ", Style::default()),
Span::styled(format!("[ {} ]", self.yes_label), yes_style),
Span::styled(" ", Style::default()),
Span::styled(
"←→ choose · enter confirm · esc cancel",
Style::default().fg(C_DIM),
),
])
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::test_terminal;
use crossterm::event::KeyModifiers;
fn press(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::empty())
}
fn dialog() -> Confirm {
Confirm::new(
"Kill agent?",
vec![Line::from("run-1 is still active.")],
"Kill",
"Cancel",
)
}
#[test]
fn focus_starts_on_no_and_enter_declines() {
let mut confirm = dialog();
assert!(!confirm.focus_yes);
assert_eq!(confirm.handle(&press(KeyCode::Enter)), ConfirmOutcome::No);
}
#[test]
fn every_focus_movement_key_flips_the_focused_button() {
for code in [
KeyCode::Left,
KeyCode::Right,
KeyCode::Tab,
KeyCode::BackTab,
KeyCode::Char('h'),
KeyCode::Char('l'),
] {
let mut confirm = dialog();
assert_eq!(confirm.handle(&press(code)), ConfirmOutcome::Pending);
assert!(confirm.focus_yes, "{code:?} should move focus to Yes");
}
}
#[test]
fn enter_activates_the_focused_button() {
let mut confirm = dialog();
confirm.handle(&press(KeyCode::Right));
assert_eq!(confirm.handle(&press(KeyCode::Enter)), ConfirmOutcome::Yes);
}
#[test]
fn y_and_n_answer_directly_and_esc_declines() {
assert_eq!(
dialog().handle(&press(KeyCode::Char('y'))),
ConfirmOutcome::Yes
);
assert_eq!(
dialog().handle(&press(KeyCode::Char('Y'))),
ConfirmOutcome::Yes
);
assert_eq!(
dialog().handle(&press(KeyCode::Char('n'))),
ConfirmOutcome::No
);
assert_eq!(
dialog().handle(&press(KeyCode::Char('N'))),
ConfirmOutcome::No
);
assert_eq!(dialog().handle(&press(KeyCode::Esc)), ConfirmOutcome::No);
}
#[test]
fn stray_keys_never_answer() {
let mut confirm = dialog();
for code in [
KeyCode::Char('x'),
KeyCode::Char(' '),
KeyCode::Up,
KeyCode::F(1),
] {
assert_eq!(confirm.handle(&press(code)), ConfirmOutcome::Pending);
}
assert!(!confirm.focus_yes);
}
#[test]
fn draw_shows_title_body_and_both_buttons() {
let mut terminal = test_terminal();
let confirm = dialog();
terminal
.draw(|frame| confirm.draw(frame, frame.area()))
.unwrap();
let text = terminal.backend().text();
assert!(text.contains(" Kill agent? "));
assert!(text.contains("run-1 is still active."));
assert!(text.contains("[ Cancel ]"));
assert!(text.contains("[ Kill ]"));
}
#[test]
fn draw_renders_the_danger_variant_and_the_yes_focused_state() {
let mut terminal = test_terminal();
let mut confirm = dialog().danger();
confirm.handle(&press(KeyCode::Tab));
assert!(confirm.focus_yes);
terminal
.draw(|frame| confirm.draw(frame, frame.area()))
.unwrap();
assert!(terminal.backend().text().contains("[ Kill ]"));
}
}