Skip to main content

tui/components/
form.rs

1use crate::components::{Component, Event, ViewContext};
2use crate::focus::FocusRing;
3use crate::line::Line;
4use crate::rendering::frame::Frame;
5use crate::style::Style;
6
7use super::checkbox::Checkbox;
8use super::multi_select::MultiSelect;
9use super::number_field::NumberField;
10use super::radio_select::RadioSelect;
11use super::text_field::TextField;
12use crossterm::event::KeyCode;
13
14/// Messages emitted by [`Form`] input handling.
15pub enum FormMessage {
16    Close,
17    Submit,
18}
19
20enum FormMode {
21    SingleStep,
22    MultiStep,
23}
24
25impl FormMode {
26    fn from_field_count(count: usize) -> Self {
27        if count <= 1 { Self::SingleStep } else { Self::MultiStep }
28    }
29}
30
31#[doc = include_str!("../docs/form.md")]
32pub struct Form {
33    pub message: String,
34    pub fields: Vec<FormField>,
35    mode: FormMode,
36    focus: FocusRing,
37}
38
39/// A single field within a [`Form`].
40pub struct FormField {
41    pub name: String,
42    pub label: String,
43    pub description: Option<String>,
44    pub required: bool,
45    pub kind: FormFieldKind,
46}
47
48/// The widget type backing a [`FormField`].
49pub enum FormFieldKind {
50    Text(TextField),
51    Number(NumberField),
52    Boolean(Checkbox),
53    SingleSelect(RadioSelect),
54    MultiSelect(MultiSelect),
55}
56
57impl Form {
58    pub fn new(message: String, fields: Vec<FormField>) -> Self {
59        let mode = FormMode::from_field_count(fields.len());
60        let focus_len = match mode {
61            FormMode::SingleStep => fields.len(),
62            FormMode::MultiStep => fields.len() + 1,
63        };
64        Self { message, fields, mode, focus: FocusRing::new(focus_len) }
65    }
66
67    pub fn to_json(&self) -> serde_json::Value {
68        let mut map = serde_json::Map::new();
69        for field in &self.fields {
70            map.insert(field.name.clone(), field.kind.to_json());
71        }
72        serde_json::Value::Object(map)
73    }
74
75    fn is_single_step(&self) -> bool {
76        matches!(self.mode, FormMode::SingleStep)
77    }
78
79    fn is_multi_step(&self) -> bool {
80        matches!(self.mode, FormMode::MultiStep)
81    }
82
83    fn is_on_submit_tab(&self) -> bool {
84        self.is_multi_step() && self.focus.focused() == self.fields.len()
85    }
86
87    fn active_field_uses_horizontal_arrows(&self) -> bool {
88        self.fields
89            .get(self.focus.focused())
90            .is_some_and(|f| matches!(f.kind, FormFieldKind::Text(_) | FormFieldKind::Number(_)))
91    }
92
93    fn render_tab_bar(&self, context: &ViewContext) -> Line {
94        let mut line = Line::default();
95        let muted = context.theme.text_secondary();
96        let primary = context.theme.primary();
97        let success = context.theme.success();
98
99        for (i, field) in self.fields.iter().enumerate() {
100            if i > 0 {
101                line.push_styled(" · ", muted);
102            }
103
104            let is_active = self.focus.is_focused(i);
105            let indicator = if field.kind.is_answered() { "✓ " } else { "□ " };
106
107            let style = if is_active { Style::fg(primary).bold() } else { Style::fg(muted) };
108            line.push_with_style(format!("{indicator}{}", field.label), style);
109        }
110
111        // Submit tab
112        if !self.fields.is_empty() {
113            line.push_styled(" · ", muted);
114        }
115        let submit_style = if self.is_on_submit_tab() { Style::fg(success).bold() } else { Style::fg(muted) };
116        line.push_with_style("Submit", submit_style);
117
118        line
119    }
120
121    fn render_active_field(&self, context: &ViewContext) -> Vec<Line> {
122        if self.is_on_submit_tab() {
123            return self.render_submit_summary(context);
124        }
125
126        let Some(field) = self.fields.get(self.focus.focused()) else {
127            return vec![];
128        };
129
130        let mut lines = Vec::new();
131        let required_marker = if field.required { "*" } else { "" };
132        let label_line = Line::with_style(
133            format!("{}{required_marker}: ", field.label),
134            Style::fg(context.theme.text_primary()).bold(),
135        );
136
137        let field_lines = field.kind.render_field(context, true);
138        let inline = field.kind.is_inline();
139        if inline {
140            let mut combined = label_line;
141            if let Some((first, rest)) = field_lines.split_first() {
142                combined.append_line(first);
143                lines.push(combined);
144                lines.extend_from_slice(rest);
145            } else {
146                lines.push(combined);
147            }
148        } else {
149            // Multi-line widgets (radio, multi-select): label on its own line.
150            lines.push(label_line);
151            lines.extend(field_lines);
152        }
153
154        if let Some(desc) = &field.description {
155            lines.push(Line::styled(desc, context.theme.muted()));
156        }
157
158        lines
159    }
160
161    fn render_submit_summary(&self, context: &ViewContext) -> Vec<Line> {
162        let mut lines = vec![Line::with_style("Review & Submit", Style::fg(context.theme.text_primary()).bold())];
163        lines.push(Line::default());
164
165        for field in &self.fields {
166            let mut line = Line::with_style(format!("{}: ", field.label), Style::fg(context.theme.text_secondary()));
167            let value_lines = field.kind.render_field(context, false);
168            if let Some(first) = value_lines.first() {
169                line.append_line(first);
170            }
171            lines.push(line);
172        }
173
174        lines
175    }
176
177    fn render_footer(&self, context: &ViewContext) -> Line {
178        let muted = context.theme.muted();
179
180        if self.is_multi_step() && self.is_on_submit_tab() {
181            return Line::styled("Enter to submit · Esc to cancel", muted);
182        }
183
184        let Some(field) = self.fields.get(self.focus.focused()) else {
185            return if self.is_single_step() {
186                Line::styled("Enter to submit · Esc to cancel", muted)
187            } else {
188                Line::default()
189            };
190        };
191
192        let hints = if self.is_single_step() {
193            match &field.kind {
194                FormFieldKind::Text(_) | FormFieldKind::Number(_) => {
195                    "Type your answer · Enter to submit · Esc to cancel"
196                }
197                FormFieldKind::Boolean(_) => "Space to toggle · Enter to submit · Esc to cancel",
198                FormFieldKind::SingleSelect(_) => "↑↓ to select · Enter to submit · Esc to cancel",
199                FormFieldKind::MultiSelect(_) => "Space to toggle · ↑↓ to move · Enter to submit · Esc to cancel",
200            }
201        } else {
202            match &field.kind {
203                FormFieldKind::Text(_) | FormFieldKind::Number(_) => {
204                    "Type your answer · Tab to navigate · Esc to cancel"
205                }
206                FormFieldKind::Boolean(_) => "Space to toggle · Tab to navigate · Esc to cancel",
207                FormFieldKind::SingleSelect(_) => "↑↓ to select · Tab to navigate · Enter to confirm · Esc to cancel",
208                FormFieldKind::MultiSelect(_) => "Space to toggle · ↑↓ to move · Tab to navigate · Esc to cancel",
209            }
210        };
211
212        Line::styled(hints, muted)
213    }
214}
215
216impl FormFieldKind {
217    /// Returns `true` for widgets that render on a single line (text, number, checkbox)
218    /// and `false` for multi-line widgets (radio select, multi-select).
219    pub fn is_inline(&self) -> bool {
220        matches!(self, Self::Text(_) | Self::Number(_) | Self::Boolean(_))
221    }
222
223    pub fn is_answered(&self) -> bool {
224        match self {
225            Self::Text(w) => !w.value.is_empty(),
226            Self::Number(w) => !w.value.is_empty(),
227            Self::Boolean(_) | Self::SingleSelect(_) => true,
228            Self::MultiSelect(w) => w.selected.iter().any(|&s| s),
229        }
230    }
231
232    fn to_json(&self) -> serde_json::Value {
233        match self {
234            Self::Text(w) => w.to_json(),
235            Self::Number(w) => w.to_json(),
236            Self::Boolean(w) => w.to_json(),
237            Self::SingleSelect(w) => w.to_json(),
238            Self::MultiSelect(w) => w.to_json(),
239        }
240    }
241
242    fn render_field(&self, context: &ViewContext, focused: bool) -> Vec<Line> {
243        match self {
244            Self::Text(w) => w.render_field(context, focused),
245            Self::Number(w) => w.render_field(context, focused),
246            Self::Boolean(w) => w.render_field(context, focused),
247            Self::SingleSelect(w) => w.render_field(context, focused),
248            Self::MultiSelect(w) => w.render_field(context, focused),
249        }
250    }
251
252    async fn handle_event(&mut self, event: &Event) -> Option<Vec<()>> {
253        match self {
254            Self::Text(w) => w.on_event(event).await,
255            Self::Number(w) => w.on_event(event).await,
256            Self::Boolean(w) => w.on_event(event).await,
257            Self::SingleSelect(w) => w.on_event(event).await,
258            Self::MultiSelect(w) => w.on_event(event).await,
259        }
260    }
261}
262
263impl Component for Form {
264    type Message = FormMessage;
265
266    async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
267        let Event::Key(key) = event else {
268            return None;
269        };
270        match key.code {
271            KeyCode::Esc => return Some(vec![FormMessage::Close]),
272            KeyCode::Enter if self.is_single_step() => return Some(vec![FormMessage::Submit]),
273            KeyCode::Enter if self.is_on_submit_tab() => return Some(vec![FormMessage::Submit]),
274            KeyCode::Enter | KeyCode::Tab => {
275                self.focus.focus_next();
276                return Some(vec![]);
277            }
278            KeyCode::BackTab => {
279                self.focus.focus_prev();
280                return Some(vec![]);
281            }
282            KeyCode::Left if !self.active_field_uses_horizontal_arrows() => {
283                self.focus.focus_prev();
284                return Some(vec![]);
285            }
286            KeyCode::Right if !self.active_field_uses_horizontal_arrows() => {
287                self.focus.focus_next();
288                return Some(vec![]);
289            }
290            _ => {}
291        }
292
293        if let Some(field) = self.fields.get_mut(self.focus.focused()) {
294            field.kind.handle_event(event).await;
295        }
296        Some(vec![])
297    }
298
299    fn render(&mut self, context: &ViewContext) -> Frame {
300        let mut lines = vec![Line::with_style(&self.message, Style::fg(context.theme.text_primary()).bold())];
301        lines.push(Line::default());
302
303        if self.is_multi_step() {
304            lines.push(self.render_tab_bar(context));
305            lines.push(Line::default());
306        }
307
308        lines.extend(self.render_active_field(context));
309        lines.push(Line::default());
310        lines.push(self.render_footer(context));
311        Frame::new(lines)
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::super::select_option::SelectOption;
318    use super::*;
319    use crate::rendering::line::Line;
320    use crossterm::event::{KeyEvent, KeyModifiers};
321
322    fn key(code: KeyCode) -> KeyEvent {
323        KeyEvent::new(code, KeyModifiers::NONE)
324    }
325
326    fn sample_fields() -> Vec<FormField> {
327        vec![
328            FormField {
329                name: "lang".to_string(),
330                label: "Language".to_string(),
331                description: Some("Pick a language".to_string()),
332                required: true,
333                kind: FormFieldKind::SingleSelect(RadioSelect::new(
334                    vec![
335                        SelectOption { value: "rust".into(), title: "Rust".into(), description: None },
336                        SelectOption { value: "ts".into(), title: "TypeScript".into(), description: None },
337                    ],
338                    0,
339                )),
340            },
341            FormField {
342                name: "name".to_string(),
343                label: "Name".to_string(),
344                description: None,
345                required: false,
346                kind: FormFieldKind::Text(TextField::new(String::new())),
347            },
348            FormField {
349                name: "features".to_string(),
350                label: "Features".to_string(),
351                description: None,
352                required: false,
353                kind: FormFieldKind::MultiSelect(MultiSelect::new(
354                    vec![
355                        SelectOption { value: "a".into(), title: "Alpha".into(), description: None },
356                        SelectOption { value: "b".into(), title: "Beta".into(), description: None },
357                    ],
358                    vec![false, false],
359                )),
360            },
361        ]
362    }
363
364    fn single_select_field() -> Vec<FormField> {
365        vec![FormField {
366            name: "decision".to_string(),
367            label: "Decision".to_string(),
368            description: None,
369            required: true,
370            kind: FormFieldKind::SingleSelect(RadioSelect::new(
371                vec![
372                    SelectOption { value: "allow".into(), title: "allow".into(), description: None },
373                    SelectOption { value: "deny".into(), title: "deny".into(), description: None },
374                ],
375                0,
376            )),
377        }]
378    }
379
380    #[test]
381    fn render_does_not_panic_when_title_wider_than_terminal() {
382        let mut form = Form::new(
383            "This is a very long message that exceeds the terminal width".to_string(),
384            vec![FormField {
385                name: "name".to_string(),
386                label: "Name".to_string(),
387                description: None,
388                required: false,
389                kind: FormFieldKind::Text(TextField::new(String::new())),
390            }],
391        );
392        let context = ViewContext::new((10, 10));
393
394        // Should not panic
395        let frame = form.render(&context);
396        assert!(!frame.lines().is_empty());
397    }
398
399    #[test]
400    fn tab_bar_shows_all_field_labels() {
401        let form = Form::new("Survey".to_string(), sample_fields());
402        let context = ViewContext::new((80, 24));
403        let tab_bar = form.render_tab_bar(&context);
404        let text = tab_bar.plain_text();
405        assert!(text.contains("Language"), "tab bar missing 'Language'");
406        assert!(text.contains("Name"), "tab bar missing 'Name'");
407        assert!(text.contains("Features"), "tab bar missing 'Features'");
408        assert!(text.contains("Submit"), "tab bar missing 'Submit'");
409    }
410
411    #[test]
412    fn single_field_form_does_not_render_submit_tab() {
413        let mut form = Form::new("Survey".to_string(), single_select_field());
414        let context = ViewContext::new((80, 24));
415        let frame = form.render(&context);
416        let text: String = frame.lines().iter().map(Line::plain_text).collect::<Vec<_>>().join("\n");
417
418        assert!(!text.contains("Submit"));
419    }
420
421    #[test]
422    fn renders_only_active_field() {
423        let mut form = Form::new("Survey".to_string(), sample_fields());
424        let context = ViewContext::new((80, 24));
425
426        // Focus is on field 0 (Language / RadioSelect)
427        let frame = form.render(&context);
428        let text: String = frame.lines().iter().map(Line::plain_text).collect::<Vec<_>>().join("\n");
429        // The active field's options should be visible
430        assert!(text.contains("Rust"), "active field options not visible");
431        assert!(text.contains("TypeScript"), "active field options not visible");
432        // The non-active fields' expanded content should NOT be visible
433        // (MultiSelect options Alpha/Beta should not appear as expanded options)
434        // But the tab bar mentions "Features", so just check that the expanded
435        // checkbox options aren't rendered
436        assert!(!text.contains("Alpha"), "inactive field content should not appear");
437    }
438
439    #[tokio::test]
440    async fn tab_advances_to_next_pane() {
441        let mut form = Form::new("Survey".to_string(), sample_fields());
442        assert_eq!(form.focus.focused(), 0);
443        form.on_event(&Event::Key(key(KeyCode::Tab))).await;
444        assert_eq!(form.focus.focused(), 1);
445        form.on_event(&Event::Key(key(KeyCode::Tab))).await;
446        assert_eq!(form.focus.focused(), 2);
447        form.on_event(&Event::Key(key(KeyCode::Tab))).await;
448        assert_eq!(form.focus.focused(), 3); // Submit tab
449    }
450
451    #[tokio::test]
452    async fn enter_on_submit_tab_emits_submit() {
453        let mut form = Form::new("Survey".to_string(), sample_fields());
454        // Navigate to submit tab (index 3)
455        form.focus.focus(3);
456        let msgs = form.on_event(&Event::Key(key(KeyCode::Enter))).await.unwrap();
457        assert!(msgs.iter().any(|m| matches!(m, FormMessage::Submit)));
458    }
459
460    #[tokio::test]
461    async fn enter_on_field_advances() {
462        let mut form = Form::new("Survey".to_string(), sample_fields());
463        assert_eq!(form.focus.focused(), 0);
464        form.on_event(&Event::Key(key(KeyCode::Enter))).await;
465        assert_eq!(form.focus.focused(), 1);
466    }
467
468    #[tokio::test]
469    async fn single_field_form_enter_emits_submit() {
470        let mut form = Form::new("Survey".to_string(), single_select_field());
471        let msgs = form.on_event(&Event::Key(key(KeyCode::Enter))).await.unwrap();
472
473        assert!(msgs.iter().any(|m| matches!(m, FormMessage::Submit)));
474    }
475
476    #[tokio::test]
477    async fn empty_form_enter_emits_submit() {
478        let mut form = Form::new("Survey".to_string(), vec![]);
479        let msgs = form.on_event(&Event::Key(key(KeyCode::Enter))).await.unwrap();
480
481        assert!(msgs.iter().any(|m| matches!(m, FormMessage::Submit)));
482    }
483
484    #[test]
485    fn multi_field_form_still_has_submit_tab() {
486        let mut form = Form::new("Survey".to_string(), sample_fields());
487        let context = ViewContext::new((80, 24));
488        let frame = form.render(&context);
489        let text: String = frame.lines().iter().map(Line::plain_text).collect::<Vec<_>>().join("\n");
490
491        assert!(text.contains("Submit"));
492    }
493
494    #[tokio::test]
495    async fn multi_field_form_enter_before_submit_still_advances_focus() {
496        let mut form = Form::new("Survey".to_string(), sample_fields());
497        assert_eq!(form.focus.focused(), 0);
498
499        let msgs = form.on_event(&Event::Key(key(KeyCode::Enter))).await.unwrap();
500
501        assert!(msgs.is_empty());
502        assert_eq!(form.focus.focused(), 1);
503    }
504
505    #[tokio::test]
506    async fn left_right_navigate_tabs_for_select_fields() {
507        let mut form = Form::new("Survey".to_string(), sample_fields());
508        // Field 0 is a RadioSelect — Right should navigate to next tab
509        assert_eq!(form.focus.focused(), 0);
510        form.on_event(&Event::Key(key(KeyCode::Right))).await;
511        assert_eq!(form.focus.focused(), 1);
512
513        // Field 2 is a MultiSelect — Left should navigate to previous tab
514        form.focus.focus(2);
515        form.on_event(&Event::Key(key(KeyCode::Left))).await;
516        assert_eq!(form.focus.focused(), 1);
517    }
518
519    #[tokio::test]
520    async fn left_right_delegate_to_text_field() {
521        let mut form = Form::new("Survey".to_string(), sample_fields());
522        // Navigate to field 1 (Text field), type something, then use Left
523        form.focus.focus(1);
524        form.on_event(&Event::Key(key(KeyCode::Char('h')))).await;
525        form.on_event(&Event::Key(key(KeyCode::Char('i')))).await;
526        assert_eq!(form.focus.focused(), 1);
527
528        // Left should move cursor within text field, not change tab
529        form.on_event(&Event::Key(key(KeyCode::Left))).await;
530        assert_eq!(form.focus.focused(), 1); // still on same tab
531
532        // Verify the cursor moved in the text field
533        if let FormFieldKind::Text(ref tf) = form.fields[1].kind {
534            assert_eq!(tf.cursor_pos(), 1); // moved left from 2 to 1
535        } else {
536            panic!("expected Text field");
537        }
538    }
539
540    #[test]
541    fn is_answered_text_field() {
542        assert!(!FormFieldKind::Text(TextField::new(String::new())).is_answered());
543        assert!(FormFieldKind::Text(TextField::new("hello".to_string())).is_answered());
544    }
545
546    #[test]
547    fn is_answered_multi_select() {
548        let none_selected = FormFieldKind::MultiSelect(MultiSelect::new(
549            vec![SelectOption { value: "a".into(), title: "A".into(), description: None }],
550            vec![false],
551        ));
552        assert!(!none_selected.is_answered());
553
554        let some_selected = FormFieldKind::MultiSelect(MultiSelect::new(
555            vec![SelectOption { value: "a".into(), title: "A".into(), description: None }],
556            vec![true],
557        ));
558        assert!(some_selected.is_answered());
559    }
560
561    #[tokio::test]
562    async fn esc_emits_close() {
563        let mut form = Form::new("Survey".to_string(), sample_fields());
564        let msgs = form.on_event(&Event::Key(key(KeyCode::Esc))).await.unwrap();
565        assert!(msgs.iter().any(|m| matches!(m, FormMessage::Close)));
566    }
567
568    #[tokio::test]
569    async fn backtab_moves_backward() {
570        let mut form = Form::new("Survey".to_string(), sample_fields());
571        form.focus.focus(2);
572        form.on_event(&Event::Key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT))).await;
573        assert_eq!(form.focus.focused(), 1);
574    }
575
576    #[test]
577    fn submit_tab_renders_summary() {
578        let mut form = Form::new("Survey".to_string(), sample_fields());
579        form.focus.focus(3); // Submit tab
580        let context = ViewContext::new((80, 24));
581        let frame = form.render(&context);
582        let text: String = frame.lines().iter().map(Line::plain_text).collect::<Vec<_>>().join("\n");
583        assert!(text.contains("Review & Submit"));
584        assert!(text.contains("Language:"));
585        assert!(text.contains("Name:"));
586    }
587}