1use crossterm::event::{KeyCode, KeyEvent};
8use ratatui::{
9 buffer::Buffer,
10 layout::Rect,
11 style::{Modifier, Style},
12 text::{Line, Span},
13 widgets::{Block, Paragraph, Widget},
14};
15use rtcom_config::ModalStyle;
16use rtcom_core::{LineEndingConfig, ModemLineSnapshot, SerialConfig};
17
18use crate::{
19 menu::{
20 confirm::ConfirmDialog, line_endings::LineEndingsDialog, modem_control::ModemControlDialog,
21 screen_options::ScreenOptionsDialog, serial_port::SerialPortSetupDialog,
22 },
23 modal::{Dialog, DialogAction, DialogOutcome},
24};
25
26const SERIAL_PORT_SETUP_INDEX: usize = 0;
29const LINE_ENDINGS_INDEX: usize = 1;
32const MODEM_CONTROL_INDEX: usize = 2;
35const WRITE_PROFILE_INDEX: usize = 3;
39const READ_PROFILE_INDEX: usize = 4;
43const SCREEN_OPTIONS_INDEX: usize = 5;
46
47pub struct RootMenu {
55 items: &'static [&'static str],
56 selected: usize,
57 initial_config: SerialConfig,
60 initial_line_endings: LineEndingConfig,
64 initial_modem: ModemLineSnapshot,
68 initial_modal_style: ModalStyle,
72 cli_overrides: Vec<&'static str>,
78}
79
80const ITEMS: &[&str] = &[
81 "Serial port setup", "Line endings", "Modem control", "Write profile", "Read profile", "Screen options", "Exit menu", ];
91
92const EXIT_INDEX: usize = 6;
94
95const SEPARATORS_AFTER: &[usize] = &[2, 4];
97
98impl RootMenu {
99 #[must_use]
111 pub const fn new(
112 initial_config: SerialConfig,
113 initial_line_endings: LineEndingConfig,
114 initial_modem: ModemLineSnapshot,
115 initial_modal_style: ModalStyle,
116 cli_overrides: Vec<&'static str>,
117 ) -> Self {
118 Self {
119 items: ITEMS,
120 selected: 0,
121 initial_config,
122 initial_line_endings,
123 initial_modem,
124 initial_modal_style,
125 cli_overrides,
126 }
127 }
128
129 #[must_use]
131 pub const fn selected(&self) -> usize {
132 self.selected
133 }
134
135 #[must_use]
137 pub const fn items(&self) -> &'static [&'static str] {
138 self.items
139 }
140
141 const fn move_up(&mut self) {
144 if self.selected == 0 {
145 self.selected = self.items.len() - 1;
146 } else {
147 self.selected -= 1;
148 }
149 }
150
151 const fn move_down(&mut self) {
154 if self.selected + 1 >= self.items.len() {
155 self.selected = 0;
156 } else {
157 self.selected += 1;
158 }
159 }
160
161 fn activate(&self) -> DialogOutcome {
167 match self.selected {
168 EXIT_INDEX => DialogOutcome::Close,
169 SERIAL_PORT_SETUP_INDEX => DialogOutcome::Push(Box::new(SerialPortSetupDialog::new(
170 self.initial_config,
171 self.cli_overrides.clone(),
172 ))),
173 LINE_ENDINGS_INDEX => {
174 DialogOutcome::Push(Box::new(LineEndingsDialog::new(self.initial_line_endings)))
175 }
176 MODEM_CONTROL_INDEX => {
177 DialogOutcome::Push(Box::new(ModemControlDialog::new(self.initial_modem)))
178 }
179 WRITE_PROFILE_INDEX => DialogOutcome::Push(Box::new(ConfirmDialog::new(
180 "Write profile",
181 "Save current configuration to profile file on disk?",
182 DialogAction::WriteProfile,
183 ))),
184 READ_PROFILE_INDEX => DialogOutcome::Push(Box::new(ConfirmDialog::new(
185 "Read profile",
186 "Reload profile from disk? Unsaved changes will be lost.",
187 DialogAction::ReadProfile,
188 ))),
189 SCREEN_OPTIONS_INDEX => {
190 DialogOutcome::Push(Box::new(ScreenOptionsDialog::new(self.initial_modal_style)))
191 }
192 _ => {
193 let title = self.items[self.selected];
194 DialogOutcome::Push(Box::new(crate::menu::PlaceholderDialog::new(title)))
195 }
196 }
197 }
198}
199
200impl Dialog for RootMenu {
201 #[allow(
202 clippy::unnecessary_literal_bound,
203 reason = "trait signature must remain &str"
204 )]
205 fn title(&self) -> &str {
206 "Configuration"
207 }
208
209 fn render(&self, area: Rect, buf: &mut Buffer) {
210 let block = Block::bordered().title("Configuration");
211 let inner = block.inner(area);
212 block.render(area, buf);
213
214 let mut lines: Vec<Line<'_>> =
216 Vec::with_capacity(self.items.len() + SEPARATORS_AFTER.len());
217 for (idx, item) in self.items.iter().enumerate() {
218 let style = if idx == self.selected {
219 Style::default().add_modifier(Modifier::REVERSED)
220 } else {
221 Style::default()
222 };
223 let prefix = if idx == self.selected { "> " } else { " " };
224 lines.push(Line::from(vec![Span::styled(
225 format!("{prefix}{item}"),
226 style,
227 )]));
228 if SEPARATORS_AFTER.contains(&idx) {
229 let width = usize::from(inner.width);
230 lines.push(Line::from(Span::styled(
231 "-".repeat(width),
232 Style::default().add_modifier(Modifier::DIM),
233 )));
234 }
235 }
236
237 Paragraph::new(lines).render(inner, buf);
238 }
239
240 fn handle_key(&mut self, key: KeyEvent) -> DialogOutcome {
241 match key.code {
242 KeyCode::Up | KeyCode::Char('k') => {
243 self.move_up();
244 DialogOutcome::Consumed
245 }
246 KeyCode::Down | KeyCode::Char('j') => {
247 self.move_down();
248 DialogOutcome::Consumed
249 }
250 KeyCode::Esc => DialogOutcome::Close,
251 KeyCode::Enter => self.activate(),
252 _ => DialogOutcome::Consumed,
253 }
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
261
262 const fn key(code: KeyCode) -> KeyEvent {
263 KeyEvent::new(code, KeyModifiers::NONE)
264 }
265
266 fn menu() -> RootMenu {
267 RootMenu::new(
268 SerialConfig::default(),
269 LineEndingConfig::default(),
270 ModemLineSnapshot::default(),
271 ModalStyle::default(),
272 Vec::new(),
273 )
274 }
275
276 #[test]
277 fn root_menu_starts_on_first_item() {
278 let m = menu();
279 assert_eq!(m.selected(), 0);
280 }
281
282 #[test]
283 fn root_menu_down_moves_selection() {
284 let mut m = menu();
285 m.handle_key(key(KeyCode::Down));
286 assert_eq!(m.selected(), 1);
287 }
288
289 #[test]
290 fn root_menu_up_wraps_from_first() {
291 let mut m = menu();
292 m.handle_key(key(KeyCode::Up));
293 assert_eq!(m.selected(), 6);
294 }
295
296 #[test]
297 fn root_menu_down_wraps_from_last() {
298 let mut m = menu();
299 for _ in 0..6 {
300 m.handle_key(key(KeyCode::Down));
301 }
302 assert_eq!(m.selected(), 6);
303 m.handle_key(key(KeyCode::Down));
304 assert_eq!(m.selected(), 0);
305 }
306
307 #[test]
308 fn j_k_vim_bindings_work() {
309 let mut m = menu();
310 m.handle_key(key(KeyCode::Char('j')));
311 assert_eq!(m.selected(), 1);
312 m.handle_key(key(KeyCode::Char('k')));
313 assert_eq!(m.selected(), 0);
314 }
315
316 #[test]
317 fn enter_on_first_item_pushes_serial_setup_dialog() {
318 let mut m = menu();
319 let out = m.handle_key(key(KeyCode::Enter));
320 match out {
321 DialogOutcome::Push(d) => assert_eq!(d.title(), "Serial port setup"),
322 _ => panic!("expected Push"),
323 }
324 }
325
326 #[test]
327 fn enter_on_exit_closes_menu() {
328 let mut m = menu();
329 for _ in 0..6 {
330 m.handle_key(key(KeyCode::Down));
331 }
332 assert_eq!(m.selected(), 6);
333 let out = m.handle_key(key(KeyCode::Enter));
334 assert!(matches!(out, DialogOutcome::Close));
335 }
336
337 #[test]
338 fn esc_closes() {
339 let mut m = menu();
340 let out = m.handle_key(key(KeyCode::Esc));
341 assert!(matches!(out, DialogOutcome::Close));
342 }
343
344 #[test]
345 fn unknown_key_is_consumed_no_movement() {
346 let mut m = menu();
347 let out = m.handle_key(key(KeyCode::Char('x')));
348 assert!(matches!(out, DialogOutcome::Consumed));
349 assert_eq!(m.selected(), 0);
350 }
351
352 #[test]
353 fn new_takes_serial_config() {
354 let cfg = SerialConfig {
356 baud_rate: 9600,
357 ..SerialConfig::default()
358 };
359 let m = RootMenu::new(
360 cfg,
361 LineEndingConfig::default(),
362 ModemLineSnapshot::default(),
363 ModalStyle::default(),
364 Vec::new(),
365 );
366 assert_eq!(m.selected(), 0);
367 }
368
369 #[test]
370 fn enter_on_line_endings_pushes_line_endings_dialog() {
371 let mut m = menu();
372 m.handle_key(key(KeyCode::Down));
374 let out = m.handle_key(key(KeyCode::Enter));
375 match out {
376 DialogOutcome::Push(d) => assert_eq!(d.title(), "Line endings"),
377 _ => panic!("expected Push"),
378 }
379 }
380
381 #[test]
382 fn enter_on_modem_control_pushes_modem_control_dialog() {
383 let mut m = menu();
384 for _ in 0..2 {
385 m.handle_key(key(KeyCode::Down));
386 }
387 let out = m.handle_key(key(KeyCode::Enter));
388 match out {
389 DialogOutcome::Push(d) => assert_eq!(d.title(), "Modem control"),
390 _ => panic!("expected Push"),
391 }
392 }
393
394 #[test]
395 fn enter_on_write_profile_pushes_confirm_dialog() {
396 let mut m = menu();
397 for _ in 0..3 {
398 m.handle_key(key(KeyCode::Down));
399 }
400 let out = m.handle_key(key(KeyCode::Enter));
401 match out {
402 DialogOutcome::Push(d) => assert_eq!(d.title(), "Write profile"),
403 _ => panic!("expected Push"),
404 }
405 }
406
407 #[test]
408 fn enter_on_read_profile_pushes_confirm_dialog() {
409 let mut m = menu();
410 for _ in 0..4 {
411 m.handle_key(key(KeyCode::Down));
412 }
413 let out = m.handle_key(key(KeyCode::Enter));
414 match out {
415 DialogOutcome::Push(d) => assert_eq!(d.title(), "Read profile"),
416 _ => panic!("expected Push"),
417 }
418 }
419
420 #[test]
421 fn enter_on_screen_options_pushes_screen_options_dialog() {
422 let mut m = menu();
423 for _ in 0..5 {
424 m.handle_key(key(KeyCode::Down));
425 }
426 let out = m.handle_key(key(KeyCode::Enter));
427 match out {
428 DialogOutcome::Push(d) => assert_eq!(d.title(), "Screen options"),
429 _ => panic!("expected Push"),
430 }
431 }
432}