Skip to main content

teksilo_widgets/primitives/
column_flow.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `ColumnFlow` — flows children into as many columns as the width affords,
5//! re-partitioning every child when a column is gained or lost.
6//!
7//! The newspaper / CSS multi-column model: content runs down column 0, then
8//! down column 1, and so on. The column count is derived from the available
9//! width and [`min_column_width`](ColumnFlow::min_column_width) — when the
10//! width no longer affords *N* columns the layout drops to *N−1* and **all**
11//! children are re-partitioned across the survivors. Children are atomic: one
12//! child never straddles a column boundary.
13//!
14//! Pair it with a [`ScrollArea`](crate::scroll_area::ScrollArea) for vertical
15//! overflow — `ColumnFlow` reports its true content height (the tallest
16//! column), so the scroll extent is correct.
17//!
18//! ```rust
19//! # use teksilo_widgets::primitives::column_flow::ColumnFlow;
20//! # use teksilo_widgets::primitives::TextWidget;
21//! # use teksilo_widgets::scroll_area::ScrollArea;
22//! # use teksilo_i18n::lit;
23//! let _view = ScrollArea::new().child(
24//!     ColumnFlow::new()
25//!         .min_column_width(240.0)
26//!         .max_columns(4)
27//!         .column_spacing(16.0)
28//!         .item_spacing(12.0)
29//!         .child(TextWidget::new(lit!("First")))
30//!         .child(TextWidget::new(lit!("Second")))
31//!         .child(TextWidget::new(lit!("Third"))),
32//! );
33//! ```
34//!
35//! # Reading order
36//!
37//! Children are distributed as **contiguous runs in source order** — column 0
38//! takes children `0..i`, column 1 takes `i..j`. So source order, visual
39//! reading order, and focus order are the same thing, at every column count.
40//! This is why `ColumnFlow` does not reuse
41//! [`MasonryLayout`](crate::primitives::MasonryLayout)'s shortest-column
42//! packing, which interleaves children and would divorce the visual order from
43//! the source order.
44//!
45//! # Accessibility
46//!
47//! By default `ColumnFlow` emits a bare `Role::GenericContainer` carrying no
48//! properties, which the accessibility walker *prunes*, promoting the children
49//! to its parent in source order. That is the correct outcome for a layout
50//! primitive: it contributes geometry, not semantics, and the reading order is
51//! already right. Add semantics from the outside with `.access_role(..)` /
52//! `.access_label(..)`, or opt into list semantics with
53//! [`semantic_list`](ColumnFlow::semantic_list).
54//!
55//! # Relationship to CSS multi-column
56//!
57//! Close, but not identical. CSS `column-fill: balance` balances content within
58//! a column height it computes from a *bounded* block size; `ColumnFlow` derives
59//! the column *count* from the width and lets the height run free (a
60//! `ScrollArea` absorbs it). No CSS `column-fill` mode does that, so don't read
61//! this as a CSS multicol port.
62
63use std::cell::Cell;
64
65use teksilo_canvas::{Canvas, EdgeInsets, Point, Rect, Size, SizeProposal, StrokeStyle};
66use teksilo_core::accessibility::AccessNodeBuilder;
67use teksilo_core::binding::BindingLevel;
68use teksilo_core::color_prop::ColorProp;
69use teksilo_core::signal::{Prop, Signal};
70use teksilo_core::widget::{
71    LayoutContext, LayoutResponse, PaintContext, PendingChild, Widget, WidgetPlacement,
72};
73use teksilo_core::widget_id::WidgetId;
74use teksilo_tokens::HAlignment;
75
76use crate::common::column_geometry::{ColumnGeometry, WidthPolicy};
77
78/// Default minimum column width, in logical pixels — a card-ish column that
79/// reads well at typical desktop sizes.
80const DEFAULT_MIN_COLUMN_WIDTH: f32 = 240.0;
81
82/// Bisection steps used by [`balance_columns`].
83///
84/// A **fixed** count, deliberately, rather than an epsilon-driven `while`
85/// loop: `layout_response` and `place_children` each run the search
86/// independently (there is no persisted partition state — the
87/// `MasonryLayout` pattern), and only an identical, input-independent
88/// iteration count makes both calls return bit-identical results. An epsilon
89/// loop would iterate a different number of times for different inputs and
90/// could settle either side of a boundary, letting the reported height
91/// disagree with the placed one.
92///
93/// 48 halvings drive the interval below any representable `f32` gap over the
94/// ranges layout deals in.
95const BISECTION_STEPS: u32 = 48;
96
97/// The result of partitioning children into columns.
98#[derive(Debug, Clone, PartialEq)]
99pub(crate) struct BalanceResult {
100    /// The tallest column's extent — the container's content height.
101    pub height: f32,
102    /// `column_of[i]` is the column index child `i` was placed in.
103    pub column_of: Vec<usize>,
104}
105
106/// The extent of a column holding `count` items totalling `sum`, including the
107/// `count - 1` inter-item gaps.
108#[inline]
109fn run_extent(sum: f32, count: usize, gap: f32) -> f32 {
110    if count == 0 {
111        0.0
112    } else {
113        sum + (count as f32 - 1.0) * gap
114    }
115}
116
117/// How many columns a greedy left-to-right fill needs if no column may exceed
118/// `limit`. The feasibility oracle for [`balance_columns`]'s bisection.
119///
120/// Counts items rather than testing `accumulated > 0.0` to decide whether a
121/// gap applies: a run of zero-height children is still a run of *n* items with
122/// *n−1* gaps between them, and an accumulator test would silently drop those
123/// gaps.
124fn columns_needed(heights: &[f32], gap: f32, limit: f32) -> usize {
125    let mut columns = 1usize;
126    let mut count = 0usize;
127    let mut sum = 0.0_f32;
128    for &h in heights {
129        let (next_count, next_sum) = (count + 1, sum + h);
130        if count > 0 && run_extent(next_sum, next_count, gap) > limit {
131            columns += 1;
132            count = 1;
133            sum = h;
134        } else {
135            count = next_count;
136            sum = next_sum;
137        }
138    }
139    columns
140}
141
142/// Partition `heights` into at most `k` columns as contiguous, source-order
143/// runs, minimising the tallest column.
144///
145/// Bisects the column extent: `columns_needed` is monotone in the limit (a
146/// taller limit never needs more columns), so the smallest feasible extent can
147/// be found by halving. The lower bound is the tallest single item — no column
148/// can be feasible below it, since children are atomic — and the upper bound is
149/// every item in one column, gaps included.
150///
151/// Construction then re-runs the greedy fill at that extent with one extra
152/// rule: column `j` may not take so many items that fewer than one remains for
153/// each column after it. That is what makes `[10, 10, 10, 10]` into 3 columns
154/// come out as `[20, 10, 10]` rather than `[20, 20, ∅]` — both have the same
155/// (optimal) tallest column, but the second wastes a column. Reserving items
156/// can never force a column past the limit; it only ever makes a column take
157/// *fewer* items.
158pub(crate) fn balance_columns(heights: &[f32], gap: f32, k: usize) -> BalanceResult {
159    let n = heights.len();
160    if n == 0 {
161        return BalanceResult {
162            height: 0.0,
163            column_of: Vec::new(),
164        };
165    }
166    let gap = gap.max(0.0);
167    // More columns than items would leave trailing columns unavoidably empty.
168    let k_eff = k.min(n).max(1);
169
170    // Bisect for the smallest feasible column extent.
171    let mut lo = heights.iter().copied().fold(0.0_f32, f32::max).max(0.0);
172    let mut hi = heights.iter().copied().sum::<f32>() + (n as f32 - 1.0).max(0.0) * gap;
173    if hi < lo {
174        hi = lo;
175    }
176    for _ in 0..BISECTION_STEPS {
177        let mid = lo + (hi - lo) * 0.5;
178        if columns_needed(heights, gap, mid) <= k_eff {
179            hi = mid;
180        } else {
181            lo = mid;
182        }
183    }
184    // `hi` is always feasible; `lo` may not be. Never report `lo`.
185    let limit = hi;
186
187    // Construct at `limit`, reserving at least one item per remaining column.
188    let mut column_of = vec![0usize; n];
189    let mut placed = 0usize;
190    let mut idx = 0usize;
191    for col in 0..k_eff {
192        let remaining = n - placed;
193        let reserve = k_eff - col - 1;
194        let cap = if col + 1 == k_eff {
195            remaining
196        } else {
197            remaining.saturating_sub(reserve)
198        }
199        .max(1);
200
201        let mut count = 0usize;
202        let mut sum = 0.0_f32;
203        while count < cap && idx < n {
204            let (next_count, next_sum) = (count + 1, sum + heights[idx]);
205            if count > 0 && run_extent(next_sum, next_count, gap) > limit {
206                break;
207            }
208            column_of[idx] = col;
209            count = next_count;
210            sum = next_sum;
211            idx += 1;
212        }
213        placed += count;
214    }
215    // Defensive: if the reserve rule ever stranded a tail (it should not), put
216    // it in the last column rather than dropping it on the floor.
217    for slot in column_of.iter_mut().skip(idx) {
218        *slot = k_eff - 1;
219    }
220
221    let height = (0..k_eff)
222        .map(|c| {
223            let mut count = 0usize;
224            let mut sum = 0.0_f32;
225            for (i, &h) in heights.iter().enumerate() {
226                if column_of[i] == c {
227                    count += 1;
228                    sum += h;
229                }
230            }
231            run_extent(sum, count, gap)
232        })
233        .fold(0.0_f32, f32::max);
234
235    BalanceResult { height, column_of }
236}
237
238/// A layout that flows its children into as many columns as the available
239/// width affords, re-partitioning every child when a column is gained or lost.
240///
241/// ```text
242///  wide                            narrower
243/// ┌────┐ ┌────┐ ┌────┐            ┌────┐ ┌────┐
244/// │ 1  │ │ 3  │ │ 5  │            │ 1  │ │ 4  │
245/// ├────┤ ├────┤ ├────┤            ├────┤ ├────┤
246/// │ 2  │ │ 4  │ │ 6  │    ───►    │ 2  │ │ 5  │
247/// └────┘ └────┘ └────┘            ├────┤ ├────┤
248///                                 │ 3  │ │ 6  │
249///                                 └────┘ └────┘
250/// ```
251///
252/// Reading order is 1..6 at both widths. See the [module docs](self).
253pub struct ColumnFlow {
254    min_column_width: f32,
255    max_column_width: Option<f32>,
256    max_columns: Option<usize>,
257    column_spacing: Prop<f32>,
258    item_spacing: Prop<f32>,
259    alignment: HAlignment,
260    column_rule: Option<(f32, ColorProp)>,
261    semantic_list: bool,
262    child_ids: Vec<WidgetId>,
263    pending: Vec<PendingChild>,
264    /// Published column count. Written from `place_children` behind
265    /// `last_count`; see [`column_count_signal`](Self::column_count_signal).
266    column_count: Signal<usize>,
267    last_count: Cell<usize>,
268}
269
270impl std::fmt::Debug for ColumnFlow {
271    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
272        f.debug_struct("ColumnFlow")
273            .field("min_column_width", &self.min_column_width)
274            .field("max_column_width", &self.max_column_width)
275            .field("max_columns", &self.max_columns)
276            .field("alignment", &self.alignment)
277            .field("semantic_list", &self.semantic_list)
278            .field("children", &self.child_ids.len())
279            .field("column_count", &self.last_count.get())
280            .finish()
281    }
282}
283
284impl ColumnFlow {
285    /// Create a `ColumnFlow` with a 240 dp minimum column width, no maximum
286    /// column width, and no column-count cap.
287    pub fn new() -> Self {
288        Self {
289            min_column_width: DEFAULT_MIN_COLUMN_WIDTH,
290            max_column_width: None,
291            max_columns: None,
292            column_spacing: Prop::Static(0.0),
293            item_spacing: Prop::Static(0.0),
294            alignment: HAlignment::Leading,
295            column_rule: None,
296            semantic_list: false,
297            child_ids: Vec::new(),
298            pending: Vec::new(),
299            column_count: Signal::new(1),
300            last_count: Cell::new(1),
301        }
302    }
303
304    /// The narrowest a column may be. The column count is the largest *N* whose
305    /// columns are all at least this wide — CSS `column-width` / SwiftUI
306    /// `GridItem(.adaptive(minimum:))` / Compose `GridCells.Adaptive(minSize)`.
307    ///
308    /// A value of zero or less pins the layout to a single column.
309    pub fn min_column_width(mut self, width: f32) -> Self {
310        self.min_column_width = width;
311        self
312    }
313
314    /// The widest a column may be. Unset by default, so columns stretch to
315    /// share the full width evenly.
316    ///
317    /// Set it to stop columns becoming unreadably wide when few of them fit a
318    /// large display — the reason KDE's `Kirigami.CardsLayout` pairs
319    /// `minimumColumnWidth` with `maximumColumnWidth`. When it bites, the
320    /// columns no longer fill the width and
321    /// [`alignment`](Self::alignment) decides where the block sits.
322    pub fn max_column_width(mut self, width: f32) -> Self {
323        self.max_column_width = Some(width);
324        self
325    }
326
327    /// Never use more than `max` columns however wide the layout gets.
328    ///
329    /// Also decides the count when the width is unconstrained (inside a
330    /// size-to-content parent such as a popover): unset, that case reports one
331    /// column, matching CSS `column-count: auto` in a shrink-to-fit context.
332    /// Clamped to at least 1.
333    pub fn max_columns(mut self, max: usize) -> Self {
334        self.max_columns = Some(max.max(1));
335        self
336    }
337
338    /// Horizontal gap between columns. Accepts an `f32` or a `Signal<f32>`.
339    pub fn column_spacing(mut self, spacing: impl Into<Prop<f32>>) -> Self {
340        self.column_spacing = spacing.into();
341        self
342    }
343
344    /// Vertical gap between items within a column. Accepts an `f32` or a
345    /// `Signal<f32>`.
346    ///
347    /// Named for items rather than rows because there are no rows here: a
348    /// column's items are independent of its neighbours'.
349    pub fn item_spacing(mut self, spacing: impl Into<Prop<f32>>) -> Self {
350        self.item_spacing = spacing.into();
351        self
352    }
353
354    /// Where the column block sits when it does not fill the available width.
355    ///
356    /// Only observable once [`max_column_width`](Self::max_column_width) clamps
357    /// the columns narrower than their even share — otherwise the columns
358    /// consume the whole width and there is nothing to align. Defaults to
359    /// [`HAlignment::Leading`]; RTL-aware.
360    pub fn alignment(mut self, alignment: HAlignment) -> Self {
361        self.alignment = alignment;
362        self
363    }
364
365    /// Draw a rule of `width` dp, centred in every inter-column gap — CSS
366    /// `column-rule`.
367    ///
368    /// Purely decorative: it emits no accessibility node. Accepts a `Color`, a
369    /// theme role, or a `Signal`. Pass `BorderRole::Divider` to track the
370    /// theme's divider colour.
371    pub fn column_rule(mut self, width: f32, color: impl Into<ColorProp>) -> Self {
372        self.column_rule = Some((width, color.into()));
373        self
374    }
375
376    /// Expose the children to assistive technology as a list.
377    ///
378    /// The container becomes `Role::List` and every child is wrapped in a
379    /// layout-transparent node reporting `Role::ListItem` with its position and
380    /// the set size, so a screen reader announces "list, 30 items" and
381    /// "item 5 of 30" rather than reading 30 unrelated widgets.
382    ///
383    /// Off by default: a layout primitive should not invent semantics its
384    /// content may not have. Turn it on when the children genuinely *are* a
385    /// list of peers. Costs one extra node per child.
386    pub fn semantic_list(mut self, enabled: bool) -> Self {
387        self.semantic_list = enabled;
388        self
389    }
390
391    /// Add a pre-registered child by ID.
392    pub fn add_child(mut self, id: WidgetId) -> Self {
393        self.pending.push(PendingChild::Id(id));
394        self
395    }
396
397    /// Add an inline child widget (deferred insertion).
398    pub fn child(mut self, widget: impl Widget + 'static) -> Self {
399        self.pending.push(PendingChild::Deferred(Box::new(widget)));
400        self
401    }
402
403    /// Add multiple inline children from an iterator.
404    pub fn children(mut self, iter: impl IntoIterator<Item = impl Widget + 'static>) -> Self {
405        for widget in iter {
406            self.pending.push(PendingChild::Deferred(Box::new(widget)));
407        }
408        self
409    }
410
411    /// Conditionally add a child. No-op if `None`.
412    pub fn child_opt(mut self, widget: Option<impl Widget + 'static>) -> Self {
413        if let Some(w) = widget {
414            self.pending.push(PendingChild::Deferred(Box::new(w)));
415        }
416        self
417    }
418
419    /// The live column count, as a reactive signal.
420    ///
421    /// Lets an app follow the reflow — swapping to a compact header at one
422    /// column, say. Written from the layout pass behind an equality guard, so
423    /// it only fires when the count actually changes.
424    ///
425    /// **Binding contract.** Safe for `RepaintOnly` / `AccessibilityOnly`
426    /// consumers, and for `Relayout` consumers that do not feed back into this
427    /// widget's own width. The count is a pure function of the width
428    /// `ColumnFlow` is *given* — it never changes its own width, so it cannot
429    /// oscillate on its own. But a `Relayout` consumer that resizes something
430    /// which in turn resizes this `ColumnFlow` closes a feedback loop through
431    /// the layout pass, which is exactly what
432    /// [`Widget::place_children`]'s own documentation warns against.
433    ///
434    /// [`Widget::place_children`]: teksilo_core::widget::Widget::place_children
435    pub fn column_count_signal(&self) -> Signal<usize> {
436        self.column_count.clone()
437    }
438
439    /// The column-sizing policy, as understood by the shared solver.
440    fn width_policy(&self) -> WidthPolicy {
441        WidthPolicy::Adaptive {
442            min: self.min_column_width,
443            max: self.max_column_width,
444        }
445    }
446
447    /// The solver for a given inter-column gap. `ColumnFlow` carries no insets
448    /// (wrap it in `Padding`), and does its own x placement so it can align the
449    /// block and mirror for RTL.
450    ///
451    /// `max_columns` goes *into* the solver rather than clamping its result:
452    /// `column_width` divides the width by the count, so a cap applied
453    /// afterwards would size columns for the uncapped count.
454    fn geometry(&self, col_spacing: f32) -> ColumnGeometry {
455        ColumnGeometry::from_policy(self.width_policy(), col_spacing, EdgeInsets::ZERO)
456            .with_max_columns(self.max_columns)
457    }
458
459    /// Column count at `width`, honouring [`max_columns`](Self::max_columns).
460    fn column_count_at(&self, width: f32, col_spacing: f32) -> usize {
461        self.geometry(col_spacing).column_count(width)
462    }
463
464    /// Measure every active child at `col_width`, in source order.
465    ///
466    /// Returns the ids alongside their heights: `child_size` yields `None` for
467    /// dormant children, which is exactly the subset `place_children` receives,
468    /// so both hooks agree on which children exist without extra bookkeeping.
469    fn measure(
470        &self,
471        ids: &[WidgetId],
472        col_width: f32,
473        ctx: &LayoutContext,
474    ) -> (Vec<WidgetId>, Vec<f32>) {
475        let proposal = SizeProposal::with_width(col_width);
476        let mut live = Vec::with_capacity(ids.len());
477        let mut heights = Vec::with_capacity(ids.len());
478        for &id in ids {
479            if let Some(size) = ctx.child_size(id, proposal) {
480                live.push(id);
481                heights.push(size.height);
482            }
483        }
484        (live, heights)
485    }
486
487    /// The width a column should take when the parent constrains nothing.
488    fn intrinsic_column_width(&self, ids: &[WidgetId], ctx: &LayoutContext) -> f32 {
489        let mut widest = 0.0_f32;
490        for &id in ids {
491            if let Some(size) = ctx.child_size(id, SizeProposal::unspecified()) {
492                widest = widest.max(size.width);
493            }
494        }
495        let mut w = widest.max(self.min_column_width);
496        if let Some(max) = self.max_column_width {
497            w = w.min(max);
498        }
499        w.max(0.0)
500    }
501}
502
503impl Default for ColumnFlow {
504    fn default() -> Self {
505        Self::new()
506    }
507}
508
509impl Widget for ColumnFlow {
510    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
511        let pending = std::mem::take(&mut self.pending);
512        if !pending.is_empty() {
513            let resolved: Vec<WidgetId> = pending
514                .into_iter()
515                .map(|child| match child {
516                    PendingChild::Id(id) => id,
517                    PendingChild::Deferred(w) => ctx.add_boxed(w),
518                })
519                .collect();
520
521            self.child_ids = if self.semantic_list {
522                // Wrap each child so it can carry Role::ListItem + its position.
523                let total = resolved.len();
524                resolved
525                    .into_iter()
526                    .enumerate()
527                    .map(|(i, id)| ctx.add(ColumnFlowItem::new(id, i + 1, total)))
528                    .collect()
529            } else {
530                resolved
531            };
532        }
533
534        let self_id = ctx.self_id();
535        let registry = ctx.binding_registry();
536        self.column_spacing
537            .register_if_bound(self_id, registry, BindingLevel::Relayout);
538        self.item_spacing
539            .register_if_bound(self_id, registry, BindingLevel::Relayout);
540
541        self.child_ids.clone()
542    }
543
544    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
545        if self.child_ids.is_empty() {
546            return proposal.resolve(0.0, 0.0).into();
547        }
548
549        let col_spacing = self.column_spacing.get();
550        let item_spacing = self.item_spacing.get();
551
552        let (total_width, columns, col_width) = match proposal.width {
553            Some(w) => (
554                // Echo the proposal verbatim. Recomputing the width from the
555                // resolved columns would feed a slightly different value back
556                // into `column_count_at` in `place_children` (which reads
557                // `bounds.width`), and a single ULP could flip a column.
558                w,
559                self.column_count_at(w, col_spacing),
560                self.geometry(col_spacing).column_width(w),
561            ),
562            None => {
563                // Unconstrained: a size-to-content parent (popover, menu) takes
564                // this answer verbatim, so it must be finite and modest.
565                let columns = self.max_columns.unwrap_or(1).max(1);
566                let col_width = self.intrinsic_column_width(&self.child_ids, ctx);
567                let gaps = col_spacing.max(0.0) * (columns as f32 - 1.0).max(0.0);
568                (col_width * columns as f32 + gaps, columns, col_width)
569            }
570        };
571
572        let (_, heights) = self.measure(&self.child_ids, col_width, ctx);
573        let balance = balance_columns(&heights, item_spacing, columns);
574        Size::new(total_width, balance.height).into()
575    }
576
577    fn place_children(
578        &self,
579        bounds: Rect,
580        _proposal: SizeProposal,
581        children: &mut [WidgetPlacement],
582        ctx: &LayoutContext,
583    ) {
584        let col_spacing = self.column_spacing.get().max(0.0);
585        let item_spacing = self.item_spacing.get();
586
587        // Derive from the actual bounds, not the proposal — `bounds.width` is
588        // what `layout_response` echoed back, so both agree.
589        let columns = self.column_count_at(bounds.width, col_spacing);
590        self.publish_column_count(columns);
591
592        if children.is_empty() {
593            return;
594        }
595
596        let geometry = self.geometry(col_spacing);
597        let col_width = geometry.column_width(bounds.width);
598        let used = geometry.used_width(bounds.width).min(bounds.width);
599        let rtl = ctx.is_rtl();
600        let block_x = bounds.x + self.alignment.resolve(used, bounds.width, rtl);
601
602        let ids: Vec<WidgetId> = children.iter().map(|c| c.id).collect();
603        let (_, heights) = self.measure(&ids, col_width, ctx);
604        if heights.len() != ids.len() {
605            // `children` is already the active subset, so every id must
606            // measure. Bail rather than misplace them if that ever changes.
607            return;
608        }
609        let balance = balance_columns(&heights, item_spacing, columns);
610
611        let mut col_y = vec![bounds.y; columns.max(1)];
612        for (i, child) in children.iter_mut().enumerate() {
613            let col = balance.column_of[i].min(columns.saturating_sub(1));
614            // Logical column 0 sits at the leading edge in both directions.
615            let physical = if rtl { columns - 1 - col } else { col };
616            let x = block_x + physical as f32 * (col_width + col_spacing);
617
618            if col_y[col] > bounds.y {
619                col_y[col] += item_spacing;
620            }
621            child.origin = Point::new(x, col_y[col]);
622            child.size = Size::new(col_width, heights[i]);
623            col_y[col] += heights[i];
624        }
625    }
626
627    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
628        let Some((rule_width, ref color)) = self.column_rule else {
629            return;
630        };
631        if rule_width <= 0.0 {
632            return;
633        }
634        let col_spacing = self.column_spacing.get().max(0.0);
635        let columns = self.column_count_at(bounds.width, col_spacing);
636        if columns < 2 {
637            return;
638        }
639
640        let geometry = self.geometry(col_spacing);
641        let col_width = geometry.column_width(bounds.width);
642        let used = geometry.used_width(bounds.width).min(bounds.width);
643        let rtl = ctx.layout_direction == teksilo_core::environment::LayoutDirection::RightToLeft;
644        let block_x = bounds.x + self.alignment.resolve(used, bounds.width, rtl);
645        let resolved = color.resolve(ctx.theme, ctx.effective_enabled);
646
647        // One rule centred in each of the `columns - 1` gaps. Gap positions are
648        // symmetric, so no RTL mirroring is needed here.
649        for gap_index in 0..columns - 1 {
650            let x = block_x
651                + (gap_index as f32 + 1.0) * col_width
652                + gap_index as f32 * col_spacing
653                + col_spacing / 2.0;
654            canvas.draw_line(
655                Point::new(x, bounds.y),
656                Point::new(x, bounds.bottom()),
657                resolved,
658                StrokeStyle::solid(rule_width),
659            );
660        }
661    }
662
663    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
664        if self.semantic_list {
665            builder.set_role(teksilo_core::accesskit::Role::List);
666            // The set size belongs on the container, not on each item:
667            // AccessKit's `size_of_set` differs from ARIA's per-item
668            // `aria-setsize`, and `size_of_set_from_container` resolves an
669            // item's set size by walking *up* from it.
670            if !self.child_ids.is_empty() {
671                builder.set_size_of_set(self.child_ids.len());
672            }
673        } else {
674            // Deliberately bare: the walker prunes a property-free
675            // GenericContainer and promotes the children in source order,
676            // which is already the reading order. Setting anything here (even
677            // an orientation) would keep this node alive as AT noise.
678            builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
679        }
680    }
681
682    fn children(&self) -> Vec<WidgetId> {
683        self.child_ids.clone()
684    }
685}
686
687impl ColumnFlow {
688    /// Publish the column count, guarded so the signal only fires on a real
689    /// change. The guard is what keeps a `Relayout`-bound consumer from
690    /// re-dirtying the tree on every pass.
691    fn publish_column_count(&self, columns: usize) {
692        if self.last_count.get() != columns {
693            self.last_count.set(columns);
694            self.column_count.set(columns);
695        }
696    }
697}
698
699/// Layout-transparent wrapper giving one `ColumnFlow` child its list-item
700/// accessibility identity. Mounted only under
701/// [`ColumnFlow::semantic_list`].
702///
703/// Mirrors `ListItemWrapper` in [`crate::list_item_a11y`], including its
704/// flatten-to-`Size` layout: `ColumnFlow` reads only `.size` off its children,
705/// so there is no grow/shrink weight for this wrapper to forward.
706#[derive(Debug)]
707struct ColumnFlowItem {
708    child: WidgetId,
709    /// 1-based.
710    position: usize,
711    total: usize,
712}
713
714impl ColumnFlowItem {
715    fn new(child: WidgetId, position_1based: usize, total: usize) -> Self {
716        Self {
717            child,
718            position: position_1based,
719            total,
720        }
721    }
722}
723
724impl Widget for ColumnFlowItem {
725    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
726        ctx.child_size(self.child, proposal)
727            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
728            .into()
729    }
730
731    fn place_children(
732        &self,
733        bounds: Rect,
734        _proposal: SizeProposal,
735        children: &mut [WidgetPlacement],
736        _ctx: &LayoutContext,
737    ) {
738        for child in children.iter_mut() {
739            child.origin = bounds.origin();
740            child.size = bounds.size();
741        }
742    }
743
744    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
745        builder.set_role(teksilo_core::accesskit::Role::ListItem);
746        builder.set_position_in_set(self.position);
747        // The "of N" half lives on the flow's own `Role::List` node.
748    }
749
750    fn children(&self) -> Vec<WidgetId> {
751        vec![self.child]
752    }
753}
754
755#[cfg(test)]
756mod tests {
757    use super::*;
758    use teksilo_core::widget_tree::WidgetTree;
759
760    // ── balance_columns ──────────────────────────────────────────────
761
762    /// Reconstruct each column's extent from a partition, so tests assert
763    /// against the geometry rather than the algorithm's own arithmetic.
764    fn column_extents(heights: &[f32], gap: f32, r: &BalanceResult) -> Vec<f32> {
765        let cols = r.column_of.iter().copied().max().map_or(0, |m| m + 1);
766        (0..cols)
767            .map(|c| {
768                let (mut count, mut sum) = (0usize, 0.0_f32);
769                for (i, &h) in heights.iter().enumerate() {
770                    if r.column_of[i] == c {
771                        count += 1;
772                        sum += h;
773                    }
774                }
775                run_extent(sum, count, gap)
776            })
777            .collect()
778    }
779
780    #[test]
781    fn uses_every_column_instead_of_stranding_a_trailing_one() {
782        // The empty-trailing-column regression. Naive min-max would pack
783        // [10,10] [10,10] [] — same tallest column, one column wasted.
784        let h = [10.0, 10.0, 10.0, 10.0];
785        let r = balance_columns(&h, 0.0, 3);
786        assert_eq!(r.column_of, vec![0, 0, 1, 2]);
787        assert_eq!(column_extents(&h, 0.0, &r), vec![20.0, 10.0, 10.0]);
788        assert!((r.height - 20.0).abs() < 0.01);
789    }
790
791    #[test]
792    fn evenly_divisible_input_splits_evenly() {
793        let h = [10.0; 9];
794        let r = balance_columns(&h, 0.0, 3);
795        assert_eq!(r.column_of, vec![0, 0, 0, 1, 1, 1, 2, 2, 2]);
796        assert!((r.height - 30.0).abs() < 0.01);
797    }
798
799    #[test]
800    fn single_column_extent_includes_every_gap() {
801        // 3 items of 10 with gap 5 in one column = 30 + 2*5 = 40. If the
802        // bisection's upper bound omitted the gaps it would cap at 30.
803        let h = [10.0, 10.0, 10.0];
804        let r = balance_columns(&h, 5.0, 1);
805        assert_eq!(r.column_of, vec![0, 0, 0]);
806        assert!((r.height - 40.0).abs() < 0.01, "height was {}", r.height);
807    }
808
809    #[test]
810    fn zero_height_items_still_pay_the_gap() {
811        // The count-not-accumulator regression: an `if accum > 0.0` gap test
812        // reports 0.0 here, because the running sum never leaves zero.
813        let h = [0.0, 0.0, 0.0, 0.0];
814        let r = balance_columns(&h, 8.0, 2);
815        assert!(
816            (r.height - 8.0).abs() < 0.01,
817            "two zero-height items in a column still span one gap, got {}",
818            r.height
819        );
820    }
821
822    #[test]
823    fn more_columns_than_items_does_not_panic() {
824        let h = [10.0, 20.0];
825        let r = balance_columns(&h, 0.0, 5);
826        assert_eq!(r.column_of, vec![0, 1], "clamped to one column per item");
827        assert!((r.height - 20.0).abs() < 0.01);
828    }
829
830    #[test]
831    fn empty_input_is_zero() {
832        let r = balance_columns(&[], 4.0, 3);
833        assert!(r.column_of.is_empty());
834        assert_eq!(r.height, 0.0);
835    }
836
837    #[test]
838    fn single_item() {
839        let r = balance_columns(&[50.0], 0.0, 3);
840        assert_eq!(r.column_of, vec![0]);
841        assert!((r.height - 50.0).abs() < 0.01);
842    }
843
844    #[test]
845    fn one_giant_item_sets_the_floor() {
846        // No column can be shorter than the tallest atomic child.
847        let h = [200.0, 10.0, 10.0, 10.0];
848        let r = balance_columns(&h, 0.0, 3);
849        assert!(r.height >= 200.0 - 0.01, "height was {}", r.height);
850        assert_eq!(r.column_of[0], 0);
851    }
852
853    #[test]
854    fn negative_gap_is_clamped() {
855        let h = [10.0, 10.0];
856        let r = balance_columns(&h, -100.0, 1);
857        assert!((r.height - 20.0).abs() < 0.01, "height was {}", r.height);
858    }
859
860    #[test]
861    fn partition_is_contiguous_and_ordered() {
862        // The a11y keystone: columns are runs, and run k+1 starts after run k.
863        let h = [10.0, 10.0, 10.0, 40.0, 10.0, 10.0];
864        let r = balance_columns(&h, 0.0, 2);
865        for w in r.column_of.windows(2) {
866            assert!(
867                w[1] >= w[0],
868                "column index must never go backwards: {:?}",
869                r.column_of
870            );
871        }
872    }
873
874    #[test]
875    fn reported_height_matches_reconstructed_columns() {
876        // Property-ish: the reported height must equal the tallest column as
877        // actually laid out, across a spread of shapes.
878        let cases: &[(&[f32], f32, usize)] = &[
879            (&[10.0, 10.0, 10.0, 10.0], 0.0, 3),
880            (
881                &[
882                    1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 40.0, 1.0, 1.0,
883                ],
884                4.0,
885                5,
886            ),
887            (&[5.0, 100.0, 5.0], 2.0, 2),
888            (&[7.0; 13], 3.0, 4),
889            (&[0.0, 5.0, 0.0, 5.0], 1.0, 2),
890            (&[33.0, 12.0, 90.0, 4.0, 61.0, 8.0], 6.0, 3),
891        ];
892        for (h, gap, k) in cases {
893            let r = balance_columns(h, *gap, *k);
894            let extents = column_extents(h, *gap, &r);
895            let tallest = extents.iter().copied().fold(0.0_f32, f32::max);
896            assert!(
897                (r.height - tallest).abs() < 0.01,
898                "reported {} vs reconstructed {} for {:?} gap {} k {}",
899                r.height,
900                tallest,
901                h,
902                gap,
903                k
904            );
905            assert_eq!(h.len(), r.column_of.len());
906        }
907    }
908
909    #[test]
910    fn no_column_exceeds_the_reported_height() {
911        let h = [
912            1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 40.0, 1.0, 1.0,
913        ];
914        let r = balance_columns(&h, 4.0, 5);
915        for (c, extent) in column_extents(&h, 4.0, &r).iter().enumerate() {
916            assert!(
917                *extent <= r.height + 0.01,
918                "column {c} extent {extent} exceeds reported {}",
919                r.height
920            );
921        }
922    }
923
924    #[test]
925    fn is_deterministic_across_repeated_calls() {
926        // layout_response and place_children each run the search from scratch;
927        // they must agree bit-for-bit.
928        let h = [33.0, 12.0, 90.0, 4.0, 61.0, 8.0, 17.0];
929        let a = balance_columns(&h, 6.0, 3);
930        let b = balance_columns(&h, 6.0, 3);
931        assert_eq!(a, b);
932    }
933
934    // ── widget ───────────────────────────────────────────────────────
935
936    #[derive(Debug)]
937    struct FixedLeaf(f32, f32);
938    impl Widget for FixedLeaf {
939        fn layout_response(&self, _p: SizeProposal, _c: &LayoutContext) -> LayoutResponse {
940            Size::new(self.0, self.1).into()
941        }
942    }
943
944    /// A leaf that carries real semantics, so the a11y walker keeps it.
945    /// `FixedLeaf` emits a bare `Role::Unknown` and is itself presentational —
946    /// it would be pruned right along with the container, which proves
947    /// nothing about promotion.
948    #[derive(Debug)]
949    struct LabeledLeaf(f32, f32, &'static str);
950    impl Widget for LabeledLeaf {
951        fn layout_response(&self, _p: SizeProposal, _c: &LayoutContext) -> LayoutResponse {
952            Size::new(self.0, self.1).into()
953        }
954        fn accessibility(&self, builder: &mut AccessNodeBuilder) {
955            builder.set_role(teksilo_core::accesskit::Role::Button);
956            builder.set_name(self.2);
957        }
958    }
959
960    /// Six 40 dp-tall children, `min_column_width` 100.
961    fn six_children(tree: &mut WidgetTree) -> (Vec<WidgetId>, WidgetId) {
962        let ids: Vec<_> = (0..6).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
963        let mut flow = ColumnFlow::new().min_column_width(100.0);
964        for &id in &ids {
965            flow = flow.add_child(id);
966        }
967        let flow_id = tree.add(flow);
968        (ids, flow_id)
969    }
970
971    #[test]
972    fn column_count_follows_width() {
973        let mut tree = WidgetTree::new();
974        let (ids, _) = six_children(&mut tree);
975
976        // 300 wide / min 100 -> 3 columns, 2 items each.
977        tree.layout(SizeProposal::exact(300.0, 400.0));
978        assert!((tree.bounds(ids[0]).x - 0.0).abs() < 0.01);
979        assert!((tree.bounds(ids[2]).x - 100.0).abs() < 0.01);
980        assert!((tree.bounds(ids[4]).x - 200.0).abs() < 0.01);
981    }
982
983    #[test]
984    fn losing_a_column_repartitions_every_child() {
985        let mut tree = WidgetTree::new();
986        let (ids, _) = six_children(&mut tree);
987
988        // 3 columns: [0,1] [2,3] [4,5]
989        tree.layout(SizeProposal::exact(300.0, 400.0));
990        assert!((tree.bounds(ids[2]).x - 100.0).abs() < 0.01);
991        assert!((tree.bounds(ids[3]).y - 40.0).abs() < 0.01);
992
993        // 2 columns: [0,1,2] [3,4,5] — child 2 moved back to column 0 and
994        // child 3 became the top of column 1. Every child was repartitioned.
995        tree.layout(SizeProposal::exact(200.0, 400.0));
996        assert!(
997            (tree.bounds(ids[2]).x - 0.0).abs() < 0.01,
998            "child 2 -> col 0"
999        );
1000        assert!((tree.bounds(ids[2]).y - 80.0).abs() < 0.01);
1001        assert!(
1002            (tree.bounds(ids[3]).x - 100.0).abs() < 0.01,
1003            "child 3 -> col 1"
1004        );
1005        assert!(
1006            (tree.bounds(ids[3]).y - 0.0).abs() < 0.01,
1007            "child 3 tops col 1"
1008        );
1009
1010        // 1 column: everything stacks.
1011        tree.layout(SizeProposal::exact(100.0, 400.0));
1012        for (i, &id) in ids.iter().enumerate() {
1013            assert!((tree.bounds(id).x - 0.0).abs() < 0.01);
1014            assert!((tree.bounds(id).y - (i as f32 * 40.0)).abs() < 0.01);
1015        }
1016    }
1017
1018    #[test]
1019    fn reported_height_matches_placed_content() {
1020        // layout_response and place_children must agree — the container must
1021        // never report a height its own children overflow.
1022        let mut tree = WidgetTree::new();
1023        let heights = [30.0, 70.0, 20.0, 55.0, 45.0];
1024        let ids: Vec<_> = heights
1025            .iter()
1026            .map(|&h| tree.add(FixedLeaf(50.0, h)))
1027            .collect();
1028        let mut flow = ColumnFlow::new().min_column_width(100.0).item_spacing(8.0);
1029        for &id in &ids {
1030            flow = flow.add_child(id);
1031        }
1032        let flow_id = tree.add(flow);
1033
1034        for width in [100.0, 200.0, 300.0, 400.0, 500.0] {
1035            tree.layout(SizeProposal {
1036                width: Some(width),
1037                height: None,
1038            });
1039            let reported = tree.bounds(flow_id).height;
1040            let top = tree.bounds(flow_id).y;
1041            let deepest = ids
1042                .iter()
1043                .map(|&id| tree.bounds(id).bottom() - top)
1044                .fold(0.0_f32, f32::max);
1045            assert!(
1046                (reported - deepest).abs() < 0.01,
1047                "at width {width}: reported {reported}, content reaches {deepest}"
1048            );
1049        }
1050    }
1051
1052    #[test]
1053    fn children_receive_the_column_width() {
1054        let mut tree = WidgetTree::new();
1055        let (ids, _) = six_children(&mut tree);
1056        tree.layout(SizeProposal::exact(300.0, 400.0));
1057        // Placed at the column width (100), not their intrinsic 50.
1058        assert!((tree.bounds(ids[0]).width - 100.0).abs() < 0.01);
1059    }
1060
1061    #[test]
1062    fn column_spacing_applied() {
1063        let mut tree = WidgetTree::new();
1064        let ids: Vec<_> = (0..4).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
1065        let mut flow = ColumnFlow::new()
1066            .min_column_width(100.0)
1067            .column_spacing(10.0);
1068        for &id in &ids {
1069            flow = flow.add_child(id);
1070        }
1071        tree.add(flow);
1072        // floor((320 + 10) / (100 + 10)) = 3 columns; width (320 - 20)/3 = 100.
1073        tree.layout(SizeProposal::exact(320.0, 400.0));
1074        assert!((tree.bounds(ids[0]).x - 0.0).abs() < 0.01);
1075        assert!((tree.bounds(ids[2]).x - 110.0).abs() < 0.01);
1076        assert!((tree.bounds(ids[3]).x - 220.0).abs() < 0.01);
1077    }
1078
1079    #[test]
1080    fn item_spacing_applied() {
1081        let mut tree = WidgetTree::new();
1082        let ids: Vec<_> = (0..4).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
1083        let mut flow = ColumnFlow::new().min_column_width(100.0).item_spacing(8.0);
1084        for &id in &ids {
1085            flow = flow.add_child(id);
1086        }
1087        tree.add(flow);
1088        // 2 columns: [0,1] [2,3]; second item sits at 40 + 8.
1089        tree.layout(SizeProposal::exact(200.0, 400.0));
1090        assert!((tree.bounds(ids[1]).y - 48.0).abs() < 0.01);
1091        assert!((tree.bounds(ids[3]).y - 48.0).abs() < 0.01);
1092    }
1093
1094    #[test]
1095    fn max_columns_caps_the_count() {
1096        let mut tree = WidgetTree::new();
1097        let (ids, _) = {
1098            let ids: Vec<_> = (0..6).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
1099            let mut flow = ColumnFlow::new().min_column_width(100.0).max_columns(2);
1100            for &id in &ids {
1101                flow = flow.add_child(id);
1102            }
1103            let flow_id = tree.add(flow);
1104            (ids, flow_id)
1105        };
1106        // 600 wide would fit 6 columns, but max_columns pins it to 2.
1107        tree.layout(SizeProposal::exact(600.0, 400.0));
1108        assert!((tree.bounds(ids[0]).x - 0.0).abs() < 0.01);
1109        assert!((tree.bounds(ids[3]).x - 300.0).abs() < 0.01);
1110        assert!((tree.bounds(ids[5]).x - 300.0).abs() < 0.01);
1111    }
1112
1113    #[test]
1114    fn max_column_width_clamps_and_alignment_places_the_block() {
1115        let mut tree = WidgetTree::new();
1116        let ids: Vec<_> = (0..2).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
1117        let mut flow = ColumnFlow::new()
1118            .min_column_width(400.0)
1119            .max_column_width(300.0)
1120            .max_columns(2)
1121            .alignment(HAlignment::Center);
1122        for &id in &ids {
1123            flow = flow.add_child(id);
1124        }
1125        tree.add(flow);
1126        // 1000 wide: 2 columns, each clamped 500 -> 300. Used = 600,
1127        // leftover = 400, centred -> block starts at 200.
1128        tree.layout(SizeProposal::exact(1000.0, 400.0));
1129        assert!((tree.bounds(ids[0]).width - 300.0).abs() < 0.01);
1130        assert!(
1131            (tree.bounds(ids[0]).x - 200.0).abs() < 0.01,
1132            "centred block, got x = {}",
1133            tree.bounds(ids[0]).x
1134        );
1135        assert!((tree.bounds(ids[1]).x - 500.0).abs() < 0.01);
1136    }
1137
1138    #[test]
1139    fn unbounded_width_reports_one_column_by_default() {
1140        let mut tree = WidgetTree::new();
1141        let a = tree.add(FixedLeaf(80.0, 40.0));
1142        let b = tree.add(FixedLeaf(60.0, 30.0));
1143        let flow = tree.add(
1144            ColumnFlow::new()
1145                .min_column_width(50.0)
1146                .add_child(a)
1147                .add_child(b),
1148        );
1149        tree.layout(SizeProposal {
1150            width: None,
1151            height: Some(400.0),
1152        });
1153        // One column at the widest child (80). A size-to-content parent takes
1154        // this verbatim, so it must not balloon.
1155        assert!(
1156            (tree.bounds(flow).width - 80.0).abs() < 0.01,
1157            "got {}",
1158            tree.bounds(flow).width
1159        );
1160    }
1161
1162    #[test]
1163    fn unbounded_width_honours_max_columns() {
1164        let mut tree = WidgetTree::new();
1165        let a = tree.add(FixedLeaf(80.0, 40.0));
1166        let b = tree.add(FixedLeaf(60.0, 30.0));
1167        let flow = tree.add(
1168            ColumnFlow::new()
1169                .min_column_width(50.0)
1170                .max_columns(3)
1171                .column_spacing(10.0)
1172                .add_child(a)
1173                .add_child(b),
1174        );
1175        tree.layout(SizeProposal {
1176            width: None,
1177            height: Some(400.0),
1178        });
1179        // 3 columns of 80 + 2 gaps of 10 = 260.
1180        assert!(
1181            (tree.bounds(flow).width - 260.0).abs() < 0.01,
1182            "got {}",
1183            tree.bounds(flow).width
1184        );
1185    }
1186
1187    #[test]
1188    fn empty_flow_has_zero_height() {
1189        let mut tree = WidgetTree::new();
1190        let flow = tree.add(ColumnFlow::new());
1191        tree.layout(SizeProposal {
1192            width: Some(300.0),
1193            height: None,
1194        });
1195        assert!((tree.bounds(flow).height - 0.0).abs() < 0.01);
1196    }
1197
1198    #[test]
1199    fn dormant_child_excluded_and_partition_stays_stable() {
1200        let mut tree = WidgetTree::new();
1201        let ids: Vec<_> = (0..4).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
1202        let mut flow = ColumnFlow::new().min_column_width(100.0);
1203        for &id in &ids {
1204            flow = flow.add_child(id);
1205        }
1206        tree.add(flow);
1207        tree.layout(SizeProposal::exact(200.0, 400.0));
1208        // 2 columns: [0,1] [2,3]
1209        assert!((tree.bounds(ids[2]).x - 100.0).abs() < 0.01);
1210
1211        // Drop child 1: the live set is [0,2,3] -> [0,2] [3]
1212        tree.set_dormant(ids[1]);
1213        tree.layout(SizeProposal::exact(200.0, 400.0));
1214        assert!((tree.bounds(ids[0]).x - 0.0).abs() < 0.01);
1215        assert!(
1216            (tree.bounds(ids[2]).x - 0.0).abs() < 0.01,
1217            "child 2 -> col 0"
1218        );
1219        assert!((tree.bounds(ids[2]).y - 40.0).abs() < 0.01);
1220        assert!(
1221            (tree.bounds(ids[3]).x - 100.0).abs() < 0.01,
1222            "child 3 -> col 1"
1223        );
1224    }
1225
1226    #[test]
1227    fn rtl_mirrors_columns_without_touching_source_order() {
1228        let mut tree = WidgetTree::new();
1229        tree.set_layout_direction(teksilo_core::environment::LayoutDirection::RightToLeft);
1230        let (ids, flow) = six_children(&mut tree);
1231        tree.layout(SizeProposal::exact(300.0, 400.0));
1232
1233        // Logical column 0 sits at the trailing (right) edge under RTL.
1234        assert!((tree.bounds(ids[0]).x - 200.0).abs() < 0.01);
1235        assert!((tree.bounds(ids[2]).x - 100.0).abs() < 0.01);
1236        assert!((tree.bounds(ids[4]).x - 0.0).abs() < 0.01);
1237        // Mirroring is geometry only — children() order is untouched, so the
1238        // reading and focus order still follow the source.
1239        assert_eq!(tree.children(flow), ids);
1240    }
1241
1242    #[test]
1243    fn column_count_signal_fires_only_on_a_real_change() {
1244        let mut tree = WidgetTree::new();
1245        let ids: Vec<_> = (0..6).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
1246        let flow = ColumnFlow::new().min_column_width(100.0);
1247        let count = flow.column_count_signal();
1248        let mut f = flow;
1249        for &id in &ids {
1250            f = f.add_child(id);
1251        }
1252        tree.add(f);
1253
1254        let fires = std::rc::Rc::new(Cell::new(0usize));
1255        let seen = fires.clone();
1256        let _guard = count.observe(move |_| seen.set(seen.get() + 1));
1257
1258        tree.layout(SizeProposal::exact(300.0, 400.0));
1259        assert_eq!(count.get(), 3);
1260        let after_first = fires.get();
1261
1262        // Same width twice: no further notification.
1263        tree.layout(SizeProposal::exact(300.0, 400.0));
1264        assert_eq!(
1265            fires.get(),
1266            after_first,
1267            "re-layout at the same width is silent"
1268        );
1269
1270        // Crossing to 2 columns fires exactly once.
1271        tree.layout(SizeProposal::exact(200.0, 400.0));
1272        assert_eq!(count.get(), 2);
1273        assert_eq!(fires.get(), after_first + 1);
1274    }
1275
1276    // ── accessibility ────────────────────────────────────────────────
1277
1278    fn find_node(
1279        update: &teksilo_core::accesskit::TreeUpdate,
1280        id: WidgetId,
1281    ) -> Option<&teksilo_core::accesskit::Node> {
1282        let nid = teksilo_core::accessibility::widget_id_to_node_id(id);
1283        update
1284            .nodes
1285            .iter()
1286            .find(|(n, _)| *n == nid)
1287            .map(|(_, node)| node)
1288    }
1289
1290    fn nodes_with_role_ids(
1291        update: &teksilo_core::accesskit::TreeUpdate,
1292        role: teksilo_core::accesskit::Role,
1293    ) -> Vec<teksilo_core::accesskit::NodeId> {
1294        update
1295            .nodes
1296            .iter()
1297            .filter(|(_, n)| n.role() == role)
1298            .map(|(id, _)| *id)
1299            .collect()
1300    }
1301
1302    fn nodes_with_role(
1303        update: &teksilo_core::accesskit::TreeUpdate,
1304        role: teksilo_core::accesskit::Role,
1305    ) -> Vec<&teksilo_core::accesskit::Node> {
1306        update
1307            .nodes
1308            .iter()
1309            .filter(|(_, n)| n.role() == role)
1310            .map(|(_, n)| n)
1311            .collect()
1312    }
1313
1314    #[test]
1315    fn default_container_is_pruned_and_children_promoted_in_source_order() {
1316        let mut tree = WidgetTree::new();
1317        let labels = ["one", "two", "three", "four"];
1318        let ids: Vec<_> = labels
1319            .iter()
1320            .map(|&l| tree.add(LabeledLeaf(50.0, 40.0, l)))
1321            .collect();
1322        let mut flow = ColumnFlow::new().min_column_width(100.0);
1323        for &id in &ids {
1324            flow = flow.add_child(id);
1325        }
1326        let flow_id = tree.add(flow);
1327        tree.layout(SizeProposal::exact(200.0, 400.0));
1328        let update = tree.sync_accessibility();
1329
1330        // A bare GenericContainer carries no semantics, so the walker drops it
1331        // and promotes the children — the correct result for a layout, which
1332        // contributes geometry rather than meaning.
1333        assert!(
1334            find_node(&update, flow_id).is_none(),
1335            "a property-free layout container must not reach assistive tech"
1336        );
1337        // The children survive; only the empty box went away.
1338        for &id in &ids {
1339            assert!(find_node(&update, id).is_some(), "child kept");
1340        }
1341
1342        // And they are read in source order, not visual column order — the
1343        // keystone invariant. At 2 columns the visual layout is
1344        // [one, two] [three, four]; the reading order is still one..four.
1345        // ColumnFlow was the tree root, so its children promote all the way to
1346        // the synthetic Window root.
1347        let root = update
1348            .nodes
1349            .iter()
1350            .find(|(n, _)| *n == teksilo_core::accessibility::root_node_id())
1351            .map(|(_, node)| node)
1352            .expect("window root node");
1353        let order: Vec<_> = root
1354            .children()
1355            .iter()
1356            .filter_map(|nid| {
1357                update
1358                    .nodes
1359                    .iter()
1360                    .find(|(n, _)| n == nid)
1361                    .and_then(|(_, n)| n.label())
1362            })
1363            .collect();
1364        assert_eq!(order, labels, "promoted children keep source order");
1365    }
1366
1367    #[test]
1368    fn semantic_list_emits_list_and_positioned_items() {
1369        let mut tree = WidgetTree::new();
1370        let ids: Vec<_> = (0..3).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
1371        let mut flow = ColumnFlow::new()
1372            .min_column_width(100.0)
1373            .semantic_list(true);
1374        for &id in &ids {
1375            flow = flow.add_child(id);
1376        }
1377        let flow_id = tree.add(flow);
1378        tree.layout(SizeProposal::exact(300.0, 400.0));
1379        let update = tree.sync_accessibility();
1380
1381        let list = find_node(&update, flow_id).expect("List node survives pruning");
1382        assert_eq!(list.role(), teksilo_core::accesskit::Role::List);
1383
1384        let item_ids = nodes_with_role_ids(&update, teksilo_core::accesskit::Role::ListItem);
1385        assert_eq!(item_ids.len(), 3, "one ListItem per child");
1386        // Announced as "item N of 3", in source order — asked the way an
1387        // adapter asks it, so the size has to be resolvable by walking up to
1388        // the flow's own `Role::List` node.
1389        let mut seen: Vec<(Option<usize>, Option<usize>)> = item_ids
1390            .iter()
1391            .map(|id| crate::a11y_set_semantics::announced_set_position(&update, *id))
1392            .collect();
1393        seen.sort();
1394        assert_eq!(
1395            seen,
1396            vec![(Some(1), Some(3)), (Some(2), Some(3)), (Some(3), Some(3))]
1397        );
1398    }
1399
1400    // ── paint ────────────────────────────────────────────────────────
1401
1402    /// Every x the column rule was drawn at, from a real render pass.
1403    /// `draw_line` lands in `decorations` or `cosmetic_lines` depending on the
1404    /// stroke space, so check both rather than assume.
1405    fn rule_xs(tree: &mut WidgetTree) -> Vec<f32> {
1406        let frame = tree.render();
1407        let mut xs: Vec<f32> = frame
1408            .cosmetic_lines
1409            .iter()
1410            .filter(|l| (l.from[0] - l.to[0]).abs() < 0.01) // vertical only
1411            .map(|l| l.from[0])
1412            .chain(
1413                frame
1414                    .decorations
1415                    .iter()
1416                    .filter(|d| d.rect[2] > 0.0 && d.rect[2] <= 2.0 && d.rect[3] > 10.0)
1417                    .map(|d| d.rect[0] + d.rect[2] / 2.0),
1418            )
1419            .collect();
1420        xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
1421        xs
1422    }
1423
1424    fn flow_with_rule(tree: &mut WidgetTree, rule: bool) -> WidgetId {
1425        let ids: Vec<_> = (0..6).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
1426        let mut flow = ColumnFlow::new().min_column_width(100.0);
1427        if rule {
1428            flow = flow.column_rule(1.0, teksilo_tokens::BorderRole::Divider);
1429        }
1430        for &id in &ids {
1431            flow = flow.add_child(id);
1432        }
1433        tree.add(flow)
1434    }
1435
1436    #[test]
1437    fn column_rule_paints_one_line_centred_in_each_gap() {
1438        let mut tree = WidgetTree::new();
1439        flow_with_rule(&mut tree, true);
1440        // 3 columns of 100 in 300, no spacing -> gaps centred at 100 and 200.
1441        tree.layout(SizeProposal::exact(300.0, 400.0));
1442        let xs = rule_xs(&mut tree);
1443        assert_eq!(xs.len(), 2, "columns - 1 rules, got {xs:?}");
1444        assert!((xs[0] - 100.0).abs() < 0.01, "got {xs:?}");
1445        assert!((xs[1] - 200.0).abs() < 0.01, "got {xs:?}");
1446    }
1447
1448    #[test]
1449    fn column_rule_follows_the_reflow() {
1450        let mut tree = WidgetTree::new();
1451        flow_with_rule(&mut tree, true);
1452        tree.layout(SizeProposal::exact(300.0, 400.0));
1453        assert_eq!(rule_xs(&mut tree).len(), 2, "3 columns -> 2 rules");
1454
1455        tree.layout(SizeProposal::exact(200.0, 400.0));
1456        assert_eq!(rule_xs(&mut tree).len(), 1, "2 columns -> 1 rule");
1457
1458        tree.layout(SizeProposal::exact(100.0, 400.0));
1459        assert!(
1460            rule_xs(&mut tree).is_empty(),
1461            "a single column has no gap to rule"
1462        );
1463    }
1464
1465    #[test]
1466    fn no_rule_paints_nothing() {
1467        let mut tree = WidgetTree::new();
1468        flow_with_rule(&mut tree, false);
1469        tree.layout(SizeProposal::exact(300.0, 400.0));
1470        assert!(
1471            rule_xs(&mut tree).is_empty(),
1472            "column_rule is opt-in; the default layout paints nothing"
1473        );
1474    }
1475
1476    #[test]
1477    fn column_rule_sits_in_the_gap_when_spacing_is_wide() {
1478        let mut tree = WidgetTree::new();
1479        let ids: Vec<_> = (0..4).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
1480        let mut flow = ColumnFlow::new()
1481            .min_column_width(100.0)
1482            .column_spacing(20.0)
1483            .column_rule(1.0, teksilo_tokens::BorderRole::Divider);
1484        for &id in &ids {
1485            flow = flow.add_child(id);
1486        }
1487        tree.add(flow);
1488        // floor((340 + 20) / 120) = 3 columns; width = (340 - 40)/3 = 100.
1489        // Gap 0 spans 100..120 -> rule at 110. Gap 1 spans 220..240 -> 230.
1490        tree.layout(SizeProposal::exact(340.0, 400.0));
1491        let xs = rule_xs(&mut tree);
1492        assert_eq!(xs.len(), 2, "got {xs:?}");
1493        assert!((xs[0] - 110.0).abs() < 0.01, "centred in gap 0, got {xs:?}");
1494        assert!((xs[1] - 230.0).abs() < 0.01, "centred in gap 1, got {xs:?}");
1495    }
1496
1497    #[test]
1498    fn semantic_list_wrapper_is_layout_transparent() {
1499        // The wrapper must not perturb geometry.
1500        let mut tree = WidgetTree::new();
1501        let ids: Vec<_> = (0..4).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
1502        let mut flow = ColumnFlow::new()
1503            .min_column_width(100.0)
1504            .semantic_list(true);
1505        for &id in &ids {
1506            flow = flow.add_child(id);
1507        }
1508        tree.add(flow);
1509        tree.layout(SizeProposal::exact(200.0, 400.0));
1510
1511        assert!((tree.bounds(ids[0]).x - 0.0).abs() < 0.01);
1512        assert!((tree.bounds(ids[0]).width - 100.0).abs() < 0.01);
1513        assert!((tree.bounds(ids[2]).x - 100.0).abs() < 0.01);
1514        assert!((tree.bounds(ids[1]).y - 40.0).abs() < 0.01);
1515    }
1516}
1517
1518/// Property-based tests for [`balance_columns`].
1519///
1520/// `balance_columns` is `pub(crate)`, so this suite lives inline rather than
1521/// in `tests/` (an integration test cannot see it). The module docs above
1522/// state a handful of unusually crisp, checkable guarantees: children are
1523/// distributed as **contiguous source-order runs** (the property that keeps
1524/// visual order == focus order == the a11y walk order — see the "Reading
1525/// order" section at the top of this file), the partition uses **exactly**
1526/// `k` columns whenever `n >= k` with **no column left empty**, the result is
1527/// **deterministic** across repeated calls (`layout_response` and
1528/// `place_children` each re-run the search from scratch with no persisted
1529/// state, so a disagreement between two calls would desynchronise measurement
1530/// from placement), and the balanced tallest column is never worse than a
1531/// naive same-count-per-column split (the oracle this bisection search
1532/// replaces).
1533///
1534/// `cargo-fuzz` needs nightly + libfuzzer-sys, which isn't assumed here;
1535/// proptest with 256–512 cases per property (override with
1536/// `PROPTEST_CASES=N`) gives the "never panics / never regresses on a weird
1537/// shape" coverage a fuzz corpus would, plus shrinking. See `mod tests` above
1538/// for the example-based regression coverage this suite deliberately does not
1539/// repeat (the empty-trailing-column bug, the zero-height-still-pays-the-gap
1540/// bug, etc.).
1541#[cfg(test)]
1542mod proptests {
1543    use super::*;
1544    use proptest::prelude::*;
1545
1546    // Zero and small heights are the specific edge case `columns_needed`
1547    // guards against (a run of zero-height items is still `n` items with
1548    // `n-1` gaps between them) — bias toward hitting them.
1549    fn arb_height() -> impl Strategy<Value = f32> {
1550        prop_oneof![Just(0.0_f32), 0.0f32..500.0_f32,]
1551    }
1552
1553    fn arb_heights() -> impl Strategy<Value = Vec<f32>> {
1554        prop::collection::vec(arb_height(), 0..24)
1555    }
1556
1557    // Zero, negative (clamped), and huge gaps are the documented edge cases;
1558    // a mid-range gap is the common case.
1559    fn arb_gap() -> impl Strategy<Value = f32> {
1560        prop_oneof![
1561            Just(0.0_f32),
1562            Just(-5.0_f32),
1563            0.0f32..50.0_f32,
1564            Just(10_000.0_f32),
1565        ]
1566    }
1567
1568    // A non-negative gap for properties that compare against an oracle
1569    // computed with the same, unclamped gap value.
1570    fn arb_nonneg_gap() -> impl Strategy<Value = f32> {
1571        prop_oneof![Just(0.0_f32), 0.0f32..50.0_f32, Just(5_000.0_f32),]
1572    }
1573
1574    // k == 0 and k > n are the documented degenerate cases; small k is the
1575    // common case.
1576    fn arb_k() -> impl Strategy<Value = usize> {
1577        prop_oneof![Just(0usize), 1usize..8usize,]
1578    }
1579
1580    /// Same-count-per-column split: assign `heights` to `k` columns as
1581    /// contiguous runs of near-equal *count*, ignoring the heights entirely.
1582    /// This is the textbook naive multi-column partition.
1583    ///
1584    /// Why this (rather than literally re-implementing the "greedy fill"
1585    /// mentioned in the module docs) is a sound comparison oracle:
1586    /// `columns_needed` is a monotone feasibility check (a higher limit
1587    /// never needs more columns), so bisecting it finds the smallest limit
1588    /// any contiguous partition into `k_eff` runs can achieve — i.e. the
1589    /// *true minimum* possible tallest-column extent over **every** valid
1590    /// `k_eff`-way contiguous partition, not just ones `balance_columns`
1591    /// happens to construct. (The reserve tweak in `balance_columns` only
1592    /// ever makes an earlier column take *fewer* items to keep every column
1593    /// non-empty — it can't push a column's extent past the bisected limit,
1594    /// since splitting a feasible run into two contiguous sub-runs can only
1595    /// keep or shrink each half's extent.) Given that, `balance_columns`'
1596    /// tallest column is, by construction, less than or equal to *any*
1597    /// specific `k`-way contiguous partition — the even-count split above,
1598    /// a hand-rolled greedy-fill-at-the-average, or anything else — so this
1599    /// oracle is valid regardless of which "naive" strategy is picked; the
1600    /// even-count split is simply the simplest one to implement correctly.
1601    fn naive_even_split_extents(heights: &[f32], gap: f32, k: usize) -> Vec<f32> {
1602        let n = heights.len();
1603        if n == 0 {
1604            return Vec::new();
1605        }
1606        let k_eff = k.min(n).max(1);
1607        let base = n / k_eff;
1608        let extra = n % k_eff;
1609        let mut extents = Vec::with_capacity(k_eff);
1610        let mut idx = 0usize;
1611        for col in 0..k_eff {
1612            let take = base + usize::from(col < extra);
1613            let slice = &heights[idx..idx + take];
1614            let sum: f32 = slice.iter().sum();
1615            extents.push(run_extent(sum, take, gap));
1616            idx += take;
1617        }
1618        extents
1619    }
1620
1621    // ── 1. partition is a set of contiguous, source-order runs ──
1622    proptest! {
1623        #[test]
1624        fn column_indices_never_decrease_across_the_source_order(
1625            heights in arb_heights(), gap in arb_gap(), k in arb_k(),
1626        ) {
1627            // column_of is non-decreasing in i, which is exactly what makes
1628            // each column's original indices a contiguous block, and
1629            // concatenating the columns in order reproduces 0..n exactly —
1630            // the property that keeps visual order == focus order == the
1631            // a11y walk order (see the "Reading order" module docs above).
1632            let r = balance_columns(&heights, gap, k);
1633            for w in r.column_of.windows(2) {
1634                prop_assert!(
1635                    w[1] >= w[0],
1636                    "column index went backwards in {:?}", r.column_of
1637                );
1638            }
1639        }
1640    }
1641
1642    // ── 2. exactly k columns are used whenever n >= k ──
1643    proptest! {
1644        #[test]
1645        fn uses_exactly_k_columns_when_there_are_enough_items(
1646            heights in arb_heights(), gap in arb_gap(), k in 1usize..8usize,
1647        ) {
1648            let n = heights.len();
1649            prop_assume!(n >= k);
1650            let r = balance_columns(&heights, gap, k);
1651            let used = r.column_of.iter().copied().max().map_or(0, |m| m + 1);
1652            prop_assert_eq!(
1653                used, k,
1654                "expected exactly {} columns for {} items, used {}", k, n, used
1655            );
1656        }
1657    }
1658
1659    // ── 3. no column is left empty when n >= k ──
1660    proptest! {
1661        #[test]
1662        fn no_column_is_empty_when_there_are_enough_items(
1663            heights in arb_heights(), gap in arb_gap(), k in 1usize..8usize,
1664        ) {
1665            let n = heights.len();
1666            prop_assume!(n >= k);
1667            let r = balance_columns(&heights, gap, k);
1668            for col in 0..k {
1669                prop_assert!(
1670                    r.column_of.contains(&col),
1671                    "column {} is empty in partition {:?}", col, r.column_of
1672                );
1673            }
1674        }
1675    }
1676
1677    // ── 4. balance never does worse than a naive even-count split ──
1678    proptest! {
1679        #![proptest_config(ProptestConfig { cases: 512, ..ProptestConfig::default() })]
1680        #[test]
1681        fn tallest_column_is_at_most_the_naive_even_split(
1682            heights in arb_heights(), gap in arb_nonneg_gap(), k in arb_k(),
1683        ) {
1684            let r = balance_columns(&heights, gap, k);
1685            let naive_tallest = naive_even_split_extents(&heights, gap, k)
1686                .into_iter()
1687                .fold(0.0_f32, f32::max);
1688            prop_assert!(
1689                r.height <= naive_tallest + 0.01,
1690                "balanced height {} exceeds naive even-split height {} for {:?} gap {} k {}",
1691                r.height, naive_tallest, heights, gap, k
1692            );
1693        }
1694    }
1695
1696    // ── 5. determinism across repeated calls ──
1697    proptest! {
1698        #[test]
1699        fn repeated_calls_on_the_same_input_agree_bit_for_bit(
1700            heights in arb_heights(), gap in arb_gap(), k in arb_k(),
1701        ) {
1702            // layout_response and place_children each run the bisection from
1703            // scratch; a disagreement here would desynchronise measured size
1704            // from placed geometry.
1705            let a = balance_columns(&heights, gap, k);
1706            let b = balance_columns(&heights, gap, k);
1707            prop_assert_eq!(
1708                &a, &b,
1709                "two calls with identical input ({:?}, gap {}, k {}) produced different partitions: {:?} vs {:?}",
1710                heights, gap, k, a, b
1711            );
1712        }
1713    }
1714
1715    // ── 6. reported height matches the reconstructed tallest column ──
1716    proptest! {
1717        #[test]
1718        fn reported_height_matches_the_reconstructed_tallest_column(
1719            heights in arb_heights(), gap in arb_gap(), k in arb_k(),
1720        ) {
1721            let r = balance_columns(&heights, gap, k);
1722            let cols = r.column_of.iter().copied().max().map_or(0, |m| m + 1);
1723            let mut sums = vec![0.0f32; cols];
1724            let mut counts = vec![0usize; cols];
1725            for (i, &h) in heights.iter().enumerate() {
1726                counts[r.column_of[i]] += 1;
1727                sums[r.column_of[i]] += h;
1728            }
1729            let clamped_gap = gap.max(0.0);
1730            let tallest = (0..cols)
1731                .map(|c| run_extent(sums[c], counts[c], clamped_gap))
1732                .fold(0.0_f32, f32::max);
1733            prop_assert!(
1734                (r.height - tallest).abs() < 0.05,
1735                "reported height {} disagrees with reconstructed tallest column {}",
1736                r.height, tallest
1737            );
1738        }
1739    }
1740
1741    // ── 7. no column ever exceeds the reported height ──
1742    proptest! {
1743        #[test]
1744        fn no_column_extent_exceeds_the_reported_height(
1745            heights in arb_heights(), gap in arb_gap(), k in arb_k(),
1746        ) {
1747            let r = balance_columns(&heights, gap, k);
1748            let cols = r.column_of.iter().copied().max().map_or(0, |m| m + 1);
1749            let mut sums = vec![0.0f32; cols];
1750            let mut counts = vec![0usize; cols];
1751            for (i, &h) in heights.iter().enumerate() {
1752                counts[r.column_of[i]] += 1;
1753                sums[r.column_of[i]] += h;
1754            }
1755            let clamped_gap = gap.max(0.0);
1756            for c in 0..cols {
1757                let extent = run_extent(sums[c], counts[c], clamped_gap);
1758                prop_assert!(
1759                    extent <= r.height + 0.05,
1760                    "column {} extent {} exceeds reported height {}", c, extent, r.height
1761                );
1762            }
1763        }
1764    }
1765
1766    // ── 8. never panics on degenerate shapes (n == 0, k == 0, k > n, huge gap) ──
1767    proptest! {
1768        #[test]
1769        fn never_panics_on_degenerate_input(
1770            heights in prop::collection::vec(arb_height(), 0..3),
1771            gap in prop_oneof![Just(0.0_f32), Just(-1.0_f32), Just(1.0e6_f32)],
1772            k in prop_oneof![Just(0usize), Just(1usize), Just(100usize)],
1773        ) {
1774            let n = heights.len();
1775            let r = balance_columns(&heights, gap, k);
1776            prop_assert_eq!(
1777                r.column_of.len(), n,
1778                "every child must be assigned a column: heights {:?} gap {} k {} -> {:?}",
1779                heights, gap, k, r.column_of
1780            );
1781            // Every assigned column index must be a valid index into the
1782            // partition (`k_eff = k.min(n).max(1) <= n` whenever n >= 1), even
1783            // when k wildly overshoots n (k = 100 against at most 2 items).
1784            prop_assert!(
1785                r.column_of.iter().all(|&c| c < n.max(1)),
1786                "out-of-range column index in {:?} for {} items (gap {} k {})",
1787                r.column_of, n, gap, k
1788            );
1789            prop_assert!(
1790                r.height.is_finite() && r.height >= 0.0,
1791                "height {} is not a finite, non-negative number for heights {:?} gap {} k {}",
1792                r.height, heights, gap, k
1793            );
1794        }
1795    }
1796}