Skip to main content

teksilo_widgets/primitives/
form_layout.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! FormLayout — a two-column settings or preferences form layout.
5//!
6//! Children are added as label/field pairs via [`FormLayout::line`] (inline
7//! widgets) or [`FormLayout::line_ids`] (pre-registered IDs). Full-width rows
8//! that span both columns — section headers, `Divider`s, or banners — are
9//! added via [`FormLayout::full_width`] / [`FormLayout::full_width_id`]. The
10//! label column auto-sizes to the widest label across all pairs so all field
11//! inputs are left-aligned. RTL layouts are handled automatically: the label
12//! column migrates to the trailing side and the field column moves to the
13//! leading side. Dormant rows are excluded from both measurement and
14//! placement.
15//!
16//! When an accessible name is provided via [`FormLayout::label`], the widget
17//! emits `Role::Form` so screen-reader users can navigate directly to the
18//! form. Without a name it demotes to a presentational `GenericContainer`.
19//!
20//! ```rust
21//! # use teksilo_widgets::primitives::{FormLayout, TextWidget, RectWidget};
22//! # use teksilo_i18n::lit;
23//! let _form = FormLayout::new()
24//!     .label_gap(8.0)
25//!     .row_spacing(6.0)
26//!     .line(TextWidget::new(lit!("Name:")),  RectWidget::new())
27//!     .line(TextWidget::new(lit!("Email:")), RectWidget::new());
28//! ```
29
30use 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/// A resolved form row after `build()`.
37#[derive(Debug, Clone)]
38enum FormRow {
39    /// A label/field pair occupying two columns.
40    Pair(WidgetId, WidgetId),
41    /// A single widget spanning the full width.
42    FullWidth(WidgetId),
43}
44
45/// A pending form row before `build()`.
46enum 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/// A two-column form layout with auto-sized label column.
61///
62/// Children are added as label/field pairs via [`line()`](Self::line) or as
63/// full-width rows via [`full_width()`](Self::full_width). The label column
64/// auto-sizes to the widest label; the field column takes the remaining
65/// space.
66///
67/// ```text
68/// ┌─ label col ─┐ gap ┌── field col ──────────────┐
69/// │ Name:       │     │ [___________________]      │
70/// │ Email:      │     │ [___________________]      │
71/// ├─────────────┴─────┴────────────────────────────┤
72/// │ ── Advanced ──────────────────────────────────  │  ← full_width
73/// ├─ label col ─┐ gap ┌── field col ──────────────┐
74/// │ Port:       │     │ [____]                     │
75/// └─────────────┘     └────────────────────────────┘
76/// ```
77#[derive(Debug)]
78pub struct FormLayout {
79    rows: Vec<FormRow>,
80    pending_rows: Vec<PendingFormRow>,
81    label_gap: f32,
82    row_spacing: f32,
83    /// Stored unresolved so the AT name re-localizes on a locale change
84    /// (the tree re-walks `accessibility()` and re-resolves) instead of
85    /// freezing at build time.
86    a11y_label: Option<LocalizedString>,
87}
88
89impl FormLayout {
90    /// Create an empty `FormLayout` with zero label gap and zero row spacing.
91    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    /// Horizontal gap between the label column and the field column.
102    pub fn label_gap(mut self, gap: f32) -> Self {
103        self.label_gap = gap;
104        self
105    }
106
107    /// Vertical gap between rows.
108    pub fn row_spacing(mut self, spacing: f32) -> Self {
109        self.row_spacing = spacing;
110        self
111    }
112
113    /// Set an accessible name for this form. When set, the widget emits
114    /// the `Role::Form` landmark so assistive-technology users can
115    /// navigate directly to it and distinguish it from other forms on
116    /// the page. When unset, the widget demotes to a presentational
117    /// `GenericContainer` — an unnamed landmark is worse than no
118    /// landmark for AT users.
119    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
120        self.a11y_label = Some(label.into());
121        self
122    }
123
124    /// Add a label/field pair row.
125    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    /// Add a label/field pair row with pre-registered widget IDs.
134    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    /// Add a full-width row spanning both columns.
143    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    /// Add a full-width row with a pre-registered widget ID.
152    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    /// Flatten all rows into a child ID list.
159    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    /// Width of the label column (max intrinsic width of all pair labels).
174    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                        // WCAG 3.3.2 / EN 301 549 11.5.2.7: name the field after
214                        // its visible label so assistive tech reads "<label>,
215                        // edit text" instead of an unlabelled field. Serves both
216                        // `line()` (deferred) and `line_ids()` (pre-registered).
217                        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        // Determine available width and field column width.
241        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            // Unbounded: compute intrinsic width.
246            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        // Compute total height from row heights.
273        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, // both dormant
290                    }
291                }
292                FormRow::FullWidth(id) => match ctx.child_size(*id, full_proposal) {
293                    Some(s) => s.height,
294                    None => continue, // dormant
295                },
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; // entire row dormant
352                    }
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; // dormant
391                    }
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        // Row height = max(20, 25) = 25
462        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        // The AT name is resolved lazily in `accessibility()` (not frozen at
469        // build time), so a localized label still resolves correctly when the
470        // tree is walked.
471        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        // WCAG 3.3.2 (audit G8): a form field is named after its visible label
482        // via a `labelled_by` relation, so assistive tech reads the label as
483        // the field's accessible name.
484        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)); // wider label
520        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        // Label column = 100 (widest label). Both fields start at x=100.
525        assert!((tree.bounds(f1).x - 100.0).abs() < 0.01);
526        assert!((tree.bounds(f2).x - 100.0).abs() < 0.01);
527        // Label column width = 100 for both labels
528        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        // Row 0 (Pair): height 25, y=0
561        assert!((tree.bounds(l1).y - 0.0).abs() < 0.01);
562        // Row 1 (FullWidth): height 30, y=25
563        assert!((tree.bounds(fw).y - 25.0).abs() < 0.01);
564        // Row 2 (Pair): height 20, y=55
565        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        // Field starts at label_width + gap = 80 + 12 = 92
577        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        // Row 0 at y=0, height=25. Row 1 at y=25+10=35.
596        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        // Row 0: max(20,25)=25. Row 1: max(30,20)=30. Total: 25+5+30=60
619        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        // Field width = 400 - 80 - 10 = 310
631        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        // Before dormant: row 0 at y=0 (h=25), row 1 at y=35 (h=30), row 2 at y=75
677        assert!((tree.bounds(l3).y - 75.0).abs() < 0.01);
678
679        // Make row 1 dormant
680        tree.set_dormant(l2);
681        tree.set_dormant(f2);
682        tree.layout(SizeProposal {
683            width: Some(400.0),
684            height: None,
685        });
686
687        // Row 2 should move up: y = 25 + 10 = 35
688        assert!((tree.bounds(l3).y - 35.0).abs() < 0.01);
689        assert!((tree.bounds(f3).y - 35.0).abs() < 0.01);
690        // Form height = 25 + 10 + 20 = 55
691        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        // Intrinsic width = 80 + 10 + 200 = 290
706        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        // Form should have 2 children, height = max(20, 25) = 25
723        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        // field_col_width = 400 - 80 - 10 = 310
736        // RTL: field at x=0, label at x=310+10=320
737        assert!((tree.bounds(field).x - 0.0).abs() < 0.01);
738        assert!((tree.bounds(label).x - 320.0).abs() < 0.01);
739    }
740}