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