Skip to main content

tui_kit/
form.rs

1//! Multi-field input form with a stable two-column layout.
2//!
3//! Two entry points:
4//!
5//! 1. **High-level**: build a [`FormState`] from a list of [`FormField`],
6//!    route keys through [`FormState::handle_key`], and render with
7//!    [`render_form`]. Suitable for self-contained settings/wizard-style
8//!    screens.
9//!
10//! 2. **Low-level primitives**: [`label_prefix`], [`input_row`],
11//!    [`select_row`], [`error_lines`]. Use these when you want the same
12//!    visual style but manage focus/buffer state yourself (e.g. ygg's
13//!    blueprint detail panel, which stores its form values inside a
14//!    snapshot-backed buffer).
15//!
16//! ## Visual language
17//!
18//! - `  label   value`                       ← row, inactive
19//! - `  label   [ lhs│rhs ]`                 ← row, active, software caret
20//! - `  label   ◀ option ▶`                  ← row, active, enum / bool cycle
21//! - `  label * value`                       ← row with validation error (red `*`)
22//! - `        └ message wrapped over…`      ← per-row error, red italic
23//! - `          …more text`                  ← continuation, aligned under message
24//!
25//! The `│` caret is drawn in-band (software cursor). Hide the terminal's
26//! hardware cursor (`Frame::set_cursor_position` omitted) so only one caret
27//! is visible.
28
29use 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// ── Data ──────────────────────────────────────────────────────────────────────
43
44/// The interactive value held by a [`FormField`].
45#[derive(Debug, Clone, PartialEq)]
46pub enum FieldInput {
47    Text(String),
48    Integer(i64),
49    Float(f64),
50    Boolean(bool),
51    /// Inline selector cycling through `options`; `selected` is the current index.
52    Enum { options: Vec<String>, selected: usize },
53    /// Display-only ordered list (editing deferred).
54    List(Vec<String>),
55    /// Non-editable display value (e.g. a derived label shown as a row).
56    ReadOnly(String),
57}
58
59/// One row in a form.
60#[derive(Debug, Clone)]
61pub struct FormField {
62    /// Stable identifier used to key errors and touched state. Should be unique.
63    pub id: String,
64    /// Label drawn in the left column.
65    pub label: String,
66    /// Current input value.
67    pub input: FieldInput,
68    /// If true, the label is suffixed with a subtle marker.
69    pub required: bool,
70    /// Optional help text (rendered under the row when focused).
71    pub description: Option<String>,
72    /// When false, the row is skipped by focus navigation and not rendered.
73    /// Callers toggle this dynamically for contextual fields (e.g. "default"
74    /// hidden when type=Boolean has its own cycle).
75    pub visible: bool,
76}
77
78impl FormField {
79    /// Build a new field with sensible defaults (`required = false`,
80    /// `description = None`, `visible = true`).
81    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/// Outcome of routing a key through [`FormState::handle_key`].
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum FormEvent {
106    None,
107    /// Focus moved to a different (visible) row. Callers typically
108    /// re-validate and refresh derived visibility.
109    FocusMoved,
110    /// The value of the focused field changed (insert/delete/cycle/toggle).
111    FieldChanged(String),
112    /// User requested submit (Enter on the last visible row).
113    Submit,
114    /// User pressed Esc.
115    Cancel,
116}
117
118/// Form state: fields + focus + per-field cursor + touched set.
119#[derive(Debug, Clone)]
120pub struct FormState {
121    pub fields: Vec<FormField>,
122    pub focused: usize,
123    /// Char-index cursor per field (relevant for Text / Integer / Float).
124    pub cursors: Vec<usize>,
125    /// Field ids that the user has visited and then left. Callers render
126    /// validation errors only for ids in this set (error-on-leave pattern).
127    pub touched: HashSet<String>,
128}
129
130impl FormState {
131    /// Build a new state. The cursor for each Text field starts at the end
132    /// of its value; other kinds start at 0.
133    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    /// Move focus to the next visible field. Records the previously focused
143    /// field as touched. Returns true if focus actually changed.
144    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    /// Move focus to the previous visible field.
160    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        // Snap cursor to end of newly-focused value so Backspace / Left work
179        // predictably — matches the UX of the ygg bp-detail panel.
180        self.cursors[to] = cursor_end_of(&self.fields[to]);
181    }
182
183    /// Route a key event. Returns a [`FormEvent`] describing the outcome.
184    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                // Submit on the last visible row; otherwise just advance.
204                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    /// Mark the focused field as touched without moving focus. Useful when
279    /// a caller commits a form (e.g. on Enter-save) and wants every row's
280    /// error visible.
281    pub fn touch_all(&mut self) {
282        for f in &self.fields {
283            self.touched.insert(f.id.clone());
284        }
285    }
286
287    /// Reset touched state — typically called when re-opening a form fresh.
288    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
300/// Apply a key to a string buffer with a char-indexed cursor. Returns true
301/// if the buffer or cursor changed.
302fn 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
332/// Apply a key to a numeric-string buffer. `allow_minus_sign` selects integer
333/// vs float semantics (float additionally accepts `.`).
334fn 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
357// ── Rendering — low-level primitives ──────────────────────────────────────────
358
359/// Build the label-column spans for a row. Output:
360/// `"  <label><*|space><filler>"` — two-space left indent, label, then a red
361/// bold `*` if `error` is true (else a space), padded so all values line up
362/// at column `indent + 2 + pad + 2`.
363pub 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
387/// Split a string at a character index, clamping to the bounds.
388fn 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
397/// Render a text-input row.
398///
399/// When `active` is true, the value is wrapped in brackets. If `caret` is
400/// `Some(idx)`, a bold `│` is inserted at character index `idx` (software
401/// caret). When `active` is false, the value renders plain.
402pub 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
433/// Render a select-style row: when active, the value is framed with
434/// `◀ … ▶` cycle glyphs. When inactive, it renders plain.
435pub 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
454/// Render a per-row inline error message, wrapped to `max_width`. The first
455/// line is prefixed with `└ `, continuations with two spaces so the message
456/// text stays aligned. Indented by `indent` columns so it sits under the
457/// row's label.
458pub 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
476/// Word-wrap `s` into chunks of at most `width` chars each. Breaks on
477/// whitespace; hard-breaks words longer than `width`.
478pub 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// ── Rendering — high-level form widget ────────────────────────────────────────
521
522/// Styling knobs for [`render_form`].
523#[derive(Debug, Clone)]
524pub struct FormStyle {
525    /// Width reserved for the label column. `0` = auto (max label length).
526    pub label_pad: usize,
527    /// Render a `│` software caret in the focused Text / Integer / Float field.
528    pub show_caret: bool,
529    /// When true, the focused field's description is drawn on the line
530    /// immediately below it, indented to align under the value column.
531    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
540/// Render a form inside `area`, applying the default styling.
541///
542/// This thin wrapper preserves the original public API; use
543/// [`render_form_with`] for error messages and style overrides.
544pub fn render_form(f: &mut Frame, area: Rect, state: &FormState, theme: &Theme) {
545    render_form_with(f, area, state, &FormStyle::default(), &[], theme);
546}
547
548/// Render a form with explicit style options and per-field errors.
549///
550/// `errors` is a slice of `(field_id, message)` pairs. A given error is
551/// rendered only if `field_id` is in `state.touched` — so newly-opened
552/// fields do not flash errors before the user has a chance to type.
553pub 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        // Inline error lines (wrapped under the label).
633        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        // Description hint for focused row.
641        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// ── Tests ─────────────────────────────────────────────────────────────────────
656
657#[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        // First line carries the └ marker; subsequent lines are plain-indented.
785        let first = format!("{:?}", lines[0]);
786        assert!(first.contains("└"), "expected └ marker in first error line");
787    }
788}