use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Paragraph, Widget},
};
use rtcom_core::{LineEnding, LineEndingConfig};
use crate::modal::{centred_rect, Dialog, DialogAction, DialogOutcome};
const FIELD_OMAP: usize = 0;
const FIELD_IMAP: usize = 1;
const FIELD_EMAP: usize = 2;
const ACTION_APPLY_LIVE: usize = 3;
const ACTION_APPLY_SAVE: usize = 4;
const ACTION_CANCEL: usize = 5;
const CURSOR_MAX: usize = 6;
pub struct LineEndingsDialog {
#[allow(dead_code, reason = "reserved for T17 revert-on-cancel path")]
initial: LineEndingConfig,
pending: LineEndingConfig,
cursor: usize,
}
impl LineEndingsDialog {
#[must_use]
pub const fn new(initial_config: LineEndingConfig) -> Self {
Self {
initial: initial_config,
pending: initial_config,
cursor: FIELD_OMAP,
}
}
#[must_use]
pub const fn cursor(&self) -> usize {
self.cursor
}
#[must_use]
pub const fn pending(&self) -> &LineEndingConfig {
&self.pending
}
const fn move_up(&mut self) {
self.cursor = if self.cursor == 0 {
CURSOR_MAX - 1
} else {
self.cursor - 1
};
}
const fn move_down(&mut self) {
self.cursor = (self.cursor + 1) % CURSOR_MAX;
}
const fn cycle_current_field(&mut self) {
match self.cursor {
FIELD_OMAP => self.pending.omap = cycle_line_ending(self.pending.omap),
FIELD_IMAP => self.pending.imap = cycle_line_ending(self.pending.imap),
FIELD_EMAP => self.pending.emap = cycle_line_ending(self.pending.emap),
_ => {}
}
}
const fn activate(&mut self) -> DialogOutcome {
match self.cursor {
FIELD_OMAP | FIELD_IMAP | FIELD_EMAP => {
self.cycle_current_field();
DialogOutcome::Consumed
}
ACTION_APPLY_LIVE => {
DialogOutcome::Action(DialogAction::ApplyLineEndingsLive(self.pending))
}
ACTION_APPLY_SAVE => {
DialogOutcome::Action(DialogAction::ApplyLineEndingsAndSave(self.pending))
}
ACTION_CANCEL => DialogOutcome::Close,
_ => DialogOutcome::Consumed,
}
}
fn field_line(&self, field_idx: usize, label: &'static str, value: LineEnding) -> Line<'_> {
let selected = self.cursor == field_idx;
let prefix = if selected { "> " } else { " " };
let text = format!("{prefix}{label:<6} {}", line_ending_label(value));
if selected {
Line::from(Span::styled(
text,
Style::default().add_modifier(Modifier::REVERSED),
))
} else {
Line::from(Span::raw(text))
}
}
fn action_line(
&self,
action_idx: usize,
label: &'static str,
shortcut: &'static str,
) -> Line<'_> {
let selected = self.cursor == action_idx;
let prefix = if selected { "> " } else { " " };
let text = format!("{prefix}{label:<18} {shortcut}");
if selected {
Line::from(Span::styled(
text,
Style::default().add_modifier(Modifier::REVERSED),
))
} else {
Line::from(Span::raw(text))
}
}
}
impl Dialog for LineEndingsDialog {
#[allow(
clippy::unnecessary_literal_bound,
reason = "trait signature must remain &str"
)]
fn title(&self) -> &str {
"Line endings"
}
fn preferred_size(&self, outer: Rect) -> Rect {
centred_rect(outer, 46, 20)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
let block = Block::bordered().title("Line endings");
let inner = block.inner(area);
block.render(area, buf);
let cfg = &self.pending;
let sep_width = usize::from(inner.width);
let sep_line = Line::from(Span::styled(
"-".repeat(sep_width),
Style::default().add_modifier(Modifier::DIM),
));
let recipe_header = Line::from(Span::styled(
" Recipes:",
Style::default().add_modifier(Modifier::BOLD),
));
let recipe_lines = [
Line::from(Span::styled(
" imap = crlf device sends \\n only",
Style::default().add_modifier(Modifier::DIM),
)),
Line::from(Span::styled(
" lfcr device sends \\r only",
Style::default().add_modifier(Modifier::DIM),
)),
Line::from(Span::styled(
" none device sends \\r\\n",
Style::default().add_modifier(Modifier::DIM),
)),
];
let mut lines = vec![
Line::from(Span::raw("")),
self.field_line(FIELD_OMAP, "OMAP", cfg.omap),
self.field_line(FIELD_IMAP, "IMAP", cfg.imap),
self.field_line(FIELD_EMAP, "EMAP", cfg.emap),
Line::from(Span::raw("")),
sep_line,
Line::from(Span::raw("")),
self.action_line(ACTION_APPLY_LIVE, "[Apply live]", "(F2)"),
self.action_line(ACTION_APPLY_SAVE, "[Apply + Save]", "(F10)"),
self.action_line(ACTION_CANCEL, "[Cancel]", "(Esc)"),
Line::from(Span::raw("")),
recipe_header,
];
lines.extend(recipe_lines);
Paragraph::new(lines).render(inner, buf);
}
fn handle_key(&mut self, key: KeyEvent) -> DialogOutcome {
match key.code {
KeyCode::F(2) => {
return DialogOutcome::Action(DialogAction::ApplyLineEndingsLive(self.pending));
}
KeyCode::F(10) => {
return DialogOutcome::Action(DialogAction::ApplyLineEndingsAndSave(self.pending));
}
_ => {}
}
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(),
KeyCode::Char(' ') => {
self.cycle_current_field();
DialogOutcome::Consumed
}
_ => DialogOutcome::Consumed,
}
}
}
const fn cycle_line_ending(le: LineEnding) -> LineEnding {
match le {
LineEnding::None => LineEnding::AddCrToLf,
LineEnding::AddCrToLf => LineEnding::AddLfToCr,
LineEnding::AddLfToCr => LineEnding::DropCr,
LineEnding::DropCr => LineEnding::DropLf,
LineEnding::DropLf => LineEnding::None,
}
}
const fn line_ending_label(le: LineEnding) -> &'static str {
match le {
LineEnding::None => "none",
LineEnding::AddCrToLf => "crlf",
LineEnding::AddLfToCr => "lfcr",
LineEnding::DropCr => "igncr",
LineEnding::DropLf => "ignlf",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::modal::DialogAction;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use rtcom_core::{LineEnding, LineEndingConfig};
const fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn default_dialog() -> LineEndingsDialog {
LineEndingsDialog::new(LineEndingConfig::default())
}
#[test]
fn starts_on_omap() {
let d = default_dialog();
assert_eq!(d.cursor(), 0);
}
#[test]
fn cursor_span_is_six() {
let mut d = default_dialog();
for _ in 0..5 {
d.handle_key(key(KeyCode::Down));
}
assert_eq!(d.cursor(), 5);
d.handle_key(key(KeyCode::Down));
assert_eq!(d.cursor(), 0); }
#[test]
fn space_cycles_current_field() {
let mut d = default_dialog();
let before = d.pending().omap;
d.handle_key(key(KeyCode::Char(' ')));
assert_ne!(d.pending().omap, before);
}
#[test]
fn enter_on_field_cycles() {
let mut d = default_dialog();
let before = d.pending().omap;
d.handle_key(key(KeyCode::Enter));
assert_ne!(d.pending().omap, before);
}
#[test]
fn f2_emits_apply_line_endings_live() {
let mut d = default_dialog();
let out = d.handle_key(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE));
assert!(matches!(
out,
DialogOutcome::Action(DialogAction::ApplyLineEndingsLive(_))
));
}
#[test]
fn f10_emits_apply_line_endings_and_save() {
let mut d = default_dialog();
let out = d.handle_key(KeyEvent::new(KeyCode::F(10), KeyModifiers::NONE));
assert!(matches!(
out,
DialogOutcome::Action(DialogAction::ApplyLineEndingsAndSave(_))
));
}
#[test]
fn esc_closes() {
let mut d = default_dialog();
let out = d.handle_key(key(KeyCode::Esc));
assert!(matches!(out, DialogOutcome::Close));
}
#[test]
fn enter_on_apply_live_button_emits_action() {
let mut d = default_dialog();
for _ in 0..3 {
d.handle_key(key(KeyCode::Down));
} let out = d.handle_key(key(KeyCode::Enter));
assert!(matches!(
out,
DialogOutcome::Action(DialogAction::ApplyLineEndingsLive(_))
));
}
#[test]
fn enter_on_cancel_closes() {
let mut d = default_dialog();
for _ in 0..5 {
d.handle_key(key(KeyCode::Down));
} let out = d.handle_key(key(KeyCode::Enter));
assert!(matches!(out, DialogOutcome::Close));
}
#[test]
fn j_k_nav() {
let mut d = default_dialog();
d.handle_key(key(KeyCode::Char('j')));
assert_eq!(d.cursor(), 1);
d.handle_key(key(KeyCode::Char('k')));
assert_eq!(d.cursor(), 0);
}
#[test]
fn preferred_size_accommodates_recipe() {
use ratatui::layout::Rect;
let d = default_dialog();
let outer = Rect {
x: 0,
y: 0,
width: 80,
height: 24,
};
let pref = d.preferred_size(outer);
assert!(pref.width >= 46, "expected >=46 cols, got {}", pref.width);
assert!(pref.height >= 20, "expected >=20 rows, got {}", pref.height);
}
#[test]
fn dialog_renders_recipe_hint() {
use ratatui::{backend::TestBackend, Terminal};
let d = default_dialog();
let backend = TestBackend::new(60, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let area = d.preferred_size(f.area());
d.render(area, f.buffer_mut());
})
.unwrap();
let buf_dump = format!("{}", terminal.backend());
assert!(
buf_dump.contains("Recipes:"),
"missing recipe header in:\n{buf_dump}"
);
assert!(
buf_dump.contains("crlf"),
"missing 'crlf' mention in:\n{buf_dump}"
);
}
#[test]
fn cycle_order_covers_every_variant() {
let mut le = LineEnding::None;
for _ in 0..5 {
le = cycle_line_ending(le);
}
assert_eq!(le, LineEnding::None);
}
#[test]
fn cycling_imap_does_not_touch_omap_or_emap() {
let mut d = default_dialog();
d.handle_key(key(KeyCode::Down));
assert_eq!(d.cursor(), 1);
d.handle_key(key(KeyCode::Char(' ')));
assert_ne!(d.pending().imap, LineEnding::None);
assert_eq!(d.pending().omap, LineEnding::None);
assert_eq!(d.pending().emap, LineEnding::None);
}
}