1use 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
11const SUMMARY_GAP: u16 = 1;
13
14pub struct Form<Msg> {
27 label_width: Option<u16>,
28 gap: u16,
29 summary: Vec<String>,
30 body: Vec<Node<Msg>>,
31}
32
33pub 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 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 pub fn ui(&mut self) -> &mut View<'a, Msg> {
51 self.ui
52 }
53}
54
55impl<Msg: 'static> Form<Msg> {
56 #[must_use]
58 pub fn new() -> Self {
59 Self { label_width: None, gap: 1, summary: Vec::new(), body: Vec::new() }
60 }
61
62 #[must_use]
65 pub fn label_width(mut self, cells: u16) -> Self {
66 self.label_width = Some(cells);
67 self
68 }
69
70 #[must_use]
72 pub fn gap(mut self, rows: u16) -> Self {
73 self.gap = rows;
74 self
75 }
76
77 #[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 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 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}