1use teksilo_canvas::{Point, Rect, Size, SizeProposal};
31use teksilo_core::accessibility::AccessNodeBuilder;
32use teksilo_core::widget::{LayoutContext, PaintContext, PendingChild, Widget, WidgetPlacement};
33use teksilo_core::widget_id::WidgetId;
34use teksilo_i18n::LocalizedString;
35
36#[derive(Debug, Clone)]
38enum FormRow {
39 Pair(WidgetId, WidgetId),
41 FullWidth(WidgetId),
43}
44
45enum PendingFormRow {
47 Pair(PendingChild, PendingChild),
48 FullWidth(PendingChild),
49}
50
51impl std::fmt::Debug for PendingFormRow {
52 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53 match self {
54 Self::Pair(..) => f.write_str("PendingFormRow::Pair(..)"),
55 Self::FullWidth(..) => f.write_str("PendingFormRow::FullWidth(..)"),
56 }
57 }
58}
59
60#[derive(Debug)]
78pub struct FormLayout {
79 rows: Vec<FormRow>,
80 pending_rows: Vec<PendingFormRow>,
81 label_gap: f32,
82 row_spacing: f32,
83 a11y_label: Option<LocalizedString>,
87}
88
89impl FormLayout {
90 pub fn new() -> Self {
92 Self {
93 rows: Vec::new(),
94 pending_rows: Vec::new(),
95 label_gap: 0.0,
96 row_spacing: 0.0,
97 a11y_label: None,
98 }
99 }
100
101 pub fn label_gap(mut self, gap: f32) -> Self {
103 self.label_gap = gap;
104 self
105 }
106
107 pub fn row_spacing(mut self, spacing: f32) -> Self {
109 self.row_spacing = spacing;
110 self
111 }
112
113 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
120 self.a11y_label = Some(label.into());
121 self
122 }
123
124 pub fn line(mut self, label: impl Widget + 'static, field: impl Widget + 'static) -> Self {
126 self.pending_rows.push(PendingFormRow::Pair(
127 PendingChild::Deferred(Box::new(label)),
128 PendingChild::Deferred(Box::new(field)),
129 ));
130 self
131 }
132
133 pub fn line_ids(mut self, label_id: WidgetId, field_id: WidgetId) -> Self {
135 self.pending_rows.push(PendingFormRow::Pair(
136 PendingChild::Id(label_id),
137 PendingChild::Id(field_id),
138 ));
139 self
140 }
141
142 pub fn full_width(mut self, widget: impl Widget + 'static) -> Self {
144 self.pending_rows
145 .push(PendingFormRow::FullWidth(PendingChild::Deferred(Box::new(
146 widget,
147 ))));
148 self
149 }
150
151 pub fn full_width_id(mut self, id: WidgetId) -> Self {
153 self.pending_rows
154 .push(PendingFormRow::FullWidth(PendingChild::Id(id)));
155 self
156 }
157
158 fn all_child_ids(&self) -> Vec<WidgetId> {
160 let mut ids = Vec::new();
161 for row in &self.rows {
162 match row {
163 FormRow::Pair(l, f) => {
164 ids.push(*l);
165 ids.push(*f);
166 }
167 FormRow::FullWidth(id) => ids.push(*id),
168 }
169 }
170 ids
171 }
172
173 fn compute_label_width(&self, ctx: &LayoutContext) -> f32 {
175 let mut max_w = 0.0_f32;
176 for row in &self.rows {
177 if let FormRow::Pair(label_id, _) = row
178 && let Some(s) = ctx.child_size(*label_id, SizeProposal::unspecified())
179 {
180 max_w = max_w.max(s.width);
181 }
182 }
183 max_w
184 }
185}
186
187fn resolve_pending(
188 p: PendingChild,
189 ctx: &mut teksilo_core::build_context::BuildContext,
190) -> WidgetId {
191 match p {
192 PendingChild::Id(id) => id,
193 PendingChild::Deferred(w) => ctx.add_boxed(w),
194 }
195}
196
197impl Default for FormLayout {
198 fn default() -> Self {
199 Self::new()
200 }
201}
202
203impl Widget for FormLayout {
204 fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
205 let pending = std::mem::take(&mut self.pending_rows);
206 if !pending.is_empty() {
207 self.rows = pending
208 .into_iter()
209 .map(|row| match row {
210 PendingFormRow::Pair(label, field) => {
211 let l = resolve_pending(label, ctx);
212 let f = resolve_pending(field, ctx);
213 ctx.access_labelled_by(f, l);
218 FormRow::Pair(l, f)
219 }
220 PendingFormRow::FullWidth(child) => {
221 FormRow::FullWidth(resolve_pending(child, ctx))
222 }
223 })
224 .collect();
225 }
226 self.all_child_ids()
227 }
228
229 fn layout_response(
230 &self,
231 proposal: SizeProposal,
232 ctx: &LayoutContext,
233 ) -> teksilo_core::widget::LayoutResponse {
234 if self.rows.is_empty() {
235 return (proposal.resolve(0.0, 0.0)).into();
236 }
237
238 let label_col_width = self.compute_label_width(ctx);
239
240 let (available_width, field_col_width) = if let Some(w) = proposal.width {
242 let fcw = (w - label_col_width - self.label_gap).max(0.0);
243 (w, fcw)
244 } else {
245 let mut max_field_w = 0.0_f32;
247 let mut max_full_w = 0.0_f32;
248 for row in &self.rows {
249 match row {
250 FormRow::Pair(_, field_id) => {
251 if let Some(s) = ctx.child_size(*field_id, SizeProposal::unspecified()) {
252 max_field_w = max_field_w.max(s.width);
253 }
254 }
255 FormRow::FullWidth(id) => {
256 if let Some(s) = ctx.child_size(*id, SizeProposal::unspecified()) {
257 max_full_w = max_full_w.max(s.width);
258 }
259 }
260 }
261 }
262 let pair_width = if label_col_width > 0.0 || max_field_w > 0.0 {
263 label_col_width + self.label_gap + max_field_w
264 } else {
265 0.0
266 };
267 let total_w = pair_width.max(max_full_w);
268 let fcw = (total_w - label_col_width - self.label_gap).max(0.0);
269 (total_w, fcw)
270 };
271
272 let label_proposal = SizeProposal::with_width(label_col_width);
274 let field_proposal = SizeProposal::with_width(field_col_width);
275 let full_proposal = SizeProposal::with_width(available_width);
276
277 let mut total_height = 0.0_f32;
278 let mut active_count = 0_usize;
279
280 for row in &self.rows {
281 let row_h = match row {
282 FormRow::Pair(label_id, field_id) => {
283 let lh = ctx.child_size(*label_id, label_proposal).map(|s| s.height);
284 let fh = ctx.child_size(*field_id, field_proposal).map(|s| s.height);
285 match (lh, fh) {
286 (Some(l), Some(f)) => l.max(f),
287 (Some(l), None) => l,
288 (None, Some(f)) => f,
289 (None, None) => continue, }
291 }
292 FormRow::FullWidth(id) => match ctx.child_size(*id, full_proposal) {
293 Some(s) => s.height,
294 None => continue, },
296 };
297 total_height += row_h;
298 active_count += 1;
299 }
300
301 if active_count > 1 {
302 total_height += self.row_spacing * (active_count as f32 - 1.0);
303 }
304
305 Size::new(available_width, total_height).into()
306 }
307
308 fn place_children(
309 &self,
310 bounds: Rect,
311 _proposal: SizeProposal,
312 children: &mut [WidgetPlacement],
313 ctx: &LayoutContext,
314 ) {
315 if children.is_empty() {
316 return;
317 }
318
319 let label_col_width = self.compute_label_width(ctx);
320 let field_col_width = (bounds.width - label_col_width - self.label_gap).max(0.0);
321
322 let rtl = ctx.is_rtl();
323 let (label_x, field_x) = if rtl {
324 (bounds.x + field_col_width + self.label_gap, bounds.x)
325 } else {
326 (bounds.x, bounds.x + label_col_width + self.label_gap)
327 };
328
329 let label_proposal = SizeProposal::with_width(label_col_width);
330 let field_proposal = SizeProposal::with_width(field_col_width);
331 let full_proposal = SizeProposal::with_width(bounds.width);
332
333 let mut child_idx = 0;
334 let mut y = bounds.y;
335 let mut first_active = true;
336
337 for row in &self.rows {
338 match row {
339 FormRow::Pair(label_id, field_id) => {
340 let label_active =
341 child_idx < children.len() && children[child_idx].id == *label_id;
342 let field_check_idx = if label_active {
343 child_idx + 1
344 } else {
345 child_idx
346 };
347 let field_active = field_check_idx < children.len()
348 && children[field_check_idx].id == *field_id;
349
350 if !label_active && !field_active {
351 continue; }
353
354 if !first_active {
355 y += self.row_spacing;
356 }
357 first_active = false;
358
359 let label_h = if label_active {
360 ctx.child_size(*label_id, label_proposal)
361 .map(|s| s.height)
362 .unwrap_or(0.0)
363 } else {
364 0.0
365 };
366 let field_h = if field_active {
367 ctx.child_size(*field_id, field_proposal)
368 .map(|s| s.height)
369 .unwrap_or(0.0)
370 } else {
371 0.0
372 };
373 let row_h = label_h.max(field_h);
374
375 if label_active {
376 children[child_idx].origin = Point::new(label_x, y);
377 children[child_idx].size = Size::new(label_col_width, row_h);
378 child_idx += 1;
379 }
380 if field_active {
381 children[child_idx].origin = Point::new(field_x, y);
382 children[child_idx].size = Size::new(field_col_width, row_h);
383 child_idx += 1;
384 }
385
386 y += row_h;
387 }
388 FormRow::FullWidth(id) => {
389 if child_idx >= children.len() || children[child_idx].id != *id {
390 continue; }
392
393 if !first_active {
394 y += self.row_spacing;
395 }
396 first_active = false;
397
398 let child_h = ctx
399 .child_size(*id, full_proposal)
400 .map(|s| s.height)
401 .unwrap_or(0.0);
402
403 children[child_idx].origin = Point::new(bounds.x, y);
404 children[child_idx].size = Size::new(bounds.width, child_h);
405 child_idx += 1;
406
407 y += child_h;
408 }
409 }
410 }
411 }
412
413 fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {}
414
415 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
416 match self.a11y_label.as_ref() {
417 Some(ls) => {
418 builder.set_role(teksilo_core::accesskit::Role::Form);
419 builder.set_name(ls.resolve_now());
420 }
421 None => {
422 builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
423 }
424 }
425 }
426
427 fn children(&self) -> Vec<WidgetId> {
428 self.all_child_ids()
429 }
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435 use teksilo_core::widget_tree::WidgetTree;
436
437 #[derive(Debug)]
438 struct FixedLeaf(f32, f32);
439 impl Widget for FixedLeaf {
440 fn layout_response(
441 &self,
442 _proposal: SizeProposal,
443 _ctx: &LayoutContext,
444 ) -> teksilo_core::widget::LayoutResponse {
445 Size::new(self.0, self.1).into()
446 }
447 }
448
449 #[test]
450 fn basic_form_places_label_and_field() {
451 let mut tree = WidgetTree::new();
452 let label = tree.add(FixedLeaf(60.0, 20.0));
453 let field = tree.add(FixedLeaf(100.0, 25.0));
454 let _form = tree.add(FormLayout::new().line_ids(label, field));
455 tree.layout(SizeProposal::exact(300.0, 200.0));
456
457 assert!((tree.bounds(label).x - 0.0).abs() < 0.01);
458 assert!((tree.bounds(label).y - 0.0).abs() < 0.01);
459 assert!((tree.bounds(field).x - 60.0).abs() < 0.01);
460 assert!((tree.bounds(field).y - 0.0).abs() < 0.01);
461 assert!((tree.bounds(label).height - 25.0).abs() < 0.01);
463 assert!((tree.bounds(field).height - 25.0).abs() < 0.01);
464 }
465
466 #[test]
467 fn labeled_form_emits_form_role_and_name() {
468 let mut tree = WidgetTree::new();
472 let form = tree.add(FormLayout::new().label(teksilo_i18n::lit!("Account")));
473 tree.layout(SizeProposal::exact(300.0, 200.0));
474 let info = tree.accessibility_node(form);
475 assert_eq!(info.role(), teksilo_core::accesskit::Role::Form);
476 assert_eq!(info.name(), Some("Account"));
477 }
478
479 #[test]
480 fn line_wires_field_labelled_by_label() {
481 let mut tree = WidgetTree::new();
485 let label = tree.add(FixedLeaf(60.0, 20.0));
486 let field = tree.add(FixedLeaf(100.0, 25.0));
487 let _form = tree.add(FormLayout::new().line_ids(label, field));
488 tree.layout(SizeProposal::exact(300.0, 200.0));
489
490 let update = tree.sync_accessibility();
491 let field_nid = teksilo_core::accessibility::widget_id_to_node_id(field);
492 let label_nid = teksilo_core::accessibility::widget_id_to_node_id(label);
493 let field_node = update
494 .nodes
495 .iter()
496 .find(|(id, _)| *id == field_nid)
497 .map(|(_, n)| n)
498 .expect("field node present in AT tree");
499 assert!(
500 field_node.labelled_by().contains(&label_nid),
501 "field must be labelled_by its label node"
502 );
503 }
504
505 #[test]
506 fn unlabeled_form_is_presentational() {
507 let mut tree = WidgetTree::new();
508 let form = tree.add(FormLayout::new());
509 tree.layout(SizeProposal::exact(300.0, 200.0));
510 let info = tree.accessibility_node(form);
511 assert_eq!(info.role(), teksilo_core::accesskit::Role::GenericContainer);
512 }
513
514 #[test]
515 fn label_column_auto_sizes_to_widest() {
516 let mut tree = WidgetTree::new();
517 let l1 = tree.add(FixedLeaf(60.0, 20.0));
518 let f1 = tree.add(FixedLeaf(100.0, 20.0));
519 let l2 = tree.add(FixedLeaf(100.0, 20.0)); let f2 = tree.add(FixedLeaf(80.0, 20.0));
521 let _form = tree.add(FormLayout::new().line_ids(l1, f1).line_ids(l2, f2));
522 tree.layout(SizeProposal::exact(400.0, 200.0));
523
524 assert!((tree.bounds(f1).x - 100.0).abs() < 0.01);
526 assert!((tree.bounds(f2).x - 100.0).abs() < 0.01);
527 assert!((tree.bounds(l1).width - 100.0).abs() < 0.01);
529 assert!((tree.bounds(l2).width - 100.0).abs() < 0.01);
530 }
531
532 #[test]
533 fn full_width_row_spans_entire_width() {
534 let mut tree = WidgetTree::new();
535 let fw = tree.add(FixedLeaf(50.0, 30.0));
536 let _form = tree.add(FormLayout::new().full_width_id(fw));
537 tree.layout(SizeProposal::exact(400.0, 200.0));
538
539 assert!((tree.bounds(fw).x - 0.0).abs() < 0.01);
540 assert!((tree.bounds(fw).width - 400.0).abs() < 0.01);
541 assert!((tree.bounds(fw).height - 30.0).abs() < 0.01);
542 }
543
544 #[test]
545 fn mixed_pair_and_full_width_rows() {
546 let mut tree = WidgetTree::new();
547 let l1 = tree.add(FixedLeaf(80.0, 20.0));
548 let f1 = tree.add(FixedLeaf(100.0, 25.0));
549 let fw = tree.add(FixedLeaf(200.0, 30.0));
550 let l2 = tree.add(FixedLeaf(60.0, 20.0));
551 let f2 = tree.add(FixedLeaf(100.0, 20.0));
552 let _form = tree.add(
553 FormLayout::new()
554 .line_ids(l1, f1)
555 .full_width_id(fw)
556 .line_ids(l2, f2),
557 );
558 tree.layout(SizeProposal::exact(400.0, 200.0));
559
560 assert!((tree.bounds(l1).y - 0.0).abs() < 0.01);
562 assert!((tree.bounds(fw).y - 25.0).abs() < 0.01);
564 assert!((tree.bounds(l2).y - 55.0).abs() < 0.01);
566 }
567
568 #[test]
569 fn label_gap_applied() {
570 let mut tree = WidgetTree::new();
571 let label = tree.add(FixedLeaf(80.0, 20.0));
572 let field = tree.add(FixedLeaf(100.0, 20.0));
573 let _form = tree.add(FormLayout::new().label_gap(12.0).line_ids(label, field));
574 tree.layout(SizeProposal::exact(400.0, 200.0));
575
576 assert!((tree.bounds(field).x - 92.0).abs() < 0.01);
578 }
579
580 #[test]
581 fn row_spacing_applied() {
582 let mut tree = WidgetTree::new();
583 let l1 = tree.add(FixedLeaf(60.0, 20.0));
584 let f1 = tree.add(FixedLeaf(100.0, 25.0));
585 let l2 = tree.add(FixedLeaf(60.0, 20.0));
586 let f2 = tree.add(FixedLeaf(100.0, 20.0));
587 let _form = tree.add(
588 FormLayout::new()
589 .row_spacing(10.0)
590 .line_ids(l1, f1)
591 .line_ids(l2, f2),
592 );
593 tree.layout(SizeProposal::exact(400.0, 200.0));
594
595 assert!((tree.bounds(l1).y - 0.0).abs() < 0.01);
597 assert!((tree.bounds(l2).y - 35.0).abs() < 0.01);
598 }
599
600 #[test]
601 fn intrinsic_height_sums_rows() {
602 let mut tree = WidgetTree::new();
603 let l1 = tree.add(FixedLeaf(60.0, 20.0));
604 let f1 = tree.add(FixedLeaf(100.0, 25.0));
605 let l2 = tree.add(FixedLeaf(60.0, 30.0));
606 let f2 = tree.add(FixedLeaf(100.0, 20.0));
607 let form = tree.add(
608 FormLayout::new()
609 .row_spacing(5.0)
610 .line_ids(l1, f1)
611 .line_ids(l2, f2),
612 );
613 tree.layout(SizeProposal {
614 width: Some(400.0),
615 height: None,
616 });
617
618 assert!((tree.bounds(form).height - 60.0).abs() < 0.01);
620 }
621
622 #[test]
623 fn field_column_gets_remaining_width() {
624 let mut tree = WidgetTree::new();
625 let label = tree.add(FixedLeaf(80.0, 20.0));
626 let field = tree.add(FixedLeaf(100.0, 20.0));
627 let _form = tree.add(FormLayout::new().label_gap(10.0).line_ids(label, field));
628 tree.layout(SizeProposal::exact(400.0, 200.0));
629
630 assert!((tree.bounds(field).width - 310.0).abs() < 0.01);
632 }
633
634 #[test]
635 fn single_pair_row() {
636 let mut tree = WidgetTree::new();
637 let label = tree.add(FixedLeaf(70.0, 20.0));
638 let field = tree.add(FixedLeaf(100.0, 20.0));
639 let _form = tree.add(FormLayout::new().line_ids(label, field));
640 tree.layout(SizeProposal::exact(300.0, 200.0));
641
642 assert!((tree.bounds(label).x - 0.0).abs() < 0.01);
643 assert!((tree.bounds(field).x - 70.0).abs() < 0.01);
644 }
645
646 #[test]
647 fn empty_form() {
648 let mut tree = WidgetTree::new();
649 let form = tree.add(FormLayout::new());
650 tree.layout(SizeProposal {
651 width: Some(300.0),
652 height: None,
653 });
654
655 assert!((tree.bounds(form).height - 0.0).abs() < 0.01);
656 }
657
658 #[test]
659 fn dormant_row_excluded_from_layout() {
660 let mut tree = WidgetTree::new();
661 let l1 = tree.add(FixedLeaf(60.0, 20.0));
662 let f1 = tree.add(FixedLeaf(100.0, 25.0));
663 let l2 = tree.add(FixedLeaf(60.0, 20.0));
664 let f2 = tree.add(FixedLeaf(100.0, 30.0));
665 let l3 = tree.add(FixedLeaf(60.0, 20.0));
666 let f3 = tree.add(FixedLeaf(100.0, 20.0));
667 let form = tree.add(
668 FormLayout::new()
669 .row_spacing(10.0)
670 .line_ids(l1, f1)
671 .line_ids(l2, f2)
672 .line_ids(l3, f3),
673 );
674 tree.layout(SizeProposal::exact(400.0, 300.0));
675
676 assert!((tree.bounds(l3).y - 75.0).abs() < 0.01);
678
679 tree.set_dormant(l2);
681 tree.set_dormant(f2);
682 tree.layout(SizeProposal {
683 width: Some(400.0),
684 height: None,
685 });
686
687 assert!((tree.bounds(l3).y - 35.0).abs() < 0.01);
689 assert!((tree.bounds(f3).y - 35.0).abs() < 0.01);
690 assert!((tree.bounds(form).height - 55.0).abs() < 0.01);
692 }
693
694 #[test]
695 fn unbounded_width_uses_intrinsic() {
696 let mut tree = WidgetTree::new();
697 let l1 = tree.add(FixedLeaf(80.0, 20.0));
698 let f1 = tree.add(FixedLeaf(200.0, 20.0));
699 let form = tree.add(FormLayout::new().label_gap(10.0).line_ids(l1, f1));
700 tree.layout(SizeProposal {
701 width: None,
702 height: Some(200.0),
703 });
704
705 assert!((tree.bounds(form).width - 290.0).abs() < 0.01);
707 }
708
709 #[test]
710 fn deferred_line_api_works() {
711 let mut tree = WidgetTree::new();
712 let form = tree.add(
713 FormLayout::new()
714 .label_gap(10.0)
715 .line(FixedLeaf(70.0, 20.0), FixedLeaf(150.0, 25.0)),
716 );
717 tree.layout(SizeProposal {
718 width: Some(400.0),
719 height: None,
720 });
721
722 assert!((tree.bounds(form).height - 25.0).abs() < 0.01);
724 }
725
726 #[test]
727 fn rtl_swaps_label_and_field_columns() {
728 let mut tree = WidgetTree::new();
729 tree.set_layout_direction(teksilo_core::environment::LayoutDirection::RightToLeft);
730 let label = tree.add(FixedLeaf(80.0, 20.0));
731 let field = tree.add(FixedLeaf(100.0, 20.0));
732 let _form = tree.add(FormLayout::new().label_gap(10.0).line_ids(label, field));
733 tree.layout(SizeProposal::exact(400.0, 200.0));
734
735 assert!((tree.bounds(field).x - 0.0).abs() < 0.01);
738 assert!((tree.bounds(label).x - 320.0).abs() < 0.01);
739 }
740}