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 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 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}