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
20pub struct Form {
25 pub message: String,
26 pub fields: Vec<FormField>,
27 focus: FocusRing,
28}
29
30pub 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
39pub 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), }
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 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 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 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 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 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 assert!(text.contains("Rust"), "active field options not visible");
419 assert!(
420 text.contains("TypeScript"),
421 "active field options not visible"
422 );
423 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); }
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 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 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 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 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 form.on_event(&Event::Key(key(KeyCode::Left))).await;
490 assert_eq!(form.focus.focused(), 1); if let FormFieldKind::Text(ref tf) = form.fields[1].kind {
494 assert_eq!(tf.cursor_pos(), 1); } 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); 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}