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`] (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`]. 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(
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    /// Add several label/field pair rows from an iterator of `(label, field)`
138    /// pairs, in order.
139    ///
140    /// The loop form of [`line`](Self::line), and the usual one once the form is
141    /// generated from a settings schema rather than written row by row.
142    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    /// Add several label/field pair rows from an iterator of
152    /// `(label_id, field_id)` pairs, in order.
153    ///
154    /// [`lines`](Self::lines) accepts ids in both columns too, so this is the
155    /// spelling that states the id types outright rather than a capability the
156    /// other method lacks. Reach for it when a loop has already registered both
157    /// columns and naming the type reads better than inferring it.
158    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    /// Add a full-width row spanning both columns.
164    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    /// Add several full-width rows from an iterator, in order.
172    ///
173    /// The loop form of [`full_width`](Self::full_width), for a run of banners
174    /// or section headers that comes from data.
175    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    /// Flatten all rows into a child ID list.
183    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    /// Width of the label column (max intrinsic width of all pair labels).
198    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                        // WCAG 3.3.2 / EN 301 549 11.5.2.7: name the field after
238                        // its visible label so assistive tech reads "<label>,
239                        // `line()`'s two forms: a deferred widget and a pre-registered id.
240                        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    /// Reconcile: the rows are re-attached by id, not re-derived.
253    ///
254    /// `line(..)` with an id names widgets the caller registered itself — so there Under the default
255    /// tear-down-and-reconstruct semantics `build()` found `pending_rows`
256    /// already drained, re-attached the previous generation's ids, and got a
257    /// form of destroyed children: every row vanished and the widget measured
258    /// zero by zero.
259    ///
260    /// Reconciling is also what a form wants for its own sake. A rebuild
261    /// triggered by something else on the screen — a locale switch, a
262    /// `Rebuild`-level signal on an ancestor — must not empty the fields the
263    /// user has been typing into, move focus, or reset a scrolled field.
264    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        // Determine available width and field column width.
280        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            // Unbounded: compute intrinsic width.
285            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        // Compute total height from row heights.
312        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, // both dormant
329                    }
330                }
331                FormRow::FullWidth(id) => match ctx.child_size(*id, full_proposal) {
332                    Some(s) => s.height,
333                    None => continue, // dormant
334                },
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; // entire row dormant
391                    }
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; // dormant
430                    }
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        // Row height = max(20, 25) = 25
501        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        // The AT name is resolved lazily in `accessibility()` (not frozen at
508        // build time), so a localized label still resolves correctly when the
509        // tree is walked.
510        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        // WCAG 3.3.2 (audit G8): a form field is named after its visible label
521        // via a `labelled_by` relation, so assistive tech reads the label as
522        // the field's accessible name.
523        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)); // wider label
559        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        // Label column = 100 (widest label). Both fields start at x=100.
564        assert!((tree.bounds(f1).x - 100.0).abs() < 0.01);
565        assert!((tree.bounds(f2).x - 100.0).abs() < 0.01);
566        // Label column width = 100 for both labels
567        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        // Row 0 (Pair): height 25, y=0
595        assert!((tree.bounds(l1).y - 0.0).abs() < 0.01);
596        // Row 1 (FullWidth): height 30, y=25
597        assert!((tree.bounds(fw).y - 25.0).abs() < 0.01);
598        // Row 2 (Pair): height 20, y=55
599        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        // Field starts at label_width + gap = 80 + 12 = 92
611        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        // Row 0 at y=0, height=25. Row 1 at y=25+10=35.
630        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        // Row 0: max(20,25)=25. Row 1: max(30,20)=30. Total: 25+5+30=60
648        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        // Field width = 400 - 80 - 10 = 310
660        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        // Before dormant: row 0 at y=0 (h=25), row 1 at y=35 (h=30), row 2 at y=75
706        assert!((tree.bounds(l3).y - 75.0).abs() < 0.01);
707
708        // Make row 1 dormant
709        tree.set_dormant(l2);
710        tree.set_dormant(f2);
711        tree.layout(SizeProposal {
712            width: Some(400.0),
713            height: None,
714        });
715
716        // Row 2 should move up: y = 25 + 10 = 35
717        assert!((tree.bounds(l3).y - 35.0).abs() < 0.01);
718        assert!((tree.bounds(f3).y - 35.0).abs() < 0.01);
719        // Form height = 25 + 10 + 20 = 55
720        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        // Intrinsic width = 80 + 10 + 200 = 290
735        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        // Form should have 2 children, height = max(20, 25) = 25
752        assert!((tree.bounds(form).height - 25.0).abs() < 0.01);
753    }
754
755    #[test]
756    fn a_rebuilt_form_keeps_its_rows() {
757        // A form's children are handed in once and cannot be reconstructed, so
758        // a rebuild has to re-attach them rather than re-derive them. It used
759        // to re-derive: `build()` found `pending_rows` already drained,
760        // re-attached ids the framework had just destroyed, and the form came
761        // back empty at zero by zero.
762        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        // `build()` registers the label relation, and it runs again on every
784        // rebuild. Appending blindly would add a second target, and the
785        // consumer builds a node's name by concatenating every target's value
786        // — so a screen reader would read "Name Name, edit text".
787        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        // field_col_width = 400 - 80 - 10 = 310
824        // RTL: field at x=0, label at x=310+10=320
825        assert!((tree.bounds(field).x - 0.0).abs() < 0.01);
826        assert!((tree.bounds(label).x - 320.0).abs() < 0.01);
827    }
828}