Skip to main content

qframe/widgets/
form.rs

1//! Forms: fields laid out together, an error summary and Enter moving to the next field.
2
3use super::cells;
4use super::{Field, FormErrors};
5use crate::event::Event;
6use crate::geometry::{Rect, Size, clamp_u16};
7use crate::keymap::Key;
8use crate::text;
9use crate::widget::{Axis, EventCx, Flex, Length, MeasureCx, Node, NodeMut, PaintCx, View, Widget};
10
11/// Rows between the error summary and the first field.
12const SUMMARY_GAP: u16 = 1;
13
14/// A column of [`Field`]s that share one label layout, with an optional summary of problems
15/// above them.
16///
17/// Enter in a field whose control does not use it (a text input without `on_submit`) moves
18/// focus to the next field, and from the last field on to whatever follows the form, usually
19/// its submit button. The form does not validate: the application checks its values into
20/// [`FormErrors`], passes each message to its field and, on submit, returns
21/// [`FormErrors::focus_first`].
22///
23/// Style keys: `form-summary` (`bg`, `padding`), `form-summary-title` (`fg`, `bold`),
24/// `form-summary-marker` and `form-summary-item` (`fg`). The summary title is
25/// `quvyta.form.summary` with `n` problems; its marker is the `error` icon.
26pub struct Form<Msg> {
27    label_width: Option<u16>,
28    gap: u16,
29    summary: Vec<String>,
30    body: Vec<Node<Msg>>,
31}
32
33/// Adds fields to a [`Form`] inside [`Form::show`].
34pub struct FormFields<'v, 'a, Msg> {
35    ui: &'v mut View<'a, Msg>,
36    label_width: Option<u16>,
37}
38
39impl<'a, Msg: 'static> FormFields<'_, 'a, Msg> {
40    /// Adds `field` with the control built by `control`. The field takes the form's label
41    /// width unless it has its own.
42    pub fn field(&mut self, mut field: Field<Msg>, control: impl FnOnce(&mut View<'_, Msg>)) -> NodeMut<'_, Msg> {
43        if field.label_width.is_none() {
44            field.label_width = self.label_width;
45        }
46        self.ui.add_with(field, control).fill_width()
47    }
48
49    /// The form's column, for content between fields such as a sub-heading.
50    pub fn ui(&mut self) -> &mut View<'a, Msg> {
51        self.ui
52    }
53}
54
55impl<Msg: 'static> Form<Msg> {
56    /// A form with labels above controls and a row between fields.
57    #[must_use]
58    pub fn new() -> Self {
59        Self { label_width: None, gap: 1, summary: Vec::new(), body: Vec::new() }
60    }
61
62    /// Puts every field's label in a column `cells` wide beside its control while the form is
63    /// wide enough; on narrow screens labels move above controls.
64    #[must_use]
65    pub fn label_width(mut self, cells: u16) -> Self {
66        self.label_width = Some(cells);
67        self
68    }
69
70    /// Rows between fields; 1 by default.
71    #[must_use]
72    pub fn gap(mut self, rows: u16) -> Self {
73        self.gap = rows;
74        self
75    }
76
77    /// Lists every problem of `errors` above the fields. Nothing is shown while there are none.
78    #[must_use]
79    pub fn summary(mut self, errors: &FormErrors) -> Self {
80        self.summary = errors.iter().map(|(_, message)| message.to_owned()).collect();
81        self
82    }
83
84    /// Adds the form to `ui` with the fields `build` adds.
85    pub fn show<'v>(
86        mut self,
87        ui: &'v mut View<'_, Msg>,
88        build: impl FnOnce(&mut FormFields<'_, '_, Msg>),
89    ) -> NodeMut<'v, Msg> {
90        let mut children = Vec::new();
91        build(&mut FormFields { ui: &mut ui.nested(&mut children), label_width: self.label_width });
92        let mut column = Node::new(Flex::new(Axis::Column, children), 0);
93        column.layout.gap = self.gap;
94        column.layout.width = Length::Fill(1);
95        self.body = vec![column];
96        ui.add(self).fill_width()
97    }
98
99    /// Rows the error summary takes with `vertical_padding` above and below its lines, and the
100    /// gap after it; nothing while there are no problems.
101    fn summary_height(&self, vertical_padding: u16) -> u16 {
102        if self.summary.is_empty() {
103            return 0;
104        }
105        let rows = clamp_u16(i32::try_from(self.summary.len()).unwrap_or(i32::MAX)).saturating_add(1);
106        cells::sum([rows, vertical_padding.saturating_mul(2), SUMMARY_GAP])
107    }
108}
109
110impl<Msg: 'static> Default for Form<Msg> {
111    fn default() -> Self {
112        Self::new()
113    }
114}
115
116impl<Msg: 'static> Widget<Msg> for Form<Msg> {
117    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
118        let padding = cx.env().theme().style("form-summary", None, &[]).pair("padding").unwrap_or((1, 2));
119        let summary = self.summary_height(padding.0);
120        let body = self.body.first().map_or(Size::default(), |body| {
121            cx.measure_child(body, Size::new(available.width, available.height.saturating_sub(summary)))
122        });
123        let widest = self
124            .summary
125            .iter()
126            .map(|line| cells::sum([text::width(line), padding.1.saturating_mul(2), 3]))
127            .max()
128            .unwrap_or(0);
129        Size::new(body.width.max(widest), body.height.saturating_add(summary)).min(available)
130    }
131
132    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
133        let style = cx.style("form-summary", None, &[]);
134        let padding = style.padding();
135        let summary_height = self.summary_height(padding.top);
136        if !self.summary.is_empty() {
137            let block = Rect::new(area.x, area.y, area.width, summary_height - SUMMARY_GAP);
138            cx.clear(block, style.text().bg.unwrap_or_else(|| cx.color("surface")));
139            let inner = block.inset(padding);
140            let marker = cx.env().icons().glyph("error").into_owned();
141            let indent = text::width(&marker).saturating_add(2);
142            let marker_style = cx.style("form-summary-marker", None, &[]).text();
143            cx.text(inner.x, inner.y, &marker, marker_style, inner.width);
144            let title = crate::t!("quvyta.form.summary", n = self.summary.len());
145            let budget = inner.width.saturating_sub(indent);
146            let title_style = cx.style("form-summary-title", None, &[]).text();
147            let shown = text::truncate(&title, budget).into_owned();
148            cx.text(inner.x + i32::from(indent), inner.y, &shown, title_style, budget);
149            let item_style = cx.style("form-summary-item", None, &[]).text();
150            for (row, message) in self.summary.iter().enumerate() {
151                let y = inner.y + 1 + i32::try_from(row).unwrap_or(i32::MAX);
152                let shown = text::truncate(message, budget).into_owned();
153                cx.text(inner.x + i32::from(indent), y, &shown, item_style, budget);
154            }
155        }
156        if let Some(body) = self.body.first() {
157            let rest = Rect::new(
158                area.x,
159                area.y + i32::from(summary_height),
160                area.width,
161                area.height.saturating_sub(summary_height),
162            );
163            cx.paint_child(body, rest);
164        }
165    }
166
167    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
168        match event {
169            Event::Key(key) if key.is_plain(Key::Enter) => {
170                cx.focus_next();
171                true
172            }
173            _ => false,
174        }
175    }
176
177    fn children(&self) -> &[Node<Msg>] {
178        &self.body
179    }
180
181    fn children_mut(&mut self) -> &mut [Node<Msg>] {
182        &mut self.body
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use crate::runtime::{App, Command, Harness};
190    use crate::widgets::{Button, TextInput};
191
192    #[derive(Default)]
193    struct Signup {
194        name: String,
195        image: String,
196        errors: FormErrors,
197        submitted: bool,
198        label_width: Option<u16>,
199        summary: bool,
200    }
201
202    #[derive(Clone)]
203    enum Msg {
204        Name(String),
205        Image(String),
206        Submit,
207    }
208
209    impl Signup {
210        fn validate(&mut self) {
211            self.errors.check("name", self.name.chars().count() >= 3, "Use at least 3 characters");
212            self.errors.check("image", !self.image.is_empty(), "Choose an image");
213        }
214    }
215
216    impl App for Signup {
217        type Msg = Msg;
218        fn update(&mut self, msg: Msg) -> Command<Msg> {
219            match msg {
220                Msg::Name(name) => self.name = name,
221                Msg::Image(image) => self.image = image,
222                Msg::Submit => {
223                    self.validate();
224                    self.submitted = self.errors.is_empty();
225                    return self.errors.focus_first();
226                }
227            }
228            Command::none()
229        }
230        fn view(&self, ui: &mut View<'_, Msg>) {
231            let mut form = Form::new();
232            if let Some(width) = self.label_width {
233                form = form.label_width(width);
234            }
235            if self.summary {
236                form = form.summary(&self.errors);
237            }
238            form.show(ui, |form| {
239                form.field(Field::new("Name").required(true).hint("Lowercase").error(self.errors.get("name")), |ui| {
240                    ui.add(TextInput::new(&self.name).invalid(self.errors.has("name")).on_change(Msg::Name))
241                        .width(Length::Cells(20))
242                        .id("name");
243                });
244                form.field(Field::new("Image").error(self.errors.get("image")), |ui| {
245                    ui.add(TextInput::new(&self.image).on_change(Msg::Image)).width(Length::Cells(20)).id("image");
246                });
247            });
248            ui.add(Button::new("Create").on_press(Msg::Submit)).id("create");
249        }
250    }
251
252    #[test]
253    fn labels_above_with_required_word_hint_and_error() {
254        let mut h = Harness::new(Signup::default(), 40, 8);
255        assert_eq!(h.screen(), "Name  required\n  ❯\nLowercase\n\nImage\n  ❯\n  Create\n\n");
256        h.press("tab").type_text("ab");
257        h.send(Msg::Submit);
258        assert_eq!(h.screen().lines().nth(2), Some("✕ Use at least 3 characters"));
259        assert_eq!(h.fg(2, 2), Some(h.env().theme().color("danger").expect("danger")));
260        assert!(h.is_focused("name"), "submit focuses the first problem");
261    }
262
263    #[test]
264    fn label_brightens_while_its_control_has_focus() {
265        let mut h = Harness::new(Signup::default(), 40, 8);
266        let idle = h.fg(0, 0);
267        h.press("tab");
268        assert_ne!(h.fg(0, 0), idle);
269        assert!(h.is_bold(0, 0));
270    }
271
272    #[test]
273    fn enter_moves_to_the_next_field_and_then_to_the_button() {
274        let mut h = Harness::new(Signup::default(), 40, 8);
275        h.press("tab").press("enter");
276        assert!(h.is_focused("image"));
277        h.press("enter");
278        assert!(h.is_focused("create"));
279        h.press("enter");
280        assert!(!h.app().submitted);
281        assert!(h.is_focused("name"));
282    }
283
284    #[test]
285    fn labels_beside_controls_fall_back_above_when_narrow() {
286        let app = Signup { label_width: Some(10), ..Signup::default() };
287        let mut h = Harness::new(app, 40, 6);
288        assert_eq!(h.screen(), "Name          ❯\nrequired    Lowercase\n\nImage         ❯\n  Create\n\n");
289        let app = Signup { label_width: Some(10), ..Signup::default() };
290        h = Harness::new(app, 24, 7);
291        assert!(h.screen().starts_with("Name  required\n  ❯\n"), "{}", h.screen());
292    }
293
294    struct Note {
295        placeholder: &'static str,
296    }
297
298    impl App for Note {
299        type Msg = Msg;
300        fn update(&mut self, _: Msg) -> Command<Msg> {
301            Command::none()
302        }
303        fn view(&self, ui: &mut View<'_, Msg>) {
304            Form::new().label_width(16).show(ui, |form| {
305                form.field(Field::new("Project"), |ui| {
306                    ui.add(TextInput::new("").placeholder("qframe").on_change(Msg::Name));
307                });
308                form.field(Field::new("Note"), |ui| {
309                    ui.add(TextInput::new("").placeholder(self.placeholder).on_change(Msg::Image));
310                });
311            });
312        }
313    }
314
315    #[test]
316    fn a_control_wider_than_the_room_beside_its_label_goes_under_it_and_keeps_its_placeholder() {
317        for placeholder in ["A note for this session", "Eine Notiz für diese Sitzung"] {
318            let h = Harness::new(Note { placeholder }, 40, 6);
319            let screen = h.screen();
320            assert!(!screen.contains('…') && screen.contains(placeholder), "{placeholder}: {screen}");
321            let lines: Vec<&str> = screen.lines().collect();
322            assert!(lines[0].starts_with("Project") && lines[0].contains("qframe"), "stays beside: {screen}");
323            let note = lines.iter().position(|line| line.starts_with("Note")).unwrap_or_else(|| panic!("{screen}"));
324            assert_eq!(lines[note].trim_end(), "Note", "the label has its own line: {screen}");
325            assert!(lines[note + 1].contains(placeholder), "the control is right under it: {screen}");
326        }
327    }
328
329    #[test]
330    fn a_required_word_too_wide_for_the_label_column_moves_under_the_control() {
331        for code in ["en", "de", "fr", "ru"] {
332            let app = Signup { label_width: Some(8), ..Signup::default() };
333            let mut h = Harness::new(app, 40, 7);
334            h.set_locale(code);
335            let screen = h.screen();
336            let required = h.env().i18n().translate("quvyta.form.required", &[]);
337            assert!(screen.contains(&required) && !screen.contains('…'), "{code}: {screen}");
338            let lines: Vec<&str> = screen.lines().collect();
339            let control = |line: &str| line.find('❯');
340            let image = lines.iter().position(|line| line.starts_with("Image")).unwrap_or_else(|| panic!("{screen}"));
341            assert_eq!(control(lines[0]), control(lines[image]), "the controls stay in one column: {code}: {screen}");
342            if code != "en" {
343                assert!(lines[1].trim_start().starts_with(required.as_str()), "under the control: {code}: {screen}");
344                assert!(lines[2].trim_start().starts_with("Lowercase"), "the hint follows: {code}: {screen}");
345            }
346        }
347    }
348
349    #[test]
350    fn summary_lists_problems_above_the_fields() {
351        let app = Signup { summary: true, ..Signup::default() };
352        let mut h = Harness::new(app, 40, 14);
353        assert!(h.screen().starts_with("Name"));
354        h.send(Msg::Submit);
355        let screen = h.screen();
356        assert!(screen.contains("✕  2 fields need attention"), "{screen}");
357        assert!(screen.contains("Use at least 3 characters\n"), "{screen}");
358    }
359}