1use crossterm::event::{KeyCode, KeyEvent};
18use ratatui::{
19 buffer::Buffer,
20 layout::Rect,
21 style::{Modifier, Style},
22 text::{Line, Span},
23 widgets::{Block, Paragraph, Widget},
24};
25
26use rtcom_core::ModemLineSnapshot;
27
28use crate::modal::{centred_rect, Dialog, DialogAction, DialogOutcome};
29
30const ACTION_RAISE_DTR: usize = 0;
32const ACTION_LOWER_DTR: usize = 1;
34const ACTION_RAISE_RTS: usize = 2;
36const ACTION_LOWER_RTS: usize = 3;
38const ACTION_SEND_BREAK: usize = 4;
40const ACTION_CLOSE: usize = 5;
42
43const CURSOR_MAX: usize = 6;
45
46pub struct ModemControlDialog {
55 current: ModemLineSnapshot,
56 cursor: usize,
57}
58
59impl ModemControlDialog {
60 #[must_use]
64 pub const fn new(current: ModemLineSnapshot) -> Self {
65 Self {
66 current,
67 cursor: ACTION_RAISE_DTR,
68 }
69 }
70
71 #[must_use]
74 pub const fn cursor(&self) -> usize {
75 self.cursor
76 }
77
78 #[must_use]
81 pub const fn current_lines(&self) -> &ModemLineSnapshot {
82 &self.current
83 }
84
85 const fn move_up(&mut self) {
87 self.cursor = if self.cursor == 0 {
88 CURSOR_MAX - 1
89 } else {
90 self.cursor - 1
91 };
92 }
93
94 const fn move_down(&mut self) {
96 self.cursor = (self.cursor + 1) % CURSOR_MAX;
97 }
98
99 const fn activate(&self) -> DialogOutcome {
101 match self.cursor {
102 ACTION_RAISE_DTR => DialogOutcome::Action(DialogAction::SetDtr(true)),
103 ACTION_LOWER_DTR => DialogOutcome::Action(DialogAction::SetDtr(false)),
104 ACTION_RAISE_RTS => DialogOutcome::Action(DialogAction::SetRts(true)),
105 ACTION_LOWER_RTS => DialogOutcome::Action(DialogAction::SetRts(false)),
106 ACTION_SEND_BREAK => DialogOutcome::Action(DialogAction::SendBreak),
107 ACTION_CLOSE => DialogOutcome::Close,
108 _ => DialogOutcome::Consumed,
109 }
110 }
111
112 fn action_line(&self, idx: usize, label: &'static str) -> Line<'_> {
115 let selected = self.cursor == idx;
116 let prefix = if selected { "> " } else { " " };
117 let text = format!("{prefix}{label}");
118 if selected {
119 Line::from(Span::styled(
120 text,
121 Style::default().add_modifier(Modifier::REVERSED),
122 ))
123 } else {
124 Line::from(Span::raw(text))
125 }
126 }
127
128 fn close_line(&self) -> Line<'_> {
130 let selected = self.cursor == ACTION_CLOSE;
131 let prefix = if selected { "> " } else { " " };
132 let text = format!("{prefix}{:<18} {}", "[Close]", "(Esc)");
133 if selected {
134 Line::from(Span::styled(
135 text,
136 Style::default().add_modifier(Modifier::REVERSED),
137 ))
138 } else {
139 Line::from(Span::raw(text))
140 }
141 }
142}
143
144impl Dialog for ModemControlDialog {
145 #[allow(
146 clippy::unnecessary_literal_bound,
147 reason = "trait signature must remain &str"
148 )]
149 fn title(&self) -> &str {
150 "Modem control"
151 }
152
153 fn preferred_size(&self, outer: Rect) -> Rect {
154 centred_rect(outer, 40, 18)
155 }
156
157 fn render(&self, area: Rect, buf: &mut Buffer) {
158 let block = Block::bordered().title("Modem control");
159 let inner = block.inner(area);
160 block.render(area, buf);
161
162 let sep_width = usize::from(inner.width);
163 let sep_line = Line::from(Span::styled(
164 "-".repeat(sep_width),
165 Style::default().add_modifier(Modifier::DIM),
166 ));
167
168 let dtr_mark = if self.current.dtr { "*" } else { "o" };
169 let rts_mark = if self.current.rts { "*" } else { "o" };
170
171 let lines = vec![
172 Line::from(Span::raw("")),
173 Line::from(Span::raw(" Current output lines:")),
174 Line::from(Span::raw(format!(" DTR: {dtr_mark}"))),
175 Line::from(Span::raw(format!(" RTS: {rts_mark}"))),
176 Line::from(Span::raw("")),
177 sep_line,
178 Line::from(Span::raw("")),
179 self.action_line(ACTION_RAISE_DTR, "Raise DTR"),
180 self.action_line(ACTION_LOWER_DTR, "Lower DTR"),
181 self.action_line(ACTION_RAISE_RTS, "Raise RTS"),
182 self.action_line(ACTION_LOWER_RTS, "Lower RTS"),
183 self.action_line(ACTION_SEND_BREAK, "Send break (250 ms)"),
184 Line::from(Span::raw("")),
185 self.close_line(),
186 ];
187
188 Paragraph::new(lines).render(inner, buf);
189 }
190
191 fn handle_key(&mut self, key: KeyEvent) -> DialogOutcome {
192 match key.code {
193 KeyCode::Up | KeyCode::Char('k') => {
194 self.move_up();
195 DialogOutcome::Consumed
196 }
197 KeyCode::Down | KeyCode::Char('j') => {
198 self.move_down();
199 DialogOutcome::Consumed
200 }
201 KeyCode::Esc => DialogOutcome::Close,
202 KeyCode::Enter => self.activate(),
203 _ => DialogOutcome::Consumed,
204 }
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211 use crate::modal::DialogAction;
212 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
213 use rtcom_core::ModemLineSnapshot;
214
215 const fn key(code: KeyCode) -> KeyEvent {
216 KeyEvent::new(code, KeyModifiers::NONE)
217 }
218
219 fn default_dialog() -> ModemControlDialog {
220 ModemControlDialog::new(ModemLineSnapshot::default())
221 }
222
223 #[test]
224 fn starts_with_raise_dtr_selected() {
225 assert_eq!(default_dialog().cursor(), 0);
226 }
227
228 #[test]
229 fn enter_raise_dtr_emits_set_dtr_true() {
230 let mut d = default_dialog();
231 let out = d.handle_key(key(KeyCode::Enter));
232 assert!(matches!(
233 out,
234 DialogOutcome::Action(DialogAction::SetDtr(true))
235 ));
236 }
237
238 #[test]
239 fn enter_lower_dtr_emits_set_dtr_false() {
240 let mut d = default_dialog();
241 d.handle_key(key(KeyCode::Down));
242 let out = d.handle_key(key(KeyCode::Enter));
243 assert!(matches!(
244 out,
245 DialogOutcome::Action(DialogAction::SetDtr(false))
246 ));
247 }
248
249 #[test]
250 fn enter_raise_rts_emits_set_rts_true() {
251 let mut d = default_dialog();
252 for _ in 0..2 {
253 d.handle_key(key(KeyCode::Down));
254 }
255 let out = d.handle_key(key(KeyCode::Enter));
256 assert!(matches!(
257 out,
258 DialogOutcome::Action(DialogAction::SetRts(true))
259 ));
260 }
261
262 #[test]
263 fn enter_lower_rts_emits_set_rts_false() {
264 let mut d = default_dialog();
265 for _ in 0..3 {
266 d.handle_key(key(KeyCode::Down));
267 }
268 let out = d.handle_key(key(KeyCode::Enter));
269 assert!(matches!(
270 out,
271 DialogOutcome::Action(DialogAction::SetRts(false))
272 ));
273 }
274
275 #[test]
276 fn enter_send_break_emits_send_break() {
277 let mut d = default_dialog();
278 for _ in 0..4 {
279 d.handle_key(key(KeyCode::Down));
280 }
281 let out = d.handle_key(key(KeyCode::Enter));
282 assert!(matches!(
283 out,
284 DialogOutcome::Action(DialogAction::SendBreak)
285 ));
286 }
287
288 #[test]
289 fn enter_on_close_closes() {
290 let mut d = default_dialog();
291 for _ in 0..5 {
292 d.handle_key(key(KeyCode::Down));
293 }
294 let out = d.handle_key(key(KeyCode::Enter));
295 assert!(matches!(out, DialogOutcome::Close));
296 }
297
298 #[test]
299 fn esc_closes() {
300 let mut d = default_dialog();
301 let out = d.handle_key(key(KeyCode::Esc));
302 assert!(matches!(out, DialogOutcome::Close));
303 }
304
305 #[test]
306 fn cursor_wraps() {
307 let mut d = default_dialog();
308 d.handle_key(key(KeyCode::Up));
309 assert_eq!(d.cursor(), 5);
310 d.handle_key(key(KeyCode::Down));
311 assert_eq!(d.cursor(), 0);
312 }
313
314 #[test]
315 fn preferred_size_40x18() {
316 use ratatui::layout::Rect;
317 let d = default_dialog();
318 let outer = Rect {
319 x: 0,
320 y: 0,
321 width: 80,
322 height: 24,
323 };
324 let pref = d.preferred_size(outer);
325 assert_eq!(pref.width, 40);
326 assert_eq!(pref.height, 18);
327 }
328
329 #[test]
330 fn dialog_shows_current_dtr_rts_in_title_area() {
331 let snap = ModemLineSnapshot {
333 dtr: true,
334 rts: false,
335 };
336 let d = ModemControlDialog::new(snap);
337 assert!(d.current_lines().dtr);
338 assert!(!d.current_lines().rts);
339 }
340}