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 env = ui.env();
91        let mut children = Vec::new();
92        build(&mut FormFields { ui: &mut View::new(&mut children, env), label_width: self.label_width });
93        let mut column = Node::new(Flex::new(Axis::Column, children), 0);
94        column.layout.gap = self.gap;
95        column.layout.width = Length::Fill(1);
96        self.body = vec![column];
97        ui.add(self).fill_width()
98    }
99
100    /// Rows the error summary takes with `vertical_padding` above and below its lines, and the
101    /// gap after it; nothing while there are no problems.
102    fn summary_height(&self, vertical_padding: u16) -> u16 {
103        if self.summary.is_empty() {
104            return 0;
105        }
106        let rows = clamp_u16(i32::try_from(self.summary.len()).unwrap_or(i32::MAX)).saturating_add(1);
107        cells::sum([rows, vertical_padding.saturating_mul(2), SUMMARY_GAP])
108    }
109}
110
111impl<Msg: 'static> Default for Form<Msg> {
112    fn default() -> Self {
113        Self::new()
114    }
115}
116
117impl<Msg: 'static> Widget<Msg> for Form<Msg> {
118    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
119        let padding = cx.env().theme().style("form-summary", None, &[]).pair("padding").unwrap_or((1, 2));
120        let summary = self.summary_height(padding.0);
121        let body = self.body.first().map_or(Size::default(), |body| {
122            cx.measure_child(body, Size::new(available.width, available.height.saturating_sub(summary)))
123        });
124        let widest = self
125            .summary
126            .iter()
127            .map(|line| cells::sum([text::width(line), padding.1.saturating_mul(2), 3]))
128            .max()
129            .unwrap_or(0);
130        Size::new(body.width.max(widest), body.height.saturating_add(summary)).min(available)
131    }
132
133    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
134        let style = cx.style("form-summary", None, &[]);
135        let padding = style.padding();
136        let summary_height = self.summary_height(padding.top);
137        if !self.summary.is_empty() {
138            let block = Rect::new(area.x, area.y, area.width, summary_height - SUMMARY_GAP);
139            cx.clear(block, style.text().bg.unwrap_or_else(|| cx.color("surface")));
140            let inner = block.inset(padding);
141            let marker = cx.env().icons().glyph("error").into_owned();
142            let indent = text::width(&marker).saturating_add(2);
143            let marker_style = cx.style("form-summary-marker", None, &[]).text();
144            cx.text(inner.x, inner.y, &marker, marker_style, inner.width);
145            let title = crate::t!("quvyta.form.summary", n = self.summary.len());
146            let budget = inner.width.saturating_sub(indent);
147            let title_style = cx.style("form-summary-title", None, &[]).text();
148            let shown = text::truncate(&title, budget).into_owned();
149            cx.text(inner.x + i32::from(indent), inner.y, &shown, title_style, budget);
150            let item_style = cx.style("form-summary-item", None, &[]).text();
151            for (row, message) in self.summary.iter().enumerate() {
152                let y = inner.y + 1 + i32::try_from(row).unwrap_or(i32::MAX);
153                let shown = text::truncate(message, budget).into_owned();
154                cx.text(inner.x + i32::from(indent), y, &shown, item_style, budget);
155            }
156        }
157        if let Some(body) = self.body.first() {
158            let rest = Rect::new(
159                area.x,
160                area.y + i32::from(summary_height),
161                area.width,
162                area.height.saturating_sub(summary_height),
163            );
164            cx.paint_child(body, rest);
165        }
166    }
167
168    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
169        match event {
170            Event::Key(key) if key.is_plain(Key::Enter) => {
171                cx.focus_next();
172                true
173            }
174            _ => false,
175        }
176    }
177
178    fn children(&self) -> &[Node<Msg>] {
179        &self.body
180    }
181
182    fn children_mut(&mut self) -> &mut [Node<Msg>] {
183        &mut self.body
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use crate::runtime::{App, Command, Harness};
191    use crate::widgets::{Button, TextInput};
192
193    #[derive(Default)]
194    struct Signup {
195        name: String,
196        image: String,
197        errors: FormErrors,
198        submitted: bool,
199        label_width: Option<u16>,
200        summary: bool,
201    }
202
203    #[derive(Clone)]
204    enum Msg {
205        Name(String),
206        Image(String),
207        Submit,
208    }
209
210    impl Signup {
211        fn validate(&mut self) {
212            self.errors.check("name", self.name.chars().count() >= 3, "Use at least 3 characters");
213            self.errors.check("image", !self.image.is_empty(), "Choose an image");
214        }
215    }
216
217    impl App for Signup {
218        type Msg = Msg;
219        fn update(&mut self, msg: Msg) -> Command<Msg> {
220            match msg {
221                Msg::Name(name) => self.name = name,
222                Msg::Image(image) => self.image = image,
223                Msg::Submit => {
224                    self.validate();
225                    self.submitted = self.errors.is_empty();
226                    return self.errors.focus_first();
227                }
228            }
229            Command::none()
230        }
231        fn view(&self, ui: &mut View<'_, Msg>) {
232            let mut form = Form::new();
233            if let Some(width) = self.label_width {
234                form = form.label_width(width);
235            }
236            if self.summary {
237                form = form.summary(&self.errors);
238            }
239            form.show(ui, |form| {
240                form.field(Field::new("Name").required(true).hint("Lowercase").error(self.errors.get("name")), |ui| {
241                    ui.add(TextInput::new(&self.name).invalid(self.errors.has("name")).on_change(Msg::Name))
242                        .width(Length::Cells(20))
243                        .id("name");
244                });
245                form.field(Field::new("Image").error(self.errors.get("image")), |ui| {
246                    ui.add(TextInput::new(&self.image).on_change(Msg::Image)).width(Length::Cells(20)).id("image");
247                });
248            });
249            ui.add(Button::new("Create").on_press(Msg::Submit)).id("create");
250        }
251    }
252
253    #[test]
254    fn labels_above_with_required_word_hint_and_error() {
255        let mut h = Harness::new(Signup::default(), 40, 8);
256        assert_eq!(h.screen(), "Name  required\n  ❯\nLowercase\n\nImage\n  ❯\n  Create\n\n");
257        h.press("tab").type_text("ab");
258        h.send(Msg::Submit);
259        assert_eq!(h.screen().lines().nth(2), Some("✕ Use at least 3 characters"));
260        assert_eq!(h.fg(2, 2), Some(h.env().theme().color("danger").expect("danger")));
261        assert!(h.is_focused("name"), "submit focuses the first problem");
262    }
263
264    #[test]
265    fn label_brightens_while_its_control_has_focus() {
266        let mut h = Harness::new(Signup::default(), 40, 8);
267        let idle = h.fg(0, 0);
268        h.press("tab");
269        assert_ne!(h.fg(0, 0), idle);
270        assert!(h.is_bold(0, 0));
271    }
272
273    #[test]
274    fn enter_moves_to_the_next_field_and_then_to_the_button() {
275        let mut h = Harness::new(Signup::default(), 40, 8);
276        h.press("tab").press("enter");
277        assert!(h.is_focused("image"));
278        h.press("enter");
279        assert!(h.is_focused("create"));
280        h.press("enter");
281        assert!(!h.app().submitted);
282        assert!(h.is_focused("name"));
283    }
284
285    #[test]
286    fn labels_beside_controls_fall_back_above_when_narrow() {
287        let app = Signup { label_width: Some(10), ..Signup::default() };
288        let mut h = Harness::new(app, 40, 6);
289        assert_eq!(h.screen(), "Name          ❯\nrequired    Lowercase\n\nImage         ❯\n  Create\n\n");
290        let app = Signup { label_width: Some(10), ..Signup::default() };
291        h = Harness::new(app, 24, 7);
292        assert!(h.screen().starts_with("Name  required\n  ❯\n"), "{}", h.screen());
293    }
294
295    #[test]
296    fn summary_lists_problems_above_the_fields() {
297        let app = Signup { summary: true, ..Signup::default() };
298        let mut h = Harness::new(app, 40, 14);
299        assert!(h.screen().starts_with("Name"));
300        h.send(Msg::Submit);
301        let screen = h.screen();
302        assert!(screen.contains("✕  2 fields need attention"), "{screen}");
303        assert!(screen.contains("Use at least 3 characters\n"), "{screen}");
304    }
305}