use crossterm::event::{Event, KeyCode, KeyEvent, MouseButton, MouseEventKind};
use ratatui::prelude::*;
use super::widgets::{
ButtonDef, KeyResult, button_click_index, paragraph_height, render_buttons, render_help,
render_question, wizard_layout,
};
pub type ButtonKeyResult<State, FullScreen, Result> = KeyResult<(State, FullScreen), Result>;
pub trait ButtonScreen: Sized {
type State;
type Result;
type FullScreen;
fn question(&self) -> String;
const QUESTION_COLOR: Color = Color::Yellow;
fn buttons(&self) -> Vec<ButtonDef>;
fn next(self) -> Self;
fn prev(self) -> Self;
fn with_index(self, index: usize) -> Self;
fn into_continue(self, state: Self::State) -> (Self::State, Self::FullScreen);
fn on_confirm(
self,
state: Self::State,
) -> anyhow::Result<ButtonKeyResult<Self::State, Self::FullScreen, Self::Result>>;
fn handle_event(
self,
state: Self::State,
event: Event,
content_area: Rect,
) -> anyhow::Result<ButtonKeyResult<Self::State, Self::FullScreen, Self::Result>> {
let question = self.question();
match event {
Event::Key(KeyEvent { code, .. }) => match code {
KeyCode::Left | KeyCode::Char('h') => {
Ok(KeyResult::Continue(self.prev().into_continue(state)))
}
KeyCode::Right | KeyCode::Tab | KeyCode::Char('l') => {
Ok(KeyResult::Continue(self.next().into_continue(state)))
}
KeyCode::Enter => self.on_confirm(state),
KeyCode::Esc | KeyCode::Char('q') => Ok(KeyResult::Cancelled),
_ => Ok(KeyResult::Continue(self.into_continue(state))),
},
Event::Mouse(me) if matches!(me.kind, MouseEventKind::Down(MouseButton::Left)) => {
let Ok(n) = u16::try_from(self.buttons().len()) else {
return Ok(KeyResult::Continue(self.into_continue(state)));
};
if let Some(idx) = button_click_index(content_area, &question, n, me.column, me.row)
{
self.with_index(idx).on_confirm(state)
} else {
Ok(KeyResult::Continue(self.into_continue(state)))
}
}
_ => Ok(KeyResult::Continue(self.into_continue(state))),
}
}
fn render(&self, frame: &mut Frame, area: Rect) {
let question = self.question();
let chunks = wizard_layout(
area,
&[
Constraint::Length(paragraph_height(&question, area.width, 2)),
Constraint::Length(3),
Constraint::Length(1),
Constraint::Min(1),
],
);
render_question(frame, chunks[0], &question, Self::QUESTION_COLOR);
render_buttons(frame, chunks[1], &self.buttons());
render_help(frame, chunks[3], &crate::t!("button-screen-help"));
}
}
#[cfg(test)]
mod tests {
use ratatui::backend::TestBackend;
use super::super::test_utils::render_to_string;
use super::*;
#[derive(Clone, PartialEq, Debug)]
struct TestButtons {
yes: bool,
}
#[derive(Clone, PartialEq, Debug)]
enum TestScreen {
Test(bool),
}
#[derive(Clone, PartialEq, Debug)]
struct TestState;
#[derive(Clone, PartialEq, Debug)]
struct TestResult(bool);
impl ButtonScreen for TestButtons {
type State = TestState;
type Result = TestResult;
type FullScreen = TestScreen;
fn question(&self) -> String {
"Test question?".to_string()
}
fn buttons(&self) -> Vec<ButtonDef> {
vec![
ButtonDef {
label: "Yes".to_string(),
selected: self.yes,
color: None,
},
ButtonDef {
label: "No".to_string(),
selected: !self.yes,
color: None,
},
]
}
fn next(self) -> Self {
TestButtons { yes: !self.yes }
}
fn prev(self) -> Self {
TestButtons { yes: !self.yes }
}
fn with_index(self, index: usize) -> Self {
TestButtons { yes: index == 0 }
}
fn into_continue(self, state: TestState) -> (TestState, TestScreen) {
(state, TestScreen::Test(self.yes))
}
fn on_confirm(
self,
_state: TestState,
) -> anyhow::Result<KeyResult<(TestState, TestScreen), TestResult>> {
Ok(KeyResult::Complete(TestResult(self.yes)))
}
}
fn test_key(code: KeyCode) -> Event {
Event::Key(KeyEvent::new(code, crossterm::event::KeyModifiers::NONE))
}
fn test_area() -> Rect {
Rect::new(0, 0, 80, 24)
}
#[test]
fn button_screen_prev_on_left() {
let result = TestButtons { yes: true }
.handle_event(TestState, test_key(KeyCode::Left), test_area())
.unwrap();
assert_eq!(
result,
KeyResult::Continue((TestState, TestScreen::Test(false)))
);
}
#[test]
fn button_screen_next_on_right() {
let result = TestButtons { yes: false }
.handle_event(TestState, test_key(KeyCode::Right), test_area())
.unwrap();
assert_eq!(
result,
KeyResult::Continue((TestState, TestScreen::Test(true)))
);
}
#[test]
fn button_screen_next_on_tab() {
let result = TestButtons { yes: true }
.handle_event(TestState, test_key(KeyCode::Tab), test_area())
.unwrap();
assert_eq!(
result,
KeyResult::Continue((TestState, TestScreen::Test(false)))
);
}
#[test]
fn button_screen_prev_on_h() {
let result = TestButtons { yes: true }
.handle_event(TestState, test_key(KeyCode::Char('h')), test_area())
.unwrap();
assert_eq!(
result,
KeyResult::Continue((TestState, TestScreen::Test(false)))
);
}
#[test]
fn button_screen_next_on_l() {
let result = TestButtons { yes: false }
.handle_event(TestState, test_key(KeyCode::Char('l')), test_area())
.unwrap();
assert_eq!(
result,
KeyResult::Continue((TestState, TestScreen::Test(true)))
);
}
#[test]
fn button_screen_confirm_on_enter() {
let result = TestButtons { yes: true }
.handle_event(TestState, test_key(KeyCode::Enter), test_area())
.unwrap();
assert_eq!(result, KeyResult::Complete(TestResult(true)));
}
#[test]
fn button_screen_cancel_on_esc() {
let result = TestButtons { yes: true }
.handle_event(TestState, test_key(KeyCode::Esc), test_area())
.unwrap();
assert_eq!(result, KeyResult::Cancelled);
}
#[test]
fn button_screen_cancel_on_q() {
let result = TestButtons { yes: true }
.handle_event(TestState, test_key(KeyCode::Char('q')), test_area())
.unwrap();
assert_eq!(result, KeyResult::Cancelled);
}
#[test]
fn button_screen_noop_on_other_key() {
let result = TestButtons { yes: true }
.handle_event(TestState, test_key(KeyCode::Char('x')), test_area())
.unwrap();
assert_eq!(
result,
KeyResult::Continue((TestState, TestScreen::Test(true)))
);
}
#[test]
fn button_screen_click_first_button_confirms() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let event = Event::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 10,
row: 6,
modifiers: crossterm::event::KeyModifiers::NONE,
});
let result = TestButtons { yes: false }
.handle_event(TestState, event, test_area())
.unwrap();
assert_eq!(result, KeyResult::Complete(TestResult(true)));
}
#[test]
fn button_screen_click_second_button_confirms() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let event = Event::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 65,
row: 6,
modifiers: crossterm::event::KeyModifiers::NONE,
});
let result = TestButtons { yes: true }
.handle_event(TestState, event, test_area())
.unwrap();
assert_eq!(result, KeyResult::Complete(TestResult(false)));
}
#[test]
fn button_screen_click_outside_is_noop() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let event = Event::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 10,
row: 20,
modifiers: crossterm::event::KeyModifiers::NONE,
});
let result = TestButtons { yes: true }
.handle_event(TestState, event, test_area())
.unwrap();
assert_eq!(
result,
KeyResult::Continue((TestState, TestScreen::Test(true)))
);
}
#[test]
fn button_screen_non_left_click_mouse_is_noop() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let event = Event::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Right),
column: 10,
row: 6,
modifiers: crossterm::event::KeyModifiers::NONE,
});
let result = TestButtons { yes: true }
.handle_event(TestState, event, test_area())
.unwrap();
assert_eq!(
result,
KeyResult::Continue((TestState, TestScreen::Test(true)))
);
}
#[test]
fn button_screen_render_shows_question_and_buttons() {
crate::locale::set_locale("en");
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
let buttons = TestButtons { yes: true };
let content = render_to_string(&mut terminal, |frame| {
buttons.render(frame, frame.area());
});
assert!(content.contains("Test question?"));
assert!(content.contains("Yes"));
assert!(content.contains("No"));
}
}