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(
126 mut self,
127 label: impl teksilo_core::IntoTeksiChild,
128 field: impl teksilo_core::IntoTeksiChild,
129 ) -> Self {
130 self.pending_rows.push(PendingFormRow::Pair(
131 teksilo_core::IntoTeksiChild::into_pending(label),
132 teksilo_core::IntoTeksiChild::into_pending(field),
133 ));
134 self
135 }
136
137 pub fn lines<L, F>(self, rows: impl IntoIterator<Item = (L, F)>) -> Self
143 where
144 L: teksilo_core::IntoTeksiChild,
145 F: teksilo_core::IntoTeksiChild,
146 {
147 rows.into_iter()
148 .fold(self, |form, (label, field)| form.line(label, field))
149 }
150
151 pub fn line_ids(self, rows: impl IntoIterator<Item = (WidgetId, WidgetId)>) -> Self {
159 rows.into_iter()
160 .fold(self, |form, (label, field)| form.line(label, field))
161 }
162
163 pub fn full_width(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
165 self.pending_rows.push(PendingFormRow::FullWidth(
166 teksilo_core::IntoTeksiChild::into_pending(widget),
167 ));
168 self
169 }
170
171 pub fn full_width_rows(
176 self,
177 iter: impl IntoIterator<Item = impl teksilo_core::IntoTeksiChild>,
178 ) -> Self {
179 iter.into_iter().fold(self, Self::full_width)
180 }
181
182 fn all_child_ids(&self) -> Vec<WidgetId> {
184 let mut ids = Vec::new();
185 for row in &self.rows {
186 match row {
187 FormRow::Pair(l, f) => {
188 ids.push(*l);
189 ids.push(*f);
190 }
191 FormRow::FullWidth(id) => ids.push(*id),
192 }
193 }
194 ids
195 }
196
197 fn compute_label_width(&self, ctx: &LayoutContext) -> f32 {
199 let mut max_w = 0.0_f32;
200 for row in &self.rows {
201 if let FormRow::Pair(label_id, _) = row
202 && let Some(s) = ctx.child_size(*label_id, SizeProposal::unspecified())
203 {
204 max_w = max_w.max(s.width);
205 }
206 }
207 max_w
208 }
209}
210
211fn resolve_pending(
212 p: PendingChild,
213 ctx: &mut teksilo_core::build_context::BuildContext,
214) -> WidgetId {
215 match p {
216 PendingChild::Id(id) => id,
217 PendingChild::Deferred(w) => ctx.add_boxed(w),
218 }
219}
220
221impl Default for FormLayout {
222 fn default() -> Self {
223 Self::new()
224 }
225}
226
227impl Widget for FormLayout {
228 fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
229 let pending = std::mem::take(&mut self.pending_rows);
230 if !pending.is_empty() {
231 self.rows = pending
232 .into_iter()
233 .map(|row| match row {
234 PendingFormRow::Pair(label, field) => {
235 let l = resolve_pending(label, ctx);
236 let f = resolve_pending(field, ctx);
237 ctx.access_labelled_by(f, l);
241 FormRow::Pair(l, f)
242 }
243 PendingFormRow::FullWidth(child) => {
244 FormRow::FullWidth(resolve_pending(child, ctx))
245 }
246 })
247 .collect();
248 }
249 self.all_child_ids()
250 }
251
252 fn preserves_children_on_rebuild(&self) -> bool {
265 true
266 }
267
268 fn layout_response(
269 &self,
270 proposal: SizeProposal,
271 ctx: &LayoutContext,
272 ) -> teksilo_core::widget::LayoutResponse {
273 if self.rows.is_empty() {
274 return (proposal.resolve(0.0, 0.0)).into();
275 }
276
277 let label_col_width = self.compute_label_width(ctx);
278
279 let (available_width, field_col_width) = if let Some(w) = proposal.width {
281 let fcw = (w - label_col_width - self.label_gap).max(0.0);
282 (w, fcw)
283 } else {
284 let mut max_field_w = 0.0_f32;
286 let mut max_full_w = 0.0_f32;
287 for row in &self.rows {
288 match row {
289 FormRow::Pair(_, field_id) => {
290 if let Some(s) = ctx.child_size(*field_id, SizeProposal::unspecified()) {
291 max_field_w = max_field_w.max(s.width);
292 }
293 }
294 FormRow::FullWidth(id) => {
295 if let Some(s) = ctx.child_size(*id, SizeProposal::unspecified()) {
296 max_full_w = max_full_w.max(s.width);
297 }
298 }
299 }
300 }
301 let pair_width = if label_col_width > 0.0 || max_field_w > 0.0 {
302 label_col_width + self.label_gap + max_field_w
303 } else {
304 0.0
305 };
306 let total_w = pair_width.max(max_full_w);
307 let fcw = (total_w - label_col_width - self.label_gap).max(0.0);
308 (total_w, fcw)
309 };
310
311 let label_proposal = SizeProposal::with_width(label_col_width);
313 let field_proposal = SizeProposal::with_width(field_col_width);
314 let full_proposal = SizeProposal::with_width(available_width);
315
316 let mut total_height = 0.0_f32;
317 let mut active_count = 0_usize;
318
319 for row in &self.rows {
320 let row_h = match row {
321 FormRow::Pair(label_id, field_id) => {
322 let lh = ctx.child_size(*label_id, label_proposal).map(|s| s.height);
323 let fh = ctx.child_size(*field_id, field_proposal).map(|s| s.height);
324 match (lh, fh) {
325 (Some(l), Some(f)) => l.max(f),
326 (Some(l), None) => l,
327 (None, Some(f)) => f,
328 (None, None) => continue, }
330 }
331 FormRow::FullWidth(id) => match ctx.child_size(*id, full_proposal) {
332 Some(s) => s.height,
333 None => continue, },
335 };
336 total_height += row_h;
337 active_count += 1;
338 }
339
340 if active_count > 1 {
341 total_height += self.row_spacing * (active_count as f32 - 1.0);
342 }
343
344 Size::new(available_width, total_height).into()
345 }
346
347 fn place_children(
348 &self,
349 bounds: Rect,
350 _proposal: SizeProposal,
351 children: &mut [WidgetPlacement],
352 ctx: &LayoutContext,
353 ) {
354 if children.is_empty() {
355 return;
356 }
357
358 let label_col_width = self.compute_label_width(ctx);
359 let field_col_width = (bounds.width - label_col_width - self.label_gap).max(0.0);
360
361 let rtl = ctx.is_rtl();
362 let (label_x, field_x) = if rtl {
363 (bounds.x + field_col_width + self.label_gap, bounds.x)
364 } else {
365 (bounds.x, bounds.x + label_col_width + self.label_gap)
366 };
367
368 let label_proposal = SizeProposal::with_width(label_col_width);
369 let field_proposal = SizeProposal::with_width(field_col_width);
370 let full_proposal = SizeProposal::with_width(bounds.width);
371
372 let mut child_idx = 0;
373 let mut y = bounds.y;
374 let mut first_active = true;
375
376 for row in &self.rows {
377 match row {
378 FormRow::Pair(label_id, field_id) => {
379 let label_active =
380 child_idx < children.len() && children[child_idx].id == *label_id;
381 let field_check_idx = if label_active {
382 child_idx + 1
383 } else {
384 child_idx
385 };
386 let field_active = field_check_idx < children.len()
387 && children[field_check_idx].id == *field_id;
388
389 if !label_active && !field_active {
390 continue; }
392
393 if !first_active {
394 y += self.row_spacing;
395 }
396 first_active = false;
397
398 let label_h = if label_active {
399 ctx.child_size(*label_id, label_proposal)
400 .map(|s| s.height)
401 .unwrap_or(0.0)
402 } else {
403 0.0
404 };
405 let field_h = if field_active {
406 ctx.child_size(*field_id, field_proposal)
407 .map(|s| s.height)
408 .unwrap_or(0.0)
409 } else {
410 0.0
411 };
412 let row_h = label_h.max(field_h);
413
414 if label_active {
415 children[child_idx].origin = Point::new(label_x, y);
416 children[child_idx].size = Size::new(label_col_width, row_h);
417 child_idx += 1;
418 }
419 if field_active {
420 children[child_idx].origin = Point::new(field_x, y);
421 children[child_idx].size = Size::new(field_col_width, row_h);
422 child_idx += 1;
423 }
424
425 y += row_h;
426 }
427 FormRow::FullWidth(id) => {
428 if child_idx >= children.len() || children[child_idx].id != *id {
429 continue; }
431
432 if !first_active {
433 y += self.row_spacing;
434 }
435 first_active = false;
436
437 let child_h = ctx
438 .child_size(*id, full_proposal)
439 .map(|s| s.height)
440 .unwrap_or(0.0);
441
442 children[child_idx].origin = Point::new(bounds.x, y);
443 children[child_idx].size = Size::new(bounds.width, child_h);
444 child_idx += 1;
445
446 y += child_h;
447 }
448 }
449 }
450 }
451
452 fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {}
453
454 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
455 match self.a11y_label.as_ref() {
456 Some(ls) => {
457 builder.set_role(teksilo_core::accesskit::Role::Form);
458 builder.set_name(ls.resolve_now());
459 }
460 None => {
461 builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
462 }
463 }
464 }
465
466 fn children(&self) -> Vec<WidgetId> {
467 self.all_child_ids()
468 }
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474 use teksilo_core::widget_tree::WidgetTree;
475
476 #[derive(Debug)]
477 struct FixedLeaf(f32, f32);
478 impl Widget for FixedLeaf {
479 fn layout_response(
480 &self,
481 _proposal: SizeProposal,
482 _ctx: &LayoutContext,
483 ) -> teksilo_core::widget::LayoutResponse {
484 Size::new(self.0, self.1).into()
485 }
486 }
487
488 #[test]
489 fn basic_form_places_label_and_field() {
490 let mut tree = WidgetTree::new();
491 let label = tree.add(FixedLeaf(60.0, 20.0));
492 let field = tree.add(FixedLeaf(100.0, 25.0));
493 let _form = tree.add(FormLayout::new().line(label, field));
494 tree.layout(SizeProposal::exact(300.0, 200.0));
495
496 assert!((tree.bounds(label).x - 0.0).abs() < 0.01);
497 assert!((tree.bounds(label).y - 0.0).abs() < 0.01);
498 assert!((tree.bounds(field).x - 60.0).abs() < 0.01);
499 assert!((tree.bounds(field).y - 0.0).abs() < 0.01);
500 assert!((tree.bounds(label).height - 25.0).abs() < 0.01);
502 assert!((tree.bounds(field).height - 25.0).abs() < 0.01);
503 }
504
505 #[test]
506 fn labeled_form_emits_form_role_and_name() {
507 let mut tree = WidgetTree::new();
511 let form = tree.add(FormLayout::new().label(teksilo_i18n::lit!("Account")));
512 tree.layout(SizeProposal::exact(300.0, 200.0));
513 let info = tree.accessibility_node(form);
514 assert_eq!(info.role(), teksilo_core::accesskit::Role::Form);
515 assert_eq!(info.name(), Some("Account"));
516 }
517
518 #[test]
519 fn line_wires_field_labelled_by_label() {
520 let mut tree = WidgetTree::new();
524 let label = tree.add(FixedLeaf(60.0, 20.0));
525 let field = tree.add(FixedLeaf(100.0, 25.0));
526 let _form = tree.add(FormLayout::new().line(label, field));
527 tree.layout(SizeProposal::exact(300.0, 200.0));
528
529 let update = tree.sync_accessibility();
530 let field_nid = teksilo_core::accessibility::widget_id_to_node_id(field);
531 let label_nid = teksilo_core::accessibility::widget_id_to_node_id(label);
532 let field_node = update
533 .nodes
534 .iter()
535 .find(|(id, _)| *id == field_nid)
536 .map(|(_, n)| n)
537 .expect("field node present in AT tree");
538 assert!(
539 field_node.labelled_by().contains(&label_nid),
540 "field must be labelled_by its label node"
541 );
542 }
543
544 #[test]
545 fn unlabeled_form_is_presentational() {
546 let mut tree = WidgetTree::new();
547 let form = tree.add(FormLayout::new());
548 tree.layout(SizeProposal::exact(300.0, 200.0));
549 let info = tree.accessibility_node(form);
550 assert_eq!(info.role(), teksilo_core::accesskit::Role::GenericContainer);
551 }
552
553 #[test]
554 fn label_column_auto_sizes_to_widest() {
555 let mut tree = WidgetTree::new();
556 let l1 = tree.add(FixedLeaf(60.0, 20.0));
557 let f1 = tree.add(FixedLeaf(100.0, 20.0));
558 let l2 = tree.add(FixedLeaf(100.0, 20.0)); let f2 = tree.add(FixedLeaf(80.0, 20.0));
560 let _form = tree.add(FormLayout::new().line(l1, f1).line(l2, f2));
561 tree.layout(SizeProposal::exact(400.0, 200.0));
562
563 assert!((tree.bounds(f1).x - 100.0).abs() < 0.01);
565 assert!((tree.bounds(f2).x - 100.0).abs() < 0.01);
566 assert!((tree.bounds(l1).width - 100.0).abs() < 0.01);
568 assert!((tree.bounds(l2).width - 100.0).abs() < 0.01);
569 }
570
571 #[test]
572 fn full_width_row_spans_entire_width() {
573 let mut tree = WidgetTree::new();
574 let fw = tree.add(FixedLeaf(50.0, 30.0));
575 let _form = tree.add(FormLayout::new().full_width(fw));
576 tree.layout(SizeProposal::exact(400.0, 200.0));
577
578 assert!((tree.bounds(fw).x - 0.0).abs() < 0.01);
579 assert!((tree.bounds(fw).width - 400.0).abs() < 0.01);
580 assert!((tree.bounds(fw).height - 30.0).abs() < 0.01);
581 }
582
583 #[test]
584 fn mixed_pair_and_full_width_rows() {
585 let mut tree = WidgetTree::new();
586 let l1 = tree.add(FixedLeaf(80.0, 20.0));
587 let f1 = tree.add(FixedLeaf(100.0, 25.0));
588 let fw = tree.add(FixedLeaf(200.0, 30.0));
589 let l2 = tree.add(FixedLeaf(60.0, 20.0));
590 let f2 = tree.add(FixedLeaf(100.0, 20.0));
591 let _form = tree.add(FormLayout::new().line(l1, f1).full_width(fw).line(l2, f2));
592 tree.layout(SizeProposal::exact(400.0, 200.0));
593
594 assert!((tree.bounds(l1).y - 0.0).abs() < 0.01);
596 assert!((tree.bounds(fw).y - 25.0).abs() < 0.01);
598 assert!((tree.bounds(l2).y - 55.0).abs() < 0.01);
600 }
601
602 #[test]
603 fn label_gap_applied() {
604 let mut tree = WidgetTree::new();
605 let label = tree.add(FixedLeaf(80.0, 20.0));
606 let field = tree.add(FixedLeaf(100.0, 20.0));
607 let _form = tree.add(FormLayout::new().label_gap(12.0).line(label, field));
608 tree.layout(SizeProposal::exact(400.0, 200.0));
609
610 assert!((tree.bounds(field).x - 92.0).abs() < 0.01);
612 }
613
614 #[test]
615 fn row_spacing_applied() {
616 let mut tree = WidgetTree::new();
617 let l1 = tree.add(FixedLeaf(60.0, 20.0));
618 let f1 = tree.add(FixedLeaf(100.0, 25.0));
619 let l2 = tree.add(FixedLeaf(60.0, 20.0));
620 let f2 = tree.add(FixedLeaf(100.0, 20.0));
621 let _form = tree.add(
622 FormLayout::new()
623 .row_spacing(10.0)
624 .line(l1, f1)
625 .line(l2, f2),
626 );
627 tree.layout(SizeProposal::exact(400.0, 200.0));
628
629 assert!((tree.bounds(l1).y - 0.0).abs() < 0.01);
631 assert!((tree.bounds(l2).y - 35.0).abs() < 0.01);
632 }
633
634 #[test]
635 fn intrinsic_height_sums_rows() {
636 let mut tree = WidgetTree::new();
637 let l1 = tree.add(FixedLeaf(60.0, 20.0));
638 let f1 = tree.add(FixedLeaf(100.0, 25.0));
639 let l2 = tree.add(FixedLeaf(60.0, 30.0));
640 let f2 = tree.add(FixedLeaf(100.0, 20.0));
641 let form = tree.add(FormLayout::new().row_spacing(5.0).line(l1, f1).line(l2, f2));
642 tree.layout(SizeProposal {
643 width: Some(400.0),
644 height: None,
645 });
646
647 assert!((tree.bounds(form).height - 60.0).abs() < 0.01);
649 }
650
651 #[test]
652 fn field_column_gets_remaining_width() {
653 let mut tree = WidgetTree::new();
654 let label = tree.add(FixedLeaf(80.0, 20.0));
655 let field = tree.add(FixedLeaf(100.0, 20.0));
656 let _form = tree.add(FormLayout::new().label_gap(10.0).line(label, field));
657 tree.layout(SizeProposal::exact(400.0, 200.0));
658
659 assert!((tree.bounds(field).width - 310.0).abs() < 0.01);
661 }
662
663 #[test]
664 fn single_pair_row() {
665 let mut tree = WidgetTree::new();
666 let label = tree.add(FixedLeaf(70.0, 20.0));
667 let field = tree.add(FixedLeaf(100.0, 20.0));
668 let _form = tree.add(FormLayout::new().line(label, field));
669 tree.layout(SizeProposal::exact(300.0, 200.0));
670
671 assert!((tree.bounds(label).x - 0.0).abs() < 0.01);
672 assert!((tree.bounds(field).x - 70.0).abs() < 0.01);
673 }
674
675 #[test]
676 fn empty_form() {
677 let mut tree = WidgetTree::new();
678 let form = tree.add(FormLayout::new());
679 tree.layout(SizeProposal {
680 width: Some(300.0),
681 height: None,
682 });
683
684 assert!((tree.bounds(form).height - 0.0).abs() < 0.01);
685 }
686
687 #[test]
688 fn dormant_row_excluded_from_layout() {
689 let mut tree = WidgetTree::new();
690 let l1 = tree.add(FixedLeaf(60.0, 20.0));
691 let f1 = tree.add(FixedLeaf(100.0, 25.0));
692 let l2 = tree.add(FixedLeaf(60.0, 20.0));
693 let f2 = tree.add(FixedLeaf(100.0, 30.0));
694 let l3 = tree.add(FixedLeaf(60.0, 20.0));
695 let f3 = tree.add(FixedLeaf(100.0, 20.0));
696 let form = tree.add(
697 FormLayout::new()
698 .row_spacing(10.0)
699 .line(l1, f1)
700 .line(l2, f2)
701 .line(l3, f3),
702 );
703 tree.layout(SizeProposal::exact(400.0, 300.0));
704
705 assert!((tree.bounds(l3).y - 75.0).abs() < 0.01);
707
708 tree.set_dormant(l2);
710 tree.set_dormant(f2);
711 tree.layout(SizeProposal {
712 width: Some(400.0),
713 height: None,
714 });
715
716 assert!((tree.bounds(l3).y - 35.0).abs() < 0.01);
718 assert!((tree.bounds(f3).y - 35.0).abs() < 0.01);
719 assert!((tree.bounds(form).height - 55.0).abs() < 0.01);
721 }
722
723 #[test]
724 fn unbounded_width_uses_intrinsic() {
725 let mut tree = WidgetTree::new();
726 let l1 = tree.add(FixedLeaf(80.0, 20.0));
727 let f1 = tree.add(FixedLeaf(200.0, 20.0));
728 let form = tree.add(FormLayout::new().label_gap(10.0).line(l1, f1));
729 tree.layout(SizeProposal {
730 width: None,
731 height: Some(200.0),
732 });
733
734 assert!((tree.bounds(form).width - 290.0).abs() < 0.01);
736 }
737
738 #[test]
739 fn deferred_line_api_works() {
740 let mut tree = WidgetTree::new();
741 let form = tree.add(
742 FormLayout::new()
743 .label_gap(10.0)
744 .line(FixedLeaf(70.0, 20.0), FixedLeaf(150.0, 25.0)),
745 );
746 tree.layout(SizeProposal {
747 width: Some(400.0),
748 height: None,
749 });
750
751 assert!((tree.bounds(form).height - 25.0).abs() < 0.01);
753 }
754
755 #[test]
756 fn a_rebuilt_form_keeps_its_rows() {
757 let mut tree = WidgetTree::new();
763 let label = tree.add(FixedLeaf(80.0, 20.0));
764 let field = tree.add(FixedLeaf(100.0, 20.0));
765 let form = tree.add(FormLayout::new().label_gap(10.0).line(label, field));
766 tree.layout(SizeProposal::exact(400.0, 200.0));
767 let before = tree.bounds(field);
768 assert!(before.width > 0.0, "the row is laid out to begin with");
769
770 tree.arena_mark_needs_rebuild_for_testing(form);
771 tree.layout(SizeProposal::exact(400.0, 200.0));
772
773 assert_eq!(
774 tree.bounds(field),
775 before,
776 "the field must survive its form's rebuild, in the same place"
777 );
778 assert!(tree.is_active(label) && tree.is_active(field));
779 }
780
781 #[test]
782 fn a_rebuilt_line_names_its_field_once() {
783 let mut tree = WidgetTree::new();
788 let label = tree.add(FixedLeaf(80.0, 20.0));
789 let field = tree.add(FixedLeaf(100.0, 20.0));
790 let form = tree.add(FormLayout::new().line(label, field));
791 tree.layout(SizeProposal::exact(400.0, 200.0));
792
793 let relations = |tree: &mut WidgetTree| {
794 let update = tree.sync_accessibility();
795 let field_nid = teksilo_core::accessibility::widget_id_to_node_id(field);
796 update
797 .nodes
798 .iter()
799 .find(|(id, _)| *id == field_nid)
800 .map(|(_, n)| n.labelled_by().len())
801 .expect("field node present in AT tree")
802 };
803 assert_eq!(relations(&mut tree), 1, "one visible label, one relation");
804
805 tree.arena_mark_needs_rebuild_for_testing(form);
806 tree.layout(SizeProposal::exact(400.0, 200.0));
807 assert_eq!(
808 relations(&mut tree),
809 1,
810 "however many rebuilds, still one relation"
811 );
812 }
813
814 #[test]
815 fn rtl_swaps_label_and_field_columns() {
816 let mut tree = WidgetTree::new();
817 tree.set_layout_direction(teksilo_core::environment::LayoutDirection::RightToLeft);
818 let label = tree.add(FixedLeaf(80.0, 20.0));
819 let field = tree.add(FixedLeaf(100.0, 20.0));
820 let _form = tree.add(FormLayout::new().label_gap(10.0).line(label, field));
821 tree.layout(SizeProposal::exact(400.0, 200.0));
822
823 assert!((tree.bounds(field).x - 0.0).abs() < 0.01);
826 assert!((tree.bounds(label).x - 320.0).abs() < 0.01);
827 }
828}