返回 CodeWhale
form.rs
1 //! Package Input: a form, an ordered set of fields with inline validation.
2 //!
3 //! ```text
4 //! Name
5 //! › nightly-report▏
6 //! Project
7 //! —
8 //! ✕ Choose a project.
9 //! Notify me
10 //! ○ Off
11 //! ```
12 //!
13 //! A [`FormState`] holds the fields, which one has focus and what each one's
14 //! check last said. A [`Form`] paints it. The host owns what a submit means:
15 //! [`FormState::handle_key`] only says [`FormOutcome::Submitted`] when every
16 //! field passes, so the host never sees a submit of invalid data.
17 //!
18 //! Three kinds of field ([`FormFieldKind`]): a text field (one
19 //! [`TextInputState`], or a secret), a checkbox, and a read-only row. The
20 //! last is shown but never takes focus.
21 //!
22 //! Validation is a plain function the caller supplies,
23 //! `fn(&str) -> Result<(), Cow<'static, str>>` ([`FormValidator`]); the error
24 //! is the words shown, so the kit owns none of them. When it runs:
25 //!
26 //! - **On blur** (focus leaves the field) and **on submit** (every field): the
27 //! result is shown. An error appears as `✕` and its words under the field,
28 //! never as color alone.
29 //! - **On every edit**, quietly: an error already shown clears the moment the
30 //! text becomes valid (or changes to the new reason), but a field that was
31 //! not showing an error does not start showing one while the person is
32 //! still typing.
33 //! - A submit that fails clears nothing, moves focus to the first invalid
34 //! field and answers [`FormOutcome::Blocked`] with its index.
35 //!
36 //! Keys: `Tab`, `↓` and `Shift+Tab`, `↑` move focus (wrapping, skipping
37 //! read-only rows), `Space` toggles a checkbox, `Enter` submits and `Esc`
38 //! cancels. Everything else goes to the focused text field
39 //! ([`TextInputState::handle_key`]).
40
41 use std::borrow::Cow;
42
43 use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
44 use ratatui::{
45 buffer::Buffer,
46 layout::{Position, Rect},
47 style::Modifier,
48 };
49
50 use super::text_input::{FieldChrome, FieldNote, KeyEffect, paint_input_value};
51 use crate::{Paint, Role, Theme, glyphs, text};
52
53 /// A caller's check of a text field: `Ok`, or the words that say what is
54 /// wrong. Plain function so a field stays `Clone` and `Debug`.
55 pub type FormValidator = fn(&str) -> Result<(), Cow<'static, str>>;
56
57 /// What a field's check last said.
58 #[derive(Clone, Debug, Default, PartialEq, Eq)]
59 pub enum FormStatus {
60 /// Not checked yet, or edited since and not worth saying: nothing shows.
61 #[default]
62 Untouched,
63 /// Checked and passed. Nothing shows: a field with no complaint is fine.
64 Valid,
65 /// Checked and failed: `✕` and these words show under the field.
66 Invalid(Cow<'static, str>),
67 }
68
69 /// What kind of field, and its value.
70 #[derive(Clone, Debug)]
71 pub enum FormFieldKind {
72 Text(crate::TextInputState),
73 Check(bool),
74 ReadOnly(Cow<'static, str>),
75 }
76
77 /// One field of a [`FormState`], built with [`FormField::text`],
78 /// [`FormField::secret`], [`FormField::check`] or [`FormField::read_only`].
79 #[derive(Clone, Debug)]
80 pub struct FormField {
81 label: Cow<'static, str>,
82 help: Option<Cow<'static, str>>,
83 placeholder: Option<Cow<'static, str>>,
84 kind: FormFieldKind,
85 validate: Option<FormValidator>,
86 /// A checkbox that must be on, and the words when it is not.
87 required: Option<Cow<'static, str>>,
88 status: FormStatus,
89 initial_text: String,
90 initial_checked: bool,
91 }
92
93 impl FormField {
94 fn new(label: impl Into<Cow<'static, str>>, kind: FormFieldKind) -> Self {
95 Self {
96 label: label.into(),
97 help: None,
98 placeholder: None,
99 kind,
100 validate: None,
101 required: None,
102 status: FormStatus::Untouched,
103 initial_text: String::new(),
104 initial_checked: false,
105 }
106 }
107
108 /// A single-line text field.
109 #[must_use]
110 pub fn text(label: impl Into<Cow<'static, str>>) -> Self {
111 Self::new(label, FormFieldKind::Text(crate::TextInputState::new()))
112 }
113
114 /// A secret: painted as a mask, never as its text.
115 #[must_use]
116 pub fn secret(label: impl Into<Cow<'static, str>>) -> Self {
117 Self::new(label, FormFieldKind::Text(crate::TextInputState::secret()))
118 }
119
120 /// A checkbox.
121 #[must_use]
122 pub fn check(label: impl Into<Cow<'static, str>>, checked: bool) -> Self {
123 let mut field = Self::new(label, FormFieldKind::Check(checked));
124 field.initial_checked = checked;
125 field
126 }
127
128 /// A row that shows a value and takes no focus.
129 #[must_use]
130 pub fn read_only(
131 label: impl Into<Cow<'static, str>>,
132 value: impl Into<Cow<'static, str>>,
133 ) -> Self {
134 Self::new(label, FormFieldKind::ReadOnly(value.into()))
135 }
136
137 /// The starting text of a text field (what [`FormState::is_dirty`]
138 /// compares against).
139 #[must_use]
140 pub fn value(mut self, text: &str) -> Self {
141 if let FormFieldKind::Text(input) = &mut self.kind {
142 input.set_text(text);
143 self.initial_text = input.text().to_owned();
144 }
145 self
146 }
147
148 #[must_use]
149 pub fn placeholder(mut self, placeholder: impl Into<Cow<'static, str>>) -> Self {
150 self.placeholder = Some(placeholder.into());
151 self
152 }
153
154 /// Muted guidance under the field, shown while it has no error.
155 #[must_use]
156 pub fn help(mut self, help: impl Into<Cow<'static, str>>) -> Self {
157 self.help = Some(help.into());
158 self
159 }
160
161 /// The check a text field runs on blur and on submit.
162 #[must_use]
163 pub fn validate(mut self, validate: FormValidator) -> Self {
164 self.validate = Some(validate);
165 self
166 }
167
168 /// A checkbox that blocks submit until it is on, with the words to show.
169 #[must_use]
170 pub fn require(mut self, message: impl Into<Cow<'static, str>>) -> Self {
171 self.required = Some(message.into());
172 self
173 }
174
175 #[must_use]
176 pub fn label(&self) -> &str {
177 &self.label
178 }
179
180 #[must_use]
181 pub const fn kind(&self) -> &FormFieldKind {
182 &self.kind
183 }
184
185 #[must_use]
186 pub const fn status(&self) -> &FormStatus {
187 &self.status
188 }
189
190 /// The text of a text field.
191 #[must_use]
192 pub fn text_value(&self) -> Option<&str> {
193 match &self.kind {
194 FormFieldKind::Text(input) => Some(input.text()),
195 _ => None,
196 }
197 }
198
199 /// The state of a checkbox.
200 #[must_use]
201 pub const fn checked(&self) -> Option<bool> {
202 match self.kind {
203 FormFieldKind::Check(on) => Some(on),
204 _ => None,
205 }
206 }
207
208 const fn focusable(&self) -> bool {
209 !matches!(self.kind, FormFieldKind::ReadOnly(_))
210 }
211
212 const fn has_rule(&self) -> bool {
213 self.validate.is_some() || self.required.is_some()
214 }
215
216 fn check_now(&self) -> Result<(), Cow<'static, str>> {
217 match &self.kind {
218 FormFieldKind::Text(input) => self.validate.map_or(Ok(()), |check| check(input.text())),
219 FormFieldKind::Check(on) => match (&self.required, on) {
220 (Some(message), false) => Err(message.clone()),
221 _ => Ok(()),
222 },
223 FormFieldKind::ReadOnly(_) => Ok(()),
224 }
225 }
226
227 fn is_dirty(&self) -> bool {
228 match &self.kind {
229 FormFieldKind::Text(input) => input.text() != self.initial_text,
230 FormFieldKind::Check(on) => *on != self.initial_checked,
231 FormFieldKind::ReadOnly(_) => false,
232 }
233 }
234 }
235
236 /// What a key did to a [`FormState`]: the message a host reacts to.
237 #[derive(Clone, Debug, PartialEq, Eq)]
238 pub enum FormOutcome {
239 /// The key means nothing to the form; the host may use it.
240 Ignored,
241 /// Something changed (text, a cursor, a checkbox, focus, an error
242 /// shown or cleared); repaint.
243 Changed,
244 /// Enter, and every field passes.
245 Submitted,
246 /// Enter, but field `n` is invalid. Its error is showing and it has
247 /// focus; nothing was cleared.
248 Blocked(usize),
249 /// Esc. [`FormState::is_dirty`] says whether there is anything to lose.
250 Cancelled,
251 }
252
253 /// The fields of a form, which one has focus and what each check said.
254 #[derive(Clone, Debug, Default)]
255 pub struct FormState {
256 fields: Vec<FormField>,
257 focus: usize,
258 }
259
260 impl FormState {
261 /// A form over `fields`, focus on the first one that takes it.
262 #[must_use]
263 pub fn new(fields: Vec<FormField>) -> Self {
264 let focus = fields.iter().position(FormField::focusable).unwrap_or(0);
265 Self { fields, focus }
266 }
267
268 #[must_use]
269 pub fn fields(&self) -> &[FormField] {
270 &self.fields
271 }
272
273 #[must_use]
274 pub fn len(&self) -> usize {
275 self.fields.len()
276 }
277
278 #[must_use]
279 pub fn is_empty(&self) -> bool {
280 self.fields.is_empty()
281 }
282
283 #[must_use]
284 pub fn field(&self, index: usize) -> Option<&FormField> {
285 self.fields.get(index)
286 }
287
288 /// The index of the focused field.
289 #[must_use]
290 pub const fn focus(&self) -> usize {
291 self.focus
292 }
293
294 /// The text of field `index`, if it is a text field.
295 #[must_use]
296 pub fn text(&self, index: usize) -> Option<&str> {
297 self.fields.get(index)?.text_value()
298 }
299
300 /// The state of field `index`, if it is a checkbox.
301 #[must_use]
302 pub fn checked(&self, index: usize) -> Option<bool> {
303 self.fields.get(index)?.checked()
304 }
305
306 #[must_use]
307 pub fn status(&self, index: usize) -> Option<&FormStatus> {
308 self.fields.get(index).map(FormField::status)
309 }
310
311 /// Whether any field differs from what it started as.
312 #[must_use]
313 pub fn is_dirty(&self) -> bool {
314 self.fields.iter().any(FormField::is_dirty)
315 }
316
317 /// Show `status` on field `index`: how a host reports what only it can
318 /// know (the name is taken, the project is gone).
319 pub fn set_status(&mut self, index: usize, status: FormStatus) {
320 if let Some(field) = self.fields.get_mut(index) {
321 field.status = status;
322 }
323 }
324
325 /// Move focus to `index` (ignored for a read-only row or an index past
326 /// the end). The field it leaves is checked, as on any blur.
327 pub fn set_focus(&mut self, index: usize) {
328 if self.fields.get(index).is_some_and(FormField::focusable) && index != self.focus {
329 self.blur();
330 self.focus = index;
331 }
332 }
333
334 /// Run the checks of every field now; the index of the first invalid one.
335 pub fn validate_all(&mut self) -> Option<usize> {
336 let mut first = None;
337 for (i, field) in self.fields.iter_mut().enumerate() {
338 if !field.has_rule() {
339 continue;
340 }
341 field.status = match field.check_now() {
342 Ok(()) => FormStatus::Valid,
343 Err(reason) => {
344 first.get_or_insert(i);
345 FormStatus::Invalid(reason)
346 }
347 };
348 }
349 first
350 }
351
352 /// Whether every field passes its check right now. Shows nothing.
353 #[must_use]
354 pub fn is_valid(&self) -> bool {
355 self.fields.iter().all(|f| f.check_now().is_ok())
356 }
357
358 /// Submit: check every field, and either answer
359 /// [`FormOutcome::Submitted`] or put focus on the first invalid field
360 /// and answer [`FormOutcome::Blocked`].
361 pub fn submit(&mut self) -> FormOutcome {
362 match self.validate_all() {
363 None => FormOutcome::Submitted,
364 Some(first) => {
365 self.focus = first;
366 FormOutcome::Blocked(first)
367 }
368 }
369 }
370
371 /// Insert pasted text into the focused text field.
372 pub fn paste(&mut self, text: &str) -> FormOutcome {
373 let Some(FormFieldKind::Text(input)) = self.fields.get_mut(self.focus).map(|f| &mut f.kind)
374 else {
375 return FormOutcome::Ignored;
376 };
377 if input.paste(text) == crate::TextInputOutcome::Ignored {
378 return FormOutcome::Ignored;
379 }
380 self.after_edit();
381 FormOutcome::Changed
382 }
383
384 /// Apply a key press and say what happened (see the module's key list).
385 /// Navigation and editing accept held-key repeats. Submit, cancel and
386 /// checkbox toggles require an initial press; modified navigation and
387 /// activation keys belong to the host. Releases are ignored.
388 pub fn handle_key(&mut self, key: KeyEvent) -> FormOutcome {
389 if key.kind == KeyEventKind::Release || self.fields.is_empty() {
390 return FormOutcome::Ignored;
391 }
392 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
393 match key.code {
394 KeyCode::Tab | KeyCode::BackTab
395 if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT =>
396 {
397 self.step(key.code == KeyCode::Tab && !shift)
398 }
399 KeyCode::Down if key.modifiers.is_empty() => self.step(true),
400 KeyCode::Up if key.modifiers.is_empty() => self.step(false),
401 KeyCode::Enter if key.kind == KeyEventKind::Press && key.modifiers.is_empty() => {
402 self.submit()
403 }
404 KeyCode::Esc if key.kind == KeyEventKind::Press && key.modifiers.is_empty() => {
405 FormOutcome::Cancelled
406 }
407 _ => self.edit(key),
408 }
409 }
410
411 fn edit(&mut self, key: KeyEvent) -> FormOutcome {
412 match self.fields.get_mut(self.focus).map(|f| &mut f.kind) {
413 Some(FormFieldKind::Text(input)) => match input.apply(key) {
414 KeyEffect::Nothing => FormOutcome::Ignored,
415 KeyEffect::Moved => FormOutcome::Changed,
416 // Enter and Esc never reach here: the form took them.
417 KeyEffect::Submit | KeyEffect::Cancel => FormOutcome::Ignored,
418 KeyEffect::Edited => {
419 self.after_edit();
420 FormOutcome::Changed
421 }
422 },
423 Some(FormFieldKind::Check(on))
424 if key.code == KeyCode::Char(' ')
425 && key.kind == KeyEventKind::Press
426 && key.modifiers.is_empty() =>
427 {
428 *on = !*on;
429 self.after_edit();
430 FormOutcome::Changed
431 }
432 _ => FormOutcome::Ignored,
433 }
434 }
435
436 /// Move focus one field, wrapping and skipping read-only rows. Ignored
437 /// when there is nowhere to go, so the host can use the key.
438 fn step(&mut self, forward: bool) -> FormOutcome {
439 let n = self.fields.len();
440 let target = (1..n)
441 .map(|d| {
442 if forward {
443 (self.focus + d) % n
444 } else {
445 (self.focus + n - d) % n
446 }
447 })
448 .find(|i| self.fields[*i].focusable());
449 match target {
450 Some(to) => {
451 self.blur();
452 self.focus = to;
453 FormOutcome::Changed
454 }
455 None => FormOutcome::Ignored,
456 }
457 }
458
459 /// Focus is leaving the focused field: show what its check says.
460 fn blur(&mut self) {
461 if let Some(field) = self.fields.get_mut(self.focus)
462 && field.has_rule()
463 {
464 field.status = match field.check_now() {
465 Ok(()) => FormStatus::Valid,
466 Err(reason) => FormStatus::Invalid(reason),
467 };
468 }
469 }
470
471 /// The focused field was edited: re-run its check without announcing a
472 /// new error. See the module's note on when errors show.
473 fn after_edit(&mut self) {
474 let Some(field) = self.fields.get_mut(self.focus) else {
475 return;
476 };
477 if !field.has_rule() {
478 return;
479 }
480 let result = field.check_now();
481 field.status = match (std::mem::take(&mut field.status), result) {
482 (FormStatus::Invalid(_), Err(reason)) => FormStatus::Invalid(reason),
483 (FormStatus::Invalid(_) | FormStatus::Valid, Ok(())) => FormStatus::Valid,
484 _ => FormStatus::Untouched,
485 };
486 }
487 }
488
489 /// The words a [`Form`] shows itself. English by default.
490 #[derive(Clone, Debug, PartialEq, Eq)]
491 pub struct FormWords {
492 /// A checkbox that is on.
493 pub on: Cow<'static, str>,
494 /// A checkbox that is off.
495 pub off: Cow<'static, str>,
496 /// Under a read-only row.
497 pub read_only: Cow<'static, str>,
498 }
499
500 impl Default for FormWords {
501 fn default() -> Self {
502 Self {
503 on: Cow::Borrowed("On"),
504 off: Cow::Borrowed("Off"),
505 read_only: Cow::Borrowed("Read only"),
506 }
507 }
508 }
509
510 /// Paints a [`FormState`]: each field as a label, its input and, under it,
511 /// an error (`✕` and words), help, or what a read-only row is. Fields stack
512 /// without gaps; when the form is taller than its area it scrolls to keep
513 /// the focused field in view.
514 #[derive(Clone, Debug)]
515 pub struct Form<'a> {
516 state: &'a FormState,
517 focused: bool,
518 words: FormWords,
519 }
520
521 impl<'a> Form<'a> {
522 #[must_use]
523 pub fn new(state: &'a FormState) -> Self {
524 Self {
525 state,
526 focused: true,
527 words: FormWords::default(),
528 }
529 }
530
531 /// Whether the form has the host's focus. An unfocused form paints no
532 /// prompt and no cursor; its state still remembers which field is next.
533 #[must_use]
534 pub const fn focused(mut self, focused: bool) -> Self {
535 self.focused = focused;
536 self
537 }
538
539 #[must_use]
540 pub fn words(mut self, words: &FormWords) -> Self {
541 self.words = words.clone();
542 self
543 }
544
545 fn chrome<'f>(&'f self, index: usize, field: &'f FormField) -> FieldChrome<'f> {
546 let note = match (&field.status, &field.kind) {
547 (FormStatus::Invalid(reason), _) => Some(FieldNote::error(reason)),
548 (_, FormFieldKind::ReadOnly(_)) => Some(FieldNote::locked(&self.words.read_only)),
549 _ => field.help.as_deref().map(FieldNote::help),
550 };
551 FieldChrome {
552 label: Some(&field.label),
553 focused: self.focused && index == self.state.focus && field.focusable(),
554 dimmed: matches!(field.kind, FormFieldKind::ReadOnly(_)),
555 note,
556 }
557 }
558
559 /// Each visible field and the rows it gets in `area`. A form taller than
560 /// `area` starts at the first field from which the focused one still
561 /// fits.
562 fn layout(&self, area: Rect) -> Vec<(usize, Rect)> {
563 let heights: Vec<u16> = self
564 .state
565 .fields
566 .iter()
567 .enumerate()
568 .map(|(i, f)| self.chrome(i, f).height(area.width))
569 .collect();
570 let focus = self.state.focus.min(heights.len().saturating_sub(1));
571 let mut first = focus;
572 let mut used = heights.get(focus).copied().unwrap_or(0);
573 while first > 0 && used.saturating_add(heights[first - 1]) <= area.height {
574 first -= 1;
575 used = used.saturating_add(heights[first]);
576 }
577 let mut y = area.y;
578 let mut placed = Vec::new();
579 for (i, height) in heights.iter().enumerate().skip(first) {
580 let room = area.bottom().saturating_sub(y);
581 if room == 0 {
582 break;
583 }
584 let height = (*height).min(room);
585 placed.push((i, Rect { y, height, ..area }));
586 y += height;
587 }
588 placed
589 }
590
591 /// Where the terminal's own cursor goes when the form is painted in
592 /// `area`: the cursor cell of the focused text field.
593 #[must_use]
594 pub fn cursor_position(&self, area: Rect) -> Option<Position> {
595 let (index, rect) = self
596 .layout(area)
597 .into_iter()
598 .find(|(i, _)| *i == self.state.focus)?;
599 let field = &self.state.fields[index];
600 let FormFieldKind::Text(input) = &field.kind else {
601 return None;
602 };
603 let placed = self.chrome(index, field).place(rect)?;
604 let col = if input.is_empty() {
605 0
606 } else {
607 input.window(usize::from(placed.value.width)).cursor_col?
608 };
609 let x = placed
610 .value
611 .x
612 .saturating_add(u16::try_from(col).unwrap_or(u16::MAX));
613 (self.focused && placed.value.width > 0 && x < placed.value.right())
614 .then_some(Position::new(x, placed.value.y))
615 }
616 }
617
618 impl Paint for Form<'_> {
619 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
620 let area = area.intersection(buf.area);
621 for (index, rect) in self.layout(area) {
622 let field = &self.state.fields[index];
623 let chrome = self.chrome(index, field);
624 let (focused, dimmed) = (chrome.focused, chrome.dimmed);
625 chrome.paint(rect, buf, theme, |value, buf| match &field.kind {
626 FormFieldKind::Text(input) => paint_input_value(
627 input,
628 field.placeholder.as_deref(),
629 focused,
630 dimmed,
631 value,
632 buf,
633 theme,
634 ),
635 FormFieldKind::Check(on) => self.paint_check(*on, focused, value, buf, theme),
636 FormFieldKind::ReadOnly(shown) => {
637 let safe = text::display_safe(shown);
638 let shown = text::truncate(&safe, usize::from(value.width), theme.ascii());
639 buf.set_stringn(
640 value.x,
641 value.y,
642 shown,
643 usize::from(value.width),
644 theme.fg(Role::Muted).add_modifier(Modifier::DIM),
645 );
646 }
647 });
648 }
649 }
650
651 fn height(&self, width: u16, _theme: &Theme) -> u16 {
652 self.state
653 .fields
654 .iter()
655 .enumerate()
656 .map(|(i, f)| self.chrome(i, f).height(width))
657 .fold(0, u16::saturating_add)
658 }
659 }
660
661 impl Form<'_> {
662 /// `● On` / `○ Off` (`[x]` / `[ ]` in ASCII, as the picker draws them):
663 /// a shape and a word, and bold when focused.
664 fn paint_check(&self, on: bool, focused: bool, area: Rect, buf: &mut Buffer, theme: &Theme) {
665 let ascii = theme.ascii();
666 let (mark, word, role) = match (on, ascii) {
667 (true, false) => (glyphs::CURRENT, &self.words.on, Role::Foreground),
668 (false, false) => (glyphs::AVAILABLE, &self.words.off, Role::Muted),
669 (true, true) => ("[x]", &self.words.on, Role::Foreground),
670 (false, true) => ("[ ]", &self.words.off, Role::Muted),
671 };
672 let mut style = theme.fg(role);
673 if focused {
674 style = style.add_modifier(Modifier::BOLD);
675 }
676 let line = format!("{mark} {}", text::display_safe(word));
677 let line = text::truncate(&line, usize::from(area.width), ascii);
678 buf.set_stringn(area.x, area.y, line, usize::from(area.width), style);
679 }
680 }
681
681 lines RUST