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 #[test]
295 fn summary_lists_problems_above_the_fields() {
296 let app = Signup { summary: true, ..Signup::default() };
297 let mut h = Harness::new(app, 40, 14);
298 assert!(h.screen().starts_with("Name"));
299 h.send(Msg::Submit);
300 let screen = h.screen();
301 assert!(screen.contains("✕ 2 fields need attention"), "{screen}");
302 assert!(screen.contains("Use at least 3 characters\n"), "{screen}");
303 }
304}