use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Paragraph, Widget},
};
use rtcom_config::ModalStyle;
use rtcom_core::{LineEndingConfig, ModemLineSnapshot, SerialConfig};
use crate::{
menu::{
confirm::ConfirmDialog, line_endings::LineEndingsDialog, modem_control::ModemControlDialog,
screen_options::ScreenOptionsDialog, serial_port::SerialPortSetupDialog,
},
modal::{Dialog, DialogAction, DialogOutcome},
};
const SERIAL_PORT_SETUP_INDEX: usize = 0;
const LINE_ENDINGS_INDEX: usize = 1;
const MODEM_CONTROL_INDEX: usize = 2;
const WRITE_PROFILE_INDEX: usize = 3;
const READ_PROFILE_INDEX: usize = 4;
const SCREEN_OPTIONS_INDEX: usize = 5;
pub struct RootMenu {
items: &'static [&'static str],
selected: usize,
initial_config: SerialConfig,
initial_line_endings: LineEndingConfig,
initial_modem: ModemLineSnapshot,
initial_modal_style: ModalStyle,
cli_overrides: Vec<&'static str>,
}
const ITEMS: &[&str] = &[
"Serial port setup", "Line endings", "Modem control", "Write profile", "Read profile", "Screen options", "Exit menu", ];
const EXIT_INDEX: usize = 6;
const SEPARATORS_AFTER: &[usize] = &[2, 4];
impl RootMenu {
#[must_use]
pub const fn new(
initial_config: SerialConfig,
initial_line_endings: LineEndingConfig,
initial_modem: ModemLineSnapshot,
initial_modal_style: ModalStyle,
cli_overrides: Vec<&'static str>,
) -> Self {
Self {
items: ITEMS,
selected: 0,
initial_config,
initial_line_endings,
initial_modem,
initial_modal_style,
cli_overrides,
}
}
#[must_use]
pub const fn selected(&self) -> usize {
self.selected
}
#[must_use]
pub const fn items(&self) -> &'static [&'static str] {
self.items
}
const fn move_up(&mut self) {
if self.selected == 0 {
self.selected = self.items.len() - 1;
} else {
self.selected -= 1;
}
}
const fn move_down(&mut self) {
if self.selected + 1 >= self.items.len() {
self.selected = 0;
} else {
self.selected += 1;
}
}
fn activate(&self) -> DialogOutcome {
match self.selected {
EXIT_INDEX => DialogOutcome::Close,
SERIAL_PORT_SETUP_INDEX => DialogOutcome::Push(Box::new(SerialPortSetupDialog::new(
self.initial_config,
self.cli_overrides.clone(),
))),
LINE_ENDINGS_INDEX => {
DialogOutcome::Push(Box::new(LineEndingsDialog::new(self.initial_line_endings)))
}
MODEM_CONTROL_INDEX => {
DialogOutcome::Push(Box::new(ModemControlDialog::new(self.initial_modem)))
}
WRITE_PROFILE_INDEX => DialogOutcome::Push(Box::new(ConfirmDialog::new(
"Write profile",
"Save current configuration to profile file on disk?",
DialogAction::WriteProfile,
))),
READ_PROFILE_INDEX => DialogOutcome::Push(Box::new(ConfirmDialog::new(
"Read profile",
"Reload profile from disk? Unsaved changes will be lost.",
DialogAction::ReadProfile,
))),
SCREEN_OPTIONS_INDEX => {
DialogOutcome::Push(Box::new(ScreenOptionsDialog::new(self.initial_modal_style)))
}
_ => {
let title = self.items[self.selected];
DialogOutcome::Push(Box::new(crate::menu::PlaceholderDialog::new(title)))
}
}
}
}
impl Dialog for RootMenu {
#[allow(
clippy::unnecessary_literal_bound,
reason = "trait signature must remain &str"
)]
fn title(&self) -> &str {
"Configuration"
}
fn render(&self, area: Rect, buf: &mut Buffer) {
let block = Block::bordered().title("Configuration");
let inner = block.inner(area);
block.render(area, buf);
let mut lines: Vec<Line<'_>> =
Vec::with_capacity(self.items.len() + SEPARATORS_AFTER.len());
for (idx, item) in self.items.iter().enumerate() {
let style = if idx == self.selected {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
};
let prefix = if idx == self.selected { "> " } else { " " };
lines.push(Line::from(vec![Span::styled(
format!("{prefix}{item}"),
style,
)]));
if SEPARATORS_AFTER.contains(&idx) {
let width = usize::from(inner.width);
lines.push(Line::from(Span::styled(
"-".repeat(width),
Style::default().add_modifier(Modifier::DIM),
)));
}
}
Paragraph::new(lines).render(inner, buf);
}
fn handle_key(&mut self, key: KeyEvent) -> DialogOutcome {
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
self.move_up();
DialogOutcome::Consumed
}
KeyCode::Down | KeyCode::Char('j') => {
self.move_down();
DialogOutcome::Consumed
}
KeyCode::Esc => DialogOutcome::Close,
KeyCode::Enter => self.activate(),
_ => DialogOutcome::Consumed,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
const fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn menu() -> RootMenu {
RootMenu::new(
SerialConfig::default(),
LineEndingConfig::default(),
ModemLineSnapshot::default(),
ModalStyle::default(),
Vec::new(),
)
}
#[test]
fn root_menu_starts_on_first_item() {
let m = menu();
assert_eq!(m.selected(), 0);
}
#[test]
fn root_menu_down_moves_selection() {
let mut m = menu();
m.handle_key(key(KeyCode::Down));
assert_eq!(m.selected(), 1);
}
#[test]
fn root_menu_up_wraps_from_first() {
let mut m = menu();
m.handle_key(key(KeyCode::Up));
assert_eq!(m.selected(), 6);
}
#[test]
fn root_menu_down_wraps_from_last() {
let mut m = menu();
for _ in 0..6 {
m.handle_key(key(KeyCode::Down));
}
assert_eq!(m.selected(), 6);
m.handle_key(key(KeyCode::Down));
assert_eq!(m.selected(), 0);
}
#[test]
fn j_k_vim_bindings_work() {
let mut m = menu();
m.handle_key(key(KeyCode::Char('j')));
assert_eq!(m.selected(), 1);
m.handle_key(key(KeyCode::Char('k')));
assert_eq!(m.selected(), 0);
}
#[test]
fn enter_on_first_item_pushes_serial_setup_dialog() {
let mut m = menu();
let out = m.handle_key(key(KeyCode::Enter));
match out {
DialogOutcome::Push(d) => assert_eq!(d.title(), "Serial port setup"),
_ => panic!("expected Push"),
}
}
#[test]
fn enter_on_exit_closes_menu() {
let mut m = menu();
for _ in 0..6 {
m.handle_key(key(KeyCode::Down));
}
assert_eq!(m.selected(), 6);
let out = m.handle_key(key(KeyCode::Enter));
assert!(matches!(out, DialogOutcome::Close));
}
#[test]
fn esc_closes() {
let mut m = menu();
let out = m.handle_key(key(KeyCode::Esc));
assert!(matches!(out, DialogOutcome::Close));
}
#[test]
fn unknown_key_is_consumed_no_movement() {
let mut m = menu();
let out = m.handle_key(key(KeyCode::Char('x')));
assert!(matches!(out, DialogOutcome::Consumed));
assert_eq!(m.selected(), 0);
}
#[test]
fn new_takes_serial_config() {
let cfg = SerialConfig {
baud_rate: 9600,
..SerialConfig::default()
};
let m = RootMenu::new(
cfg,
LineEndingConfig::default(),
ModemLineSnapshot::default(),
ModalStyle::default(),
Vec::new(),
);
assert_eq!(m.selected(), 0);
}
#[test]
fn enter_on_line_endings_pushes_line_endings_dialog() {
let mut m = menu();
m.handle_key(key(KeyCode::Down));
let out = m.handle_key(key(KeyCode::Enter));
match out {
DialogOutcome::Push(d) => assert_eq!(d.title(), "Line endings"),
_ => panic!("expected Push"),
}
}
#[test]
fn enter_on_modem_control_pushes_modem_control_dialog() {
let mut m = menu();
for _ in 0..2 {
m.handle_key(key(KeyCode::Down));
}
let out = m.handle_key(key(KeyCode::Enter));
match out {
DialogOutcome::Push(d) => assert_eq!(d.title(), "Modem control"),
_ => panic!("expected Push"),
}
}
#[test]
fn enter_on_write_profile_pushes_confirm_dialog() {
let mut m = menu();
for _ in 0..3 {
m.handle_key(key(KeyCode::Down));
}
let out = m.handle_key(key(KeyCode::Enter));
match out {
DialogOutcome::Push(d) => assert_eq!(d.title(), "Write profile"),
_ => panic!("expected Push"),
}
}
#[test]
fn enter_on_read_profile_pushes_confirm_dialog() {
let mut m = menu();
for _ in 0..4 {
m.handle_key(key(KeyCode::Down));
}
let out = m.handle_key(key(KeyCode::Enter));
match out {
DialogOutcome::Push(d) => assert_eq!(d.title(), "Read profile"),
_ => panic!("expected Push"),
}
}
#[test]
fn enter_on_screen_options_pushes_screen_options_dialog() {
let mut m = menu();
for _ in 0..5 {
m.handle_key(key(KeyCode::Down));
}
let out = m.handle_key(key(KeyCode::Enter));
match out {
DialogOutcome::Push(d) => assert_eq!(d.title(), "Screen options"),
_ => panic!("expected Push"),
}
}
}