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
20#[doc = include_str!("../docs/form.md")]
21pub struct Form {
22 pub message: String,
23 pub fields: Vec<FormField>,
24 focus: FocusRing,
25}
26
27pub 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
36pub 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), }
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 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 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 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 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 let frame = form.render(&context);
366 let text: String = frame.lines().iter().map(Line::plain_text).collect::<Vec<_>>().join("\n");
367 assert!(text.contains("Rust"), "active field options not visible");
369 assert!(text.contains("TypeScript"), "active field options not visible");
370 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); }
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 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 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 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 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 form.on_event(&Event::Key(key(KeyCode::Left))).await;
431 assert_eq!(form.focus.focused(), 1); if let FormFieldKind::Text(ref tf) = form.fields[1].kind {
435 assert_eq!(tf.cursor_pos(), 1); } 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); 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}