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
14pub 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
39pub 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
48pub 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 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 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 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 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 let frame = form.render(&context);
428 let text: String = frame.lines().iter().map(Line::plain_text).collect::<Vec<_>>().join("\n");
429 assert!(text.contains("Rust"), "active field options not visible");
431 assert!(text.contains("TypeScript"), "active field options not visible");
432 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); }
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 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 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 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 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 form.on_event(&Event::Key(key(KeyCode::Left))).await;
530 assert_eq!(form.focus.focused(), 1); if let FormFieldKind::Text(ref tf) = form.fields[1].kind {
534 assert_eq!(tf.cursor_pos(), 1); } 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); 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}