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