1use std::collections::HashSet;
30
31use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
32use ratatui::{
33 layout::Rect,
34 style::{Color, Modifier, Style},
35 text::{Line, Span},
36 widgets::Paragraph,
37 Frame,
38};
39
40use crate::Theme;
41
42#[derive(Debug, Clone, PartialEq)]
46pub enum FieldInput {
47 Text(String),
48 Integer(i64),
49 Float(f64),
50 Boolean(bool),
51 Enum { options: Vec<String>, selected: usize },
53 List(Vec<String>),
55 ReadOnly(String),
57}
58
59#[derive(Debug, Clone)]
61pub struct FormField {
62 pub id: String,
64 pub label: String,
66 pub input: FieldInput,
68 pub required: bool,
70 pub description: Option<String>,
72 pub visible: bool,
76}
77
78impl FormField {
79 pub fn new(
82 id: impl Into<String>,
83 label: impl Into<String>,
84 input: FieldInput,
85 ) -> Self {
86 Self {
87 id: id.into(),
88 label: label.into(),
89 input,
90 required: false,
91 description: None,
92 visible: true,
93 }
94 }
95
96 pub fn required(mut self, required: bool) -> Self { self.required = required; self }
97 pub fn description(mut self, d: impl Into<String>) -> Self {
98 self.description = Some(d.into()); self
99 }
100 pub fn visible(mut self, visible: bool) -> Self { self.visible = visible; self }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum FormEvent {
106 None,
107 FocusMoved,
110 FieldChanged(String),
112 Submit,
114 Cancel,
116}
117
118#[derive(Debug, Clone)]
120pub struct FormState {
121 pub fields: Vec<FormField>,
122 pub focused: usize,
123 pub cursors: Vec<usize>,
125 pub touched: HashSet<String>,
128}
129
130impl FormState {
131 pub fn new(fields: Vec<FormField>) -> Self {
134 let cursors = fields.iter().map(cursor_end_of).collect();
135 let focused = fields
136 .iter()
137 .position(|f| f.visible)
138 .unwrap_or(0);
139 Self { fields, focused, cursors, touched: HashSet::new() }
140 }
141
142 pub fn focus_next(&mut self) -> bool {
145 let from = self.focused;
146 let n = self.fields.len();
147 if n == 0 { return false; }
148 let mut i = from;
149 while i + 1 < n {
150 i += 1;
151 if self.fields[i].visible {
152 self.on_focus_change(from, i);
153 return true;
154 }
155 }
156 false
157 }
158
159 pub fn focus_prev(&mut self) -> bool {
161 let from = self.focused;
162 let mut i = from;
163 while i > 0 {
164 i -= 1;
165 if self.fields[i].visible {
166 self.on_focus_change(from, i);
167 return true;
168 }
169 }
170 false
171 }
172
173 fn on_focus_change(&mut self, from: usize, to: usize) {
174 if from < self.fields.len() {
175 self.touched.insert(self.fields[from].id.clone());
176 }
177 self.focused = to;
178 self.cursors[to] = cursor_end_of(&self.fields[to]);
181 }
182
183 pub fn handle_key(&mut self, key: KeyEvent) -> FormEvent {
185 if self.fields.is_empty() {
186 return FormEvent::None;
187 }
188 match key.code {
189 KeyCode::Esc => FormEvent::Cancel,
190 KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => {
191 if self.focus_prev() { FormEvent::FocusMoved } else { FormEvent::None }
192 }
193 KeyCode::BackTab => {
194 if self.focus_prev() { FormEvent::FocusMoved } else { FormEvent::None }
195 }
196 KeyCode::Tab | KeyCode::Down => {
197 if self.focus_next() { FormEvent::FocusMoved } else { FormEvent::None }
198 }
199 KeyCode::Up => {
200 if self.focus_prev() { FormEvent::FocusMoved } else { FormEvent::None }
201 }
202 KeyCode::Enter => {
203 if self.is_on_last_visible() {
205 FormEvent::Submit
206 } else if self.focus_next() {
207 FormEvent::FocusMoved
208 } else {
209 FormEvent::Submit
210 }
211 }
212 _ => self.handle_field_key(key),
213 }
214 }
215
216 fn is_on_last_visible(&self) -> bool {
217 self.fields
218 .iter()
219 .enumerate()
220 .filter(|(_, f)| f.visible)
221 .last()
222 .map(|(i, _)| i == self.focused)
223 .unwrap_or(false)
224 }
225
226 fn handle_field_key(&mut self, key: KeyEvent) -> FormEvent {
227 let idx = self.focused;
228 let id = self.fields[idx].id.clone();
229 let mut changed = false;
230 match &mut self.fields[idx].input {
231 FieldInput::Text(s) => {
232 if edit_text(key, s, &mut self.cursors[idx]) { changed = true; }
233 }
234 FieldInput::Integer(n) => {
235 let mut s = n.to_string();
236 let mut cur = self.cursors[idx].min(s.chars().count());
237 if edit_numeric(key, &mut s, &mut cur, true) {
238 *n = s.parse::<i64>().unwrap_or(*n);
239 self.cursors[idx] = cur;
240 changed = true;
241 }
242 }
243 FieldInput::Float(f) => {
244 let mut s = format!("{}", f);
245 let mut cur = self.cursors[idx].min(s.chars().count());
246 if edit_numeric(key, &mut s, &mut cur, false) {
247 *f = s.parse::<f64>().unwrap_or(*f);
248 self.cursors[idx] = cur;
249 changed = true;
250 }
251 }
252 FieldInput::Boolean(b) => match key.code {
253 KeyCode::Left | KeyCode::Right | KeyCode::Char(' ') => {
254 *b = !*b;
255 changed = true;
256 }
257 _ => {}
258 },
259 FieldInput::Enum { options, selected } => {
260 if options.is_empty() { return FormEvent::None; }
261 match key.code {
262 KeyCode::Right | KeyCode::Char('l') => {
263 *selected = (*selected + 1) % options.len();
264 changed = true;
265 }
266 KeyCode::Left | KeyCode::Char('h') => {
267 *selected = if *selected == 0 { options.len() - 1 } else { *selected - 1 };
268 changed = true;
269 }
270 _ => {}
271 }
272 }
273 FieldInput::List(_) | FieldInput::ReadOnly(_) => {}
274 }
275 if changed { FormEvent::FieldChanged(id) } else { FormEvent::None }
276 }
277
278 pub fn touch_all(&mut self) {
282 for f in &self.fields {
283 self.touched.insert(f.id.clone());
284 }
285 }
286
287 pub fn untouch_all(&mut self) { self.touched.clear(); }
289}
290
291fn cursor_end_of(f: &FormField) -> usize {
292 match &f.input {
293 FieldInput::Text(s) => s.chars().count(),
294 FieldInput::Integer(n) => n.to_string().chars().count(),
295 FieldInput::Float(v) => format!("{}", v).chars().count(),
296 _ => 0,
297 }
298}
299
300fn edit_text(key: KeyEvent, buf: &mut String, cursor: &mut usize) -> bool {
303 let len_chars = buf.chars().count();
304 match key.code {
305 KeyCode::Char(c) => {
306 let byte = char_index_to_byte(buf, *cursor);
307 buf.insert(byte, c);
308 *cursor += 1;
309 true
310 }
311 KeyCode::Backspace if *cursor > 0 => {
312 let from = char_index_to_byte(buf, *cursor - 1);
313 let to = char_index_to_byte(buf, *cursor);
314 buf.replace_range(from..to, "");
315 *cursor -= 1;
316 true
317 }
318 KeyCode::Delete if *cursor < len_chars => {
319 let from = char_index_to_byte(buf, *cursor);
320 let to = char_index_to_byte(buf, *cursor + 1);
321 buf.replace_range(from..to, "");
322 true
323 }
324 KeyCode::Left if *cursor > 0 => { *cursor -= 1; true }
325 KeyCode::Right if *cursor < len_chars => { *cursor += 1; true }
326 KeyCode::Home if *cursor != 0 => { *cursor = 0; true }
327 KeyCode::End if *cursor != len_chars => { *cursor = len_chars; true }
328 _ => false,
329 }
330}
331
332fn edit_numeric(key: KeyEvent, buf: &mut String, cursor: &mut usize, integer: bool) -> bool {
335 match key.code {
336 KeyCode::Char(c) => {
337 let is_digit = c.is_ascii_digit();
338 let is_sign = c == '-' && *cursor == 0 && !buf.starts_with('-');
339 let is_dot = !integer && c == '.' && !buf.contains('.');
340 if is_digit || is_sign || is_dot {
341 let byte = char_index_to_byte(buf, *cursor);
342 buf.insert(byte, c);
343 *cursor += 1;
344 true
345 } else {
346 false
347 }
348 }
349 _ => edit_text(key, buf, cursor),
350 }
351}
352
353fn char_index_to_byte(s: &str, char_idx: usize) -> usize {
354 s.char_indices().nth(char_idx).map(|(b, _)| b).unwrap_or(s.len())
355}
356
357pub fn label_prefix(
364 label: &str,
365 pad: usize,
366 error: bool,
367 theme: &Theme,
368) -> Vec<Span<'static>> {
369 let marker = if error {
370 Span::styled(
371 "*",
372 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
373 )
374 } else {
375 Span::raw(" ")
376 };
377 let total = pad + 2;
378 let filler_width = total.saturating_sub(label.chars().count() + 1);
379 vec![
380 Span::raw(" "),
381 Span::styled(label.to_string(), theme.hint),
382 marker,
383 Span::raw(" ".repeat(filler_width)),
384 ]
385}
386
387fn split_at_char(s: &str, char_idx: usize) -> (&str, &str) {
389 for (i, (b, _)) in s.char_indices().enumerate() {
390 if i == char_idx {
391 return (&s[..b], &s[b..]);
392 }
393 }
394 (s, "")
395}
396
397pub fn input_row(
403 label: &str,
404 pad: usize,
405 value: &str,
406 active: bool,
407 error: bool,
408 caret: Option<usize>,
409 theme: &Theme,
410) -> Line<'static> {
411 let mut spans = label_prefix(label, pad, error, theme);
412 if active {
413 spans.push(Span::styled("[", theme.shortcut_key));
414 match caret {
415 Some(pos) => {
416 let (lhs, rhs) = split_at_char(value, pos);
417 spans.push(Span::styled(lhs.to_string(), theme.body));
418 spans.push(Span::styled(
419 "│".to_string(),
420 theme.shortcut_key.add_modifier(Modifier::BOLD),
421 ));
422 spans.push(Span::styled(rhs.to_string(), theme.body));
423 }
424 None => spans.push(Span::styled(value.to_string(), theme.body)),
425 }
426 spans.push(Span::styled("]", theme.shortcut_key));
427 } else {
428 spans.push(Span::styled(value.to_string(), theme.body));
429 }
430 Line::from(spans)
431}
432
433pub fn select_row(
436 label: &str,
437 pad: usize,
438 value: &str,
439 active: bool,
440 error: bool,
441 theme: &Theme,
442) -> Line<'static> {
443 let mut spans = label_prefix(label, pad, error, theme);
444 if active {
445 spans.push(Span::styled("◀ ", theme.shortcut_key));
446 spans.push(Span::styled(value.to_string(), theme.body));
447 spans.push(Span::styled(" ▶", theme.shortcut_key));
448 } else {
449 spans.push(Span::styled(value.to_string(), theme.body));
450 }
451 Line::from(spans)
452}
453
454pub fn error_lines(msg: &str, indent: usize, max_width: usize) -> Vec<Line<'static>> {
459 let style = Style::default().fg(Color::Red).add_modifier(Modifier::ITALIC);
460 let prefix_cols = 2;
461 let avail = max_width.saturating_sub(indent + prefix_cols).max(1);
462 let chunks = wrap_chars(msg, avail);
463 chunks
464 .into_iter()
465 .enumerate()
466 .map(|(i, chunk)| {
467 let marker = if i == 0 { "└ " } else { " " };
468 Line::from(vec![
469 Span::raw(" ".repeat(indent)),
470 Span::styled(format!("{}{}", marker, chunk), style),
471 ])
472 })
473 .collect()
474}
475
476pub fn wrap_chars(s: &str, width: usize) -> Vec<String> {
479 if width == 0 { return vec![s.to_string()]; }
480 let mut out: Vec<String> = Vec::new();
481 let mut line = String::new();
482 let mut line_len = 0usize;
483 for word in s.split_whitespace() {
484 let wlen = word.chars().count();
485 if wlen > width {
486 if !line.is_empty() {
487 out.push(std::mem::take(&mut line));
488 line_len = 0;
489 }
490 let mut buf = String::new();
491 let mut n = 0;
492 for c in word.chars() {
493 buf.push(c);
494 n += 1;
495 if n == width {
496 out.push(std::mem::take(&mut buf));
497 n = 0;
498 }
499 }
500 if !buf.is_empty() { line = buf; line_len = n; }
501 continue;
502 }
503 let sep = if line_len == 0 { 0 } else { 1 };
504 if line_len + sep + wlen > width {
505 out.push(std::mem::take(&mut line));
506 line_len = 0;
507 }
508 if line_len > 0 {
509 line.push(' ');
510 line_len += 1;
511 }
512 line.push_str(word);
513 line_len += wlen;
514 }
515 if !line.is_empty() { out.push(line); }
516 if out.is_empty() { out.push(String::new()); }
517 out
518}
519
520#[derive(Debug, Clone)]
524pub struct FormStyle {
525 pub label_pad: usize,
527 pub show_caret: bool,
529 pub show_description: bool,
532}
533
534impl Default for FormStyle {
535 fn default() -> Self {
536 Self { label_pad: 0, show_caret: true, show_description: true }
537 }
538}
539
540pub fn render_form(f: &mut Frame, area: Rect, state: &FormState, theme: &Theme) {
545 render_form_with(f, area, state, &FormStyle::default(), &[], theme);
546}
547
548pub fn render_form_with(
554 f: &mut Frame,
555 area: Rect,
556 state: &FormState,
557 style: &FormStyle,
558 errors: &[(String, String)],
559 theme: &Theme,
560) {
561 if area.height == 0 { return; }
562
563 let pad = if style.label_pad == 0 {
564 state
565 .fields
566 .iter()
567 .filter(|f| f.visible)
568 .map(|f| f.label.chars().count())
569 .max()
570 .unwrap_or(0)
571 } else {
572 style.label_pad
573 };
574
575 let mut lines: Vec<Line<'static>> = Vec::new();
576 for (idx, field) in state.fields.iter().enumerate() {
577 if !field.visible { continue; }
578
579 let active = idx == state.focused;
580 let touched = state.touched.contains(&field.id);
581 let has_error = touched
582 && errors.iter().any(|(id, _)| id == &field.id);
583
584 let label_with_req = if field.required {
585 format!("{}*", field.label)
586 } else {
587 field.label.clone()
588 };
589
590 let caret = if style.show_caret && active {
591 Some(state.cursors.get(idx).copied().unwrap_or(0))
592 } else {
593 None
594 };
595
596 let line = match &field.input {
597 FieldInput::Text(s) => input_row(&label_with_req, pad, s, active, has_error, caret, theme),
598 FieldInput::Integer(n) => {
599 let s = n.to_string();
600 input_row(&label_with_req, pad, &s, active, has_error, caret, theme)
601 }
602 FieldInput::Float(v) => {
603 let s = format!("{}", v);
604 input_row(&label_with_req, pad, &s, active, has_error, caret, theme)
605 }
606 FieldInput::Boolean(b) => {
607 let v = if *b { "yes" } else { "no" };
608 select_row(&label_with_req, pad, v, active, has_error, theme)
609 }
610 FieldInput::Enum { options, selected } => {
611 let v = options.get(*selected).map(String::as_str).unwrap_or("");
612 select_row(&label_with_req, pad, v, active, has_error, theme)
613 }
614 FieldInput::ReadOnly(s) => {
615 let mut spans = label_prefix(&label_with_req, pad, has_error, theme);
616 spans.push(Span::styled(s.clone(), theme.hint));
617 Line::from(spans)
618 }
619 FieldInput::List(items) => {
620 let v = if items.is_empty() {
621 "(empty)".to_string()
622 } else {
623 items.join(", ")
624 };
625 let mut spans = label_prefix(&label_with_req, pad, has_error, theme);
626 spans.push(Span::styled(v, theme.body));
627 Line::from(spans)
628 }
629 };
630 lines.push(line);
631
632 if active && has_error {
634 if let Some((_, msg)) = errors.iter().find(|(id, _)| id == &field.id) {
635 let indent = 2;
636 lines.extend(error_lines(msg, indent, area.width as usize));
637 }
638 }
639
640 if style.show_description && active {
642 if let Some(desc) = &field.description {
643 lines.push(Line::from(vec![
644 Span::raw(" ".repeat(2 + pad + 2)),
645 Span::styled(desc.clone(), theme.hint),
646 ]));
647 }
648 }
649 }
650
651 let paragraph = Paragraph::new(lines);
652 f.render_widget(paragraph, area);
653}
654
655#[cfg(test)]
658mod tests {
659 use super::*;
660 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
661
662 fn key(code: KeyCode) -> KeyEvent {
663 KeyEvent::new(code, KeyModifiers::NONE)
664 }
665
666 fn text_state() -> FormState {
667 FormState::new(vec![
668 FormField::new("name", "name", FieldInput::Text("ab".into())),
669 FormField::new("age", "age", FieldInput::Integer(42)),
670 ])
671 }
672
673 #[test]
674 fn text_insertion_tracks_cursor() {
675 let mut s = FormState::new(vec![FormField::new(
676 "x", "x", FieldInput::Text("".into()),
677 )]);
678 assert_eq!(s.cursors[0], 0);
679 let r = s.handle_key(key(KeyCode::Char('a')));
680 assert!(matches!(r, FormEvent::FieldChanged(_)));
681 assert_eq!(s.cursors[0], 1);
682 match &s.fields[0].input {
683 FieldInput::Text(v) => assert_eq!(v, "a"),
684 _ => unreachable!(),
685 }
686 }
687
688 #[test]
689 fn tab_moves_forward_and_touches_previous() {
690 let mut s = text_state();
691 assert_eq!(s.focused, 0);
692 let r = s.handle_key(key(KeyCode::Tab));
693 assert_eq!(r, FormEvent::FocusMoved);
694 assert_eq!(s.focused, 1);
695 assert!(s.touched.contains("name"));
696 }
697
698 #[test]
699 fn backtab_moves_back() {
700 let mut s = text_state();
701 s.focused = 1;
702 let r = s.handle_key(key(KeyCode::BackTab));
703 assert_eq!(r, FormEvent::FocusMoved);
704 assert_eq!(s.focused, 0);
705 }
706
707 #[test]
708 fn hidden_rows_skipped_by_navigation() {
709 let mut s = FormState::new(vec![
710 FormField::new("a", "a", FieldInput::Text("a".into())),
711 FormField::new("b", "b", FieldInput::Text("b".into())).visible(false),
712 FormField::new("c", "c", FieldInput::Text("c".into())),
713 ]);
714 assert_eq!(s.focused, 0);
715 s.handle_key(key(KeyCode::Tab));
716 assert_eq!(s.focused, 2, "should skip the hidden row");
717 }
718
719 #[test]
720 fn enum_cycles_with_arrows() {
721 let mut s = FormState::new(vec![FormField::new(
722 "k",
723 "kind",
724 FieldInput::Enum {
725 options: vec!["A".into(), "B".into(), "C".into()],
726 selected: 0,
727 },
728 )]);
729 s.handle_key(key(KeyCode::Right));
730 match &s.fields[0].input {
731 FieldInput::Enum { selected, .. } => assert_eq!(*selected, 1),
732 _ => unreachable!(),
733 }
734 s.handle_key(key(KeyCode::Left));
735 s.handle_key(key(KeyCode::Left));
736 match &s.fields[0].input {
737 FieldInput::Enum { selected, .. } => assert_eq!(*selected, 2, "wraps"),
738 _ => unreachable!(),
739 }
740 }
741
742 #[test]
743 fn bool_toggles_with_left_right() {
744 let mut s = FormState::new(vec![FormField::new(
745 "b", "b", FieldInput::Boolean(false),
746 )]);
747 s.handle_key(key(KeyCode::Left));
748 assert!(matches!(s.fields[0].input, FieldInput::Boolean(true)));
749 }
750
751 #[test]
752 fn esc_cancels() {
753 let mut s = text_state();
754 assert_eq!(s.handle_key(key(KeyCode::Esc)), FormEvent::Cancel);
755 }
756
757 #[test]
758 fn enter_on_last_submits() {
759 let mut s = text_state();
760 s.focused = 1;
761 assert_eq!(s.handle_key(key(KeyCode::Enter)), FormEvent::Submit);
762 }
763
764 #[test]
765 fn enter_on_middle_advances() {
766 let mut s = text_state();
767 assert_eq!(s.handle_key(key(KeyCode::Enter)), FormEvent::FocusMoved);
768 assert_eq!(s.focused, 1);
769 }
770
771 #[test]
772 fn wrap_chars_respects_width_and_hard_breaks() {
773 let out = wrap_chars("one two three four", 8);
774 assert_eq!(out, vec!["one two", "three", "four"]);
775
776 let out = wrap_chars("abcdefghijk", 3);
777 assert_eq!(out, vec!["abc", "def", "ghi", "jk"]);
778 }
779
780 #[test]
781 fn error_lines_align_under_indent() {
782 let lines = error_lines("oops bad value", 4, 20);
783 assert!(!lines.is_empty());
784 let first = format!("{:?}", lines[0]);
786 assert!(first.contains("└"), "expected └ marker in first error line");
787 }
788}