1use crossterm::event::{KeyCode, KeyEvent};
13use ratatui::{
14 buffer::Buffer,
15 layout::Rect,
16 style::{Modifier, Style},
17 text::{Line, Span},
18 widgets::{Block, Paragraph, Widget},
19};
20
21use rtcom_core::{LineEnding, LineEndingConfig};
22
23use crate::modal::{centred_rect, Dialog, DialogAction, DialogOutcome};
24
25const FIELD_OMAP: usize = 0;
27const FIELD_IMAP: usize = 1;
29const FIELD_EMAP: usize = 2;
31
32const ACTION_APPLY_LIVE: usize = 3;
34const ACTION_APPLY_SAVE: usize = 4;
36const ACTION_CANCEL: usize = 5;
38
39const CURSOR_MAX: usize = 6;
41
42pub struct LineEndingsDialog {
54 #[allow(dead_code, reason = "reserved for T17 revert-on-cancel path")]
55 initial: LineEndingConfig,
56 pending: LineEndingConfig,
57 cursor: usize,
58}
59
60impl LineEndingsDialog {
61 #[must_use]
64 pub const fn new(initial_config: LineEndingConfig) -> Self {
65 Self {
66 initial: initial_config,
67 pending: initial_config,
68 cursor: FIELD_OMAP,
69 }
70 }
71
72 #[must_use]
76 pub const fn cursor(&self) -> usize {
77 self.cursor
78 }
79
80 #[must_use]
83 pub const fn pending(&self) -> &LineEndingConfig {
84 &self.pending
85 }
86
87 const fn move_up(&mut self) {
89 self.cursor = if self.cursor == 0 {
90 CURSOR_MAX - 1
91 } else {
92 self.cursor - 1
93 };
94 }
95
96 const fn move_down(&mut self) {
98 self.cursor = (self.cursor + 1) % CURSOR_MAX;
99 }
100
101 const fn cycle_current_field(&mut self) {
104 match self.cursor {
105 FIELD_OMAP => self.pending.omap = cycle_line_ending(self.pending.omap),
106 FIELD_IMAP => self.pending.imap = cycle_line_ending(self.pending.imap),
107 FIELD_EMAP => self.pending.emap = cycle_line_ending(self.pending.emap),
108 _ => {}
109 }
110 }
111
112 const fn activate(&mut self) -> DialogOutcome {
114 match self.cursor {
115 FIELD_OMAP | FIELD_IMAP | FIELD_EMAP => {
116 self.cycle_current_field();
117 DialogOutcome::Consumed
118 }
119 ACTION_APPLY_LIVE => {
120 DialogOutcome::Action(DialogAction::ApplyLineEndingsLive(self.pending))
121 }
122 ACTION_APPLY_SAVE => {
123 DialogOutcome::Action(DialogAction::ApplyLineEndingsAndSave(self.pending))
124 }
125 ACTION_CANCEL => DialogOutcome::Close,
126 _ => DialogOutcome::Consumed,
127 }
128 }
129
130 fn field_line(&self, field_idx: usize, label: &'static str, value: LineEnding) -> Line<'_> {
132 let selected = self.cursor == field_idx;
133 let prefix = if selected { "> " } else { " " };
134 let text = format!("{prefix}{label:<6} {}", line_ending_label(value));
135 if selected {
136 Line::from(Span::styled(
137 text,
138 Style::default().add_modifier(Modifier::REVERSED),
139 ))
140 } else {
141 Line::from(Span::raw(text))
142 }
143 }
144
145 fn action_line(
147 &self,
148 action_idx: usize,
149 label: &'static str,
150 shortcut: &'static str,
151 ) -> Line<'_> {
152 let selected = self.cursor == action_idx;
153 let prefix = if selected { "> " } else { " " };
154 let text = format!("{prefix}{label:<18} {shortcut}");
155 if selected {
156 Line::from(Span::styled(
157 text,
158 Style::default().add_modifier(Modifier::REVERSED),
159 ))
160 } else {
161 Line::from(Span::raw(text))
162 }
163 }
164}
165
166impl Dialog for LineEndingsDialog {
167 #[allow(
168 clippy::unnecessary_literal_bound,
169 reason = "trait signature must remain &str"
170 )]
171 fn title(&self) -> &str {
172 "Line endings"
173 }
174
175 fn preferred_size(&self, outer: Rect) -> Rect {
176 centred_rect(outer, 46, 20)
177 }
178
179 fn render(&self, area: Rect, buf: &mut Buffer) {
180 let block = Block::bordered().title("Line endings");
181 let inner = block.inner(area);
182 block.render(area, buf);
183
184 let cfg = &self.pending;
185 let sep_width = usize::from(inner.width);
186 let sep_line = Line::from(Span::styled(
187 "-".repeat(sep_width),
188 Style::default().add_modifier(Modifier::DIM),
189 ));
190
191 let recipe_header = Line::from(Span::styled(
199 " Recipes:",
200 Style::default().add_modifier(Modifier::BOLD),
201 ));
202 let recipe_lines = [
203 Line::from(Span::styled(
204 " imap = crlf device sends \\n only",
205 Style::default().add_modifier(Modifier::DIM),
206 )),
207 Line::from(Span::styled(
208 " lfcr device sends \\r only",
209 Style::default().add_modifier(Modifier::DIM),
210 )),
211 Line::from(Span::styled(
212 " none device sends \\r\\n",
213 Style::default().add_modifier(Modifier::DIM),
214 )),
215 ];
216
217 let mut lines = vec![
218 Line::from(Span::raw("")),
219 self.field_line(FIELD_OMAP, "OMAP", cfg.omap),
220 self.field_line(FIELD_IMAP, "IMAP", cfg.imap),
221 self.field_line(FIELD_EMAP, "EMAP", cfg.emap),
222 Line::from(Span::raw("")),
223 sep_line,
224 Line::from(Span::raw("")),
225 self.action_line(ACTION_APPLY_LIVE, "[Apply live]", "(F2)"),
226 self.action_line(ACTION_APPLY_SAVE, "[Apply + Save]", "(F10)"),
227 self.action_line(ACTION_CANCEL, "[Cancel]", "(Esc)"),
228 Line::from(Span::raw("")),
229 recipe_header,
230 ];
231 lines.extend(recipe_lines);
232
233 Paragraph::new(lines).render(inner, buf);
234 }
235
236 fn handle_key(&mut self, key: KeyEvent) -> DialogOutcome {
237 match key.code {
240 KeyCode::F(2) => {
241 return DialogOutcome::Action(DialogAction::ApplyLineEndingsLive(self.pending));
242 }
243 KeyCode::F(10) => {
244 return DialogOutcome::Action(DialogAction::ApplyLineEndingsAndSave(self.pending));
245 }
246 _ => {}
247 }
248
249 match key.code {
250 KeyCode::Up | KeyCode::Char('k') => {
251 self.move_up();
252 DialogOutcome::Consumed
253 }
254 KeyCode::Down | KeyCode::Char('j') => {
255 self.move_down();
256 DialogOutcome::Consumed
257 }
258 KeyCode::Esc => DialogOutcome::Close,
259 KeyCode::Enter => self.activate(),
260 KeyCode::Char(' ') => {
261 self.cycle_current_field();
262 DialogOutcome::Consumed
263 }
264 _ => DialogOutcome::Consumed,
265 }
266 }
267}
268
269const fn cycle_line_ending(le: LineEnding) -> LineEnding {
275 match le {
276 LineEnding::None => LineEnding::AddCrToLf,
277 LineEnding::AddCrToLf => LineEnding::AddLfToCr,
278 LineEnding::AddLfToCr => LineEnding::DropCr,
279 LineEnding::DropCr => LineEnding::DropLf,
280 LineEnding::DropLf => LineEnding::None,
281 }
282}
283
284const fn line_ending_label(le: LineEnding) -> &'static str {
286 match le {
287 LineEnding::None => "none",
288 LineEnding::AddCrToLf => "crlf",
289 LineEnding::AddLfToCr => "lfcr",
290 LineEnding::DropCr => "igncr",
291 LineEnding::DropLf => "ignlf",
292 }
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298 use crate::modal::DialogAction;
299 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
300 use rtcom_core::{LineEnding, LineEndingConfig};
301
302 const fn key(code: KeyCode) -> KeyEvent {
303 KeyEvent::new(code, KeyModifiers::NONE)
304 }
305
306 fn default_dialog() -> LineEndingsDialog {
307 LineEndingsDialog::new(LineEndingConfig::default())
308 }
309
310 #[test]
311 fn starts_on_omap() {
312 let d = default_dialog();
313 assert_eq!(d.cursor(), 0);
314 }
315
316 #[test]
317 fn cursor_span_is_six() {
318 let mut d = default_dialog();
319 for _ in 0..5 {
320 d.handle_key(key(KeyCode::Down));
321 }
322 assert_eq!(d.cursor(), 5);
323 d.handle_key(key(KeyCode::Down));
324 assert_eq!(d.cursor(), 0); }
326
327 #[test]
328 fn space_cycles_current_field() {
329 let mut d = default_dialog();
330 let before = d.pending().omap;
331 d.handle_key(key(KeyCode::Char(' ')));
332 assert_ne!(d.pending().omap, before);
333 }
334
335 #[test]
336 fn enter_on_field_cycles() {
337 let mut d = default_dialog();
338 let before = d.pending().omap;
339 d.handle_key(key(KeyCode::Enter));
340 assert_ne!(d.pending().omap, before);
341 }
342
343 #[test]
344 fn f2_emits_apply_line_endings_live() {
345 let mut d = default_dialog();
346 let out = d.handle_key(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE));
347 assert!(matches!(
348 out,
349 DialogOutcome::Action(DialogAction::ApplyLineEndingsLive(_))
350 ));
351 }
352
353 #[test]
354 fn f10_emits_apply_line_endings_and_save() {
355 let mut d = default_dialog();
356 let out = d.handle_key(KeyEvent::new(KeyCode::F(10), KeyModifiers::NONE));
357 assert!(matches!(
358 out,
359 DialogOutcome::Action(DialogAction::ApplyLineEndingsAndSave(_))
360 ));
361 }
362
363 #[test]
364 fn esc_closes() {
365 let mut d = default_dialog();
366 let out = d.handle_key(key(KeyCode::Esc));
367 assert!(matches!(out, DialogOutcome::Close));
368 }
369
370 #[test]
371 fn enter_on_apply_live_button_emits_action() {
372 let mut d = default_dialog();
373 for _ in 0..3 {
374 d.handle_key(key(KeyCode::Down));
375 } let out = d.handle_key(key(KeyCode::Enter));
377 assert!(matches!(
378 out,
379 DialogOutcome::Action(DialogAction::ApplyLineEndingsLive(_))
380 ));
381 }
382
383 #[test]
384 fn enter_on_cancel_closes() {
385 let mut d = default_dialog();
386 for _ in 0..5 {
387 d.handle_key(key(KeyCode::Down));
388 } let out = d.handle_key(key(KeyCode::Enter));
390 assert!(matches!(out, DialogOutcome::Close));
391 }
392
393 #[test]
394 fn j_k_nav() {
395 let mut d = default_dialog();
396 d.handle_key(key(KeyCode::Char('j')));
397 assert_eq!(d.cursor(), 1);
398 d.handle_key(key(KeyCode::Char('k')));
399 assert_eq!(d.cursor(), 0);
400 }
401
402 #[test]
403 fn preferred_size_accommodates_recipe() {
404 use ratatui::layout::Rect;
405 let d = default_dialog();
406 let outer = Rect {
407 x: 0,
408 y: 0,
409 width: 80,
410 height: 24,
411 };
412 let pref = d.preferred_size(outer);
413 assert!(pref.width >= 46, "expected >=46 cols, got {}", pref.width);
414 assert!(pref.height >= 20, "expected >=20 rows, got {}", pref.height);
415 }
416
417 #[test]
418 fn dialog_renders_recipe_hint() {
419 use ratatui::{backend::TestBackend, Terminal};
420 let d = default_dialog();
421 let backend = TestBackend::new(60, 24);
422 let mut terminal = Terminal::new(backend).unwrap();
423 terminal
424 .draw(|f| {
425 let area = d.preferred_size(f.area());
426 d.render(area, f.buffer_mut());
427 })
428 .unwrap();
429 let buf_dump = format!("{}", terminal.backend());
430 assert!(
431 buf_dump.contains("Recipes:"),
432 "missing recipe header in:\n{buf_dump}"
433 );
434 assert!(
435 buf_dump.contains("crlf"),
436 "missing 'crlf' mention in:\n{buf_dump}"
437 );
438 }
439
440 #[test]
441 fn cycle_order_covers_every_variant() {
442 let mut le = LineEnding::None;
444 for _ in 0..5 {
445 le = cycle_line_ending(le);
446 }
447 assert_eq!(le, LineEnding::None);
448 }
449
450 #[test]
451 fn cycling_imap_does_not_touch_omap_or_emap() {
452 let mut d = default_dialog();
453 d.handle_key(key(KeyCode::Down));
455 assert_eq!(d.cursor(), 1);
456 d.handle_key(key(KeyCode::Char(' ')));
457 assert_ne!(d.pending().imap, LineEnding::None);
458 assert_eq!(d.pending().omap, LineEnding::None);
459 assert_eq!(d.pending().emap, LineEnding::None);
460 }
461}