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        } else {
667            // Deliberately bare: the walker prunes a property-free
668            // GenericContainer and promotes the children in source order,
669            // which is already the reading order. Setting anything here (even
670            // an orientation) would keep this node alive as AT noise.
671            builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
672        }
673    }
674
675    fn children(&self) -> Vec<WidgetId> {
676        self.child_ids.clone()
677    }
678}
679
680impl ColumnFlow {
681    /// Publish the column count, guarded so the signal only fires on a real
682    /// change. The guard is what keeps a `Relayout`-bound consumer from
683    /// re-dirtying the tree on every pass.
684    fn publish_column_count(&self, columns: usize) {
685        if self.last_count.get() != columns {
686            self.last_count.set(columns);
687            self.column_count.set(columns);
688        }
689    }
690}
691
692/// Layout-transparent wrapper giving one `ColumnFlow` child its list-item
693/// accessibility identity. Mounted only under
694/// [`ColumnFlow::semantic_list`].
695///
696/// Mirrors `ListItemWrapper` in [`crate::list_item_a11y`], including its
697/// flatten-to-`Size` layout: `ColumnFlow` reads only `.size` off its children,
698/// so there is no grow/shrink weight for this wrapper to forward.
699#[derive(Debug)]
700struct ColumnFlowItem {
701    child: WidgetId,
702    /// 1-based.
703    position: usize,
704    total: usize,
705}
706
707impl ColumnFlowItem {
708    fn new(child: WidgetId, position_1based: usize, total: usize) -> Self {
709        Self {
710            child,
711            position: position_1based,
712            total,
713        }
714    }
715}
716
717impl Widget for ColumnFlowItem {
718    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
719        ctx.child_size(self.child, proposal)
720            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
721            .into()
722    }
723
724    fn place_children(
725        &self,
726        bounds: Rect,
727        _proposal: SizeProposal,
728        children: &mut [WidgetPlacement],
729        _ctx: &LayoutContext,
730    ) {
731        for child in children.iter_mut() {
732            child.origin = bounds.origin();
733            child.size = bounds.size();
734        }
735    }
736
737    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
738        builder.set_role(teksilo_core::accesskit::Role::ListItem);
739        builder.set_position_in_set(self.position);
740        builder.set_size_of_set(self.total);
741    }
742
743    fn children(&self) -> Vec<WidgetId> {
744        vec![self.child]
745    }
746}
747
748#[cfg(test)]
749mod tests {
750    use super::*;
751    use teksilo_core::widget_tree::WidgetTree;
752
753    // ── balance_columns ──────────────────────────────────────────────
754
755    /// Reconstruct each column's extent from a partition, so tests assert
756    /// against the geometry rather than the algorithm's own arithmetic.
757    fn column_extents(heights: &[f32], gap: f32, r: &BalanceResult) -> Vec<f32> {
758        let cols = r.column_of.iter().copied().max().map_or(0, |m| m + 1);
759        (0..cols)
760            .map(|c| {
761                let (mut count, mut sum) = (0usize, 0.0_f32);
762                for (i, &h) in heights.iter().enumerate() {
763                    if r.column_of[i] == c {
764                        count += 1;
765                        sum += h;
766                    }
767                }
768                run_extent(sum, count, gap)
769            })
770            .collect()
771    }
772
773    #[test]
774    fn uses_every_column_instead_of_stranding_a_trailing_one() {
775        // The empty-trailing-column regression. Naive min-max would pack
776        // [10,10] [10,10] [] — same tallest column, one column wasted.
777        let h = [10.0, 10.0, 10.0, 10.0];
778        let r = balance_columns(&h, 0.0, 3);
779        assert_eq!(r.column_of, vec![0, 0, 1, 2]);
780        assert_eq!(column_extents(&h, 0.0, &r), vec![20.0, 10.0, 10.0]);
781        assert!((r.height - 20.0).abs() < 0.01);
782    }
783
784    #[test]
785    fn evenly_divisible_input_splits_evenly() {
786        let h = [10.0; 9];
787        let r = balance_columns(&h, 0.0, 3);
788        assert_eq!(r.column_of, vec![0, 0, 0, 1, 1, 1, 2, 2, 2]);
789        assert!((r.height - 30.0).abs() < 0.01);
790    }
791
792    #[test]
793    fn single_column_extent_includes_every_gap() {
794        // 3 items of 10 with gap 5 in one column = 30 + 2*5 = 40. If the
795        // bisection's upper bound omitted the gaps it would cap at 30.
796        let h = [10.0, 10.0, 10.0];
797        let r = balance_columns(&h, 5.0, 1);
798        assert_eq!(r.column_of, vec![0, 0, 0]);
799        assert!((r.height - 40.0).abs() < 0.01, "height was {}", r.height);
800    }
801
802    #[test]
803    fn zero_height_items_still_pay_the_gap() {
804        // The count-not-accumulator regression: an `if accum > 0.0` gap test
805        // reports 0.0 here, because the running sum never leaves zero.
806        let h = [0.0, 0.0, 0.0, 0.0];
807        let r = balance_columns(&h, 8.0, 2);
808        assert!(
809            (r.height - 8.0).abs() < 0.01,
810            "two zero-height items in a column still span one gap, got {}",
811            r.height
812        );
813    }
814
815    #[test]
816    fn more_columns_than_items_does_not_panic() {
817        let h = [10.0, 20.0];
818        let r = balance_columns(&h, 0.0, 5);
819        assert_eq!(r.column_of, vec![0, 1], "clamped to one column per item");
820        assert!((r.height - 20.0).abs() < 0.01);
821    }
822
823    #[test]
824    fn empty_input_is_zero() {
825        let r = balance_columns(&[], 4.0, 3);
826        assert!(r.column_of.is_empty());
827        assert_eq!(r.height, 0.0);
828    }
829
830    #[test]
831    fn single_item() {
832        let r = balance_columns(&[50.0], 0.0, 3);
833        assert_eq!(r.column_of, vec![0]);
834        assert!((r.height - 50.0).abs() < 0.01);
835    }
836
837    #[test]
838    fn one_giant_item_sets_the_floor() {
839        // No column can be shorter than the tallest atomic child.
840        let h = [200.0, 10.0, 10.0, 10.0];
841        let r = balance_columns(&h, 0.0, 3);
842        assert!(r.height >= 200.0 - 0.01, "height was {}", r.height);
843        assert_eq!(r.column_of[0], 0);
844    }
845
846    #[test]
847    fn negative_gap_is_clamped() {
848        let h = [10.0, 10.0];
849        let r = balance_columns(&h, -100.0, 1);
850        assert!((r.height - 20.0).abs() < 0.01, "height was {}", r.height);
851    }
852
853    #[test]
854    fn partition_is_contiguous_and_ordered() {
855        // The a11y keystone: columns are runs, and run k+1 starts after run k.
856        let h = [10.0, 10.0, 10.0, 40.0, 10.0, 10.0];
857        let r = balance_columns(&h, 0.0, 2);
858        for w in r.column_of.windows(2) {
859            assert!(
860                w[1] >= w[0],
861                "column index must never go backwards: {:?}",
862                r.column_of
863            );
864        }
865    }
866
867    #[test]
868    fn reported_height_matches_reconstructed_columns() {
869        // Property-ish: the reported height must equal the tallest column as
870        // actually laid out, across a spread of shapes.
871        let cases: &[(&[f32], f32, usize)] = &[
872            (&[10.0, 10.0, 10.0, 10.0], 0.0, 3),
873            (
874                &[
875                    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,
876                ],
877                4.0,
878                5,
879            ),
880            (&[5.0, 100.0, 5.0], 2.0, 2),
881            (&[7.0; 13], 3.0, 4),
882            (&[0.0, 5.0, 0.0, 5.0], 1.0, 2),
883            (&[33.0, 12.0, 90.0, 4.0, 61.0, 8.0], 6.0, 3),
884        ];
885        for (h, gap, k) in cases {
886            let r = balance_columns(h, *gap, *k);
887            let extents = column_extents(h, *gap, &r);
888            let tallest = extents.iter().copied().fold(0.0_f32, f32::max);
889            assert!(
890                (r.height - tallest).abs() < 0.01,
891                "reported {} vs reconstructed {} for {:?} gap {} k {}",
892                r.height,
893                tallest,
894                h,
895                gap,
896                k
897            );
898            assert_eq!(h.len(), r.column_of.len());
899        }
900    }
901
902    #[test]
903    fn no_column_exceeds_the_reported_height() {
904        let h = [
905            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,
906        ];
907        let r = balance_columns(&h, 4.0, 5);
908        for (c, extent) in column_extents(&h, 4.0, &r).iter().enumerate() {
909            assert!(
910                *extent <= r.height + 0.01,
911                "column {c} extent {extent} exceeds reported {}",
912                r.height
913            );
914        }
915    }
916
917    #[test]
918    fn is_deterministic_across_repeated_calls() {
919        // layout_response and place_children each run the search from scratch;
920        // they must agree bit-for-bit.
921        let h = [33.0, 12.0, 90.0, 4.0, 61.0, 8.0, 17.0];
922        let a = balance_columns(&h, 6.0, 3);
923        let b = balance_columns(&h, 6.0, 3);
924        assert_eq!(a, b);
925    }
926
927    // ── widget ───────────────────────────────────────────────────────
928
929    #[derive(Debug)]
930    struct FixedLeaf(f32, f32);
931    impl Widget for FixedLeaf {
932        fn layout_response(&self, _p: SizeProposal, _c: &LayoutContext) -> LayoutResponse {
933            Size::new(self.0, self.1).into()
934        }
935    }
936
937    /// A leaf that carries real semantics, so the a11y walker keeps it.
938    /// `FixedLeaf` emits a bare `Role::Unknown` and is itself presentational —
939    /// it would be pruned right along with the container, which proves
940    /// nothing about promotion.
941    #[derive(Debug)]
942    struct LabeledLeaf(f32, f32, &'static str);
943    impl Widget for LabeledLeaf {
944        fn layout_response(&self, _p: SizeProposal, _c: &LayoutContext) -> LayoutResponse {
945            Size::new(self.0, self.1).into()
946        }
947        fn accessibility(&self, builder: &mut AccessNodeBuilder) {
948            builder.set_role(teksilo_core::accesskit::Role::Button);
949            builder.set_name(self.2);
950        }
951    }
952
953    /// Six 40 dp-tall children, `min_column_width` 100.
954    fn six_children(tree: &mut WidgetTree) -> (Vec<WidgetId>, WidgetId) {
955        let ids: Vec<_> = (0..6).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
956        let mut flow = ColumnFlow::new().min_column_width(100.0);
957        for &id in &ids {
958            flow = flow.add_child(id);
959        }
960        let flow_id = tree.add(flow);
961        (ids, flow_id)
962    }
963
964    #[test]
965    fn column_count_follows_width() {
966        let mut tree = WidgetTree::new();
967        let (ids, _) = six_children(&mut tree);
968
969        // 300 wide / min 100 -> 3 columns, 2 items each.
970        tree.layout(SizeProposal::exact(300.0, 400.0));
971        assert!((tree.bounds(ids[0]).x - 0.0).abs() < 0.01);
972        assert!((tree.bounds(ids[2]).x - 100.0).abs() < 0.01);
973        assert!((tree.bounds(ids[4]).x - 200.0).abs() < 0.01);
974    }
975
976    #[test]
977    fn losing_a_column_repartitions_every_child() {
978        let mut tree = WidgetTree::new();
979        let (ids, _) = six_children(&mut tree);
980
981        // 3 columns: [0,1] [2,3] [4,5]
982        tree.layout(SizeProposal::exact(300.0, 400.0));
983        assert!((tree.bounds(ids[2]).x - 100.0).abs() < 0.01);
984        assert!((tree.bounds(ids[3]).y - 40.0).abs() < 0.01);
985
986        // 2 columns: [0,1,2] [3,4,5] — child 2 moved back to column 0 and
987        // child 3 became the top of column 1. Every child was repartitioned.
988        tree.layout(SizeProposal::exact(200.0, 400.0));
989        assert!(
990            (tree.bounds(ids[2]).x - 0.0).abs() < 0.01,
991            "child 2 -> col 0"
992        );
993        assert!((tree.bounds(ids[2]).y - 80.0).abs() < 0.01);
994        assert!(
995            (tree.bounds(ids[3]).x - 100.0).abs() < 0.01,
996            "child 3 -> col 1"
997        );
998        assert!(
999            (tree.bounds(ids[3]).y - 0.0).abs() < 0.01,
1000            "child 3 tops col 1"
1001        );
1002
1003        // 1 column: everything stacks.
1004        tree.layout(SizeProposal::exact(100.0, 400.0));
1005        for (i, &id) in ids.iter().enumerate() {
1006            assert!((tree.bounds(id).x - 0.0).abs() < 0.01);
1007            assert!((tree.bounds(id).y - (i as f32 * 40.0)).abs() < 0.01);
1008        }
1009    }
1010
1011    #[test]
1012    fn reported_height_matches_placed_content() {
1013        // layout_response and place_children must agree — the container must
1014        // never report a height its own children overflow.
1015        let mut tree = WidgetTree::new();
1016        let heights = [30.0, 70.0, 20.0, 55.0, 45.0];
1017        let ids: Vec<_> = heights
1018            .iter()
1019            .map(|&h| tree.add(FixedLeaf(50.0, h)))
1020            .collect();
1021        let mut flow = ColumnFlow::new().min_column_width(100.0).item_spacing(8.0);
1022        for &id in &ids {
1023            flow = flow.add_child(id);
1024        }
1025        let flow_id = tree.add(flow);
1026
1027        for width in [100.0, 200.0, 300.0, 400.0, 500.0] {
1028            tree.layout(SizeProposal {
1029                width: Some(width),
1030                height: None,
1031            });
1032            let reported = tree.bounds(flow_id).height;
1033            let top = tree.bounds(flow_id).y;
1034            let deepest = ids
1035                .iter()
1036                .map(|&id| tree.bounds(id).bottom() - top)
1037                .fold(0.0_f32, f32::max);
1038            assert!(
1039                (reported - deepest).abs() < 0.01,
1040                "at width {width}: reported {reported}, content reaches {deepest}"
1041            );
1042        }
1043    }
1044
1045    #[test]
1046    fn children_receive_the_column_width() {
1047        let mut tree = WidgetTree::new();
1048        let (ids, _) = six_children(&mut tree);
1049        tree.layout(SizeProposal::exact(300.0, 400.0));
1050        // Placed at the column width (100), not their intrinsic 50.
1051        assert!((tree.bounds(ids[0]).width - 100.0).abs() < 0.01);
1052    }
1053
1054    #[test]
1055    fn column_spacing_applied() {
1056        let mut tree = WidgetTree::new();
1057        let ids: Vec<_> = (0..4).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
1058        let mut flow = ColumnFlow::new()
1059            .min_column_width(100.0)
1060            .column_spacing(10.0);
1061        for &id in &ids {
1062            flow = flow.add_child(id);
1063        }
1064        tree.add(flow);
1065        // floor((320 + 10) / (100 + 10)) = 3 columns; width (320 - 20)/3 = 100.
1066        tree.layout(SizeProposal::exact(320.0, 400.0));
1067        assert!((tree.bounds(ids[0]).x - 0.0).abs() < 0.01);
1068        assert!((tree.bounds(ids[2]).x - 110.0).abs() < 0.01);
1069        assert!((tree.bounds(ids[3]).x - 220.0).abs() < 0.01);
1070    }
1071
1072    #[test]
1073    fn item_spacing_applied() {
1074        let mut tree = WidgetTree::new();
1075        let ids: Vec<_> = (0..4).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
1076        let mut flow = ColumnFlow::new().min_column_width(100.0).item_spacing(8.0);
1077        for &id in &ids {
1078            flow = flow.add_child(id);
1079        }
1080        tree.add(flow);
1081        // 2 columns: [0,1] [2,3]; second item sits at 40 + 8.
1082        tree.layout(SizeProposal::exact(200.0, 400.0));
1083        assert!((tree.bounds(ids[1]).y - 48.0).abs() < 0.01);
1084        assert!((tree.bounds(ids[3]).y - 48.0).abs() < 0.01);
1085    }
1086
1087    #[test]
1088    fn max_columns_caps_the_count() {
1089        let mut tree = WidgetTree::new();
1090        let (ids, _) = {
1091            let ids: Vec<_> = (0..6).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
1092            let mut flow = ColumnFlow::new().min_column_width(100.0).max_columns(2);
1093            for &id in &ids {
1094                flow = flow.add_child(id);
1095            }
1096            let flow_id = tree.add(flow);
1097            (ids, flow_id)
1098        };
1099        // 600 wide would fit 6 columns, but max_columns pins it to 2.
1100        tree.layout(SizeProposal::exact(600.0, 400.0));
1101        assert!((tree.bounds(ids[0]).x - 0.0).abs() < 0.01);
1102        assert!((tree.bounds(ids[3]).x - 300.0).abs() < 0.01);
1103        assert!((tree.bounds(ids[5]).x - 300.0).abs() < 0.01);
1104    }
1105
1106    #[test]
1107    fn max_column_width_clamps_and_alignment_places_the_block() {
1108        let mut tree = WidgetTree::new();
1109        let ids: Vec<_> = (0..2).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
1110        let mut flow = ColumnFlow::new()
1111            .min_column_width(400.0)
1112            .max_column_width(300.0)
1113            .max_columns(2)
1114            .alignment(HAlignment::Center);
1115        for &id in &ids {
1116            flow = flow.add_child(id);
1117        }
1118        tree.add(flow);
1119        // 1000 wide: 2 columns, each clamped 500 -> 300. Used = 600,
1120        // leftover = 400, centred -> block starts at 200.
1121        tree.layout(SizeProposal::exact(1000.0, 400.0));
1122        assert!((tree.bounds(ids[0]).width - 300.0).abs() < 0.01);
1123        assert!(
1124            (tree.bounds(ids[0]).x - 200.0).abs() < 0.01,
1125            "centred block, got x = {}",
1126            tree.bounds(ids[0]).x
1127        );
1128        assert!((tree.bounds(ids[1]).x - 500.0).abs() < 0.01);
1129    }
1130
1131    #[test]
1132    fn unbounded_width_reports_one_column_by_default() {
1133        let mut tree = WidgetTree::new();
1134        let a = tree.add(FixedLeaf(80.0, 40.0));
1135        let b = tree.add(FixedLeaf(60.0, 30.0));
1136        let flow = tree.add(
1137            ColumnFlow::new()
1138                .min_column_width(50.0)
1139                .add_child(a)
1140                .add_child(b),
1141        );
1142        tree.layout(SizeProposal {
1143            width: None,
1144            height: Some(400.0),
1145        });
1146        // One column at the widest child (80). A size-to-content parent takes
1147        // this verbatim, so it must not balloon.
1148        assert!(
1149            (tree.bounds(flow).width - 80.0).abs() < 0.01,
1150            "got {}",
1151            tree.bounds(flow).width
1152        );
1153    }
1154
1155    #[test]
1156    fn unbounded_width_honours_max_columns() {
1157        let mut tree = WidgetTree::new();
1158        let a = tree.add(FixedLeaf(80.0, 40.0));
1159        let b = tree.add(FixedLeaf(60.0, 30.0));
1160        let flow = tree.add(
1161            ColumnFlow::new()
1162                .min_column_width(50.0)
1163                .max_columns(3)
1164                .column_spacing(10.0)
1165                .add_child(a)
1166                .add_child(b),
1167        );
1168        tree.layout(SizeProposal {
1169            width: None,
1170            height: Some(400.0),
1171        });
1172        // 3 columns of 80 + 2 gaps of 10 = 260.
1173        assert!(
1174            (tree.bounds(flow).width - 260.0).abs() < 0.01,
1175            "got {}",
1176            tree.bounds(flow).width
1177        );
1178    }
1179
1180    #[test]
1181    fn empty_flow_has_zero_height() {
1182        let mut tree = WidgetTree::new();
1183        let flow = tree.add(ColumnFlow::new());
1184        tree.layout(SizeProposal {
1185            width: Some(300.0),
1186            height: None,
1187        });
1188        assert!((tree.bounds(flow).height - 0.0).abs() < 0.01);
1189    }
1190
1191    #[test]
1192    fn dormant_child_excluded_and_partition_stays_stable() {
1193        let mut tree = WidgetTree::new();
1194        let ids: Vec<_> = (0..4).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
1195        let mut flow = ColumnFlow::new().min_column_width(100.0);
1196        for &id in &ids {
1197            flow = flow.add_child(id);
1198        }
1199        tree.add(flow);
1200        tree.layout(SizeProposal::exact(200.0, 400.0));
1201        // 2 columns: [0,1] [2,3]
1202        assert!((tree.bounds(ids[2]).x - 100.0).abs() < 0.01);
1203
1204        // Drop child 1: the live set is [0,2,3] -> [0,2] [3]
1205        tree.set_dormant(ids[1]);
1206        tree.layout(SizeProposal::exact(200.0, 400.0));
1207        assert!((tree.bounds(ids[0]).x - 0.0).abs() < 0.01);
1208        assert!(
1209            (tree.bounds(ids[2]).x - 0.0).abs() < 0.01,
1210            "child 2 -> col 0"
1211        );
1212        assert!((tree.bounds(ids[2]).y - 40.0).abs() < 0.01);
1213        assert!(
1214            (tree.bounds(ids[3]).x - 100.0).abs() < 0.01,
1215            "child 3 -> col 1"
1216        );
1217    }
1218
1219    #[test]
1220    fn rtl_mirrors_columns_without_touching_source_order() {
1221        let mut tree = WidgetTree::new();
1222        tree.set_layout_direction(teksilo_core::environment::LayoutDirection::RightToLeft);
1223        let (ids, flow) = six_children(&mut tree);
1224        tree.layout(SizeProposal::exact(300.0, 400.0));
1225
1226        // Logical column 0 sits at the trailing (right) edge under RTL.
1227        assert!((tree.bounds(ids[0]).x - 200.0).abs() < 0.01);
1228        assert!((tree.bounds(ids[2]).x - 100.0).abs() < 0.01);
1229        assert!((tree.bounds(ids[4]).x - 0.0).abs() < 0.01);
1230        // Mirroring is geometry only — children() order is untouched, so the
1231        // reading and focus order still follow the source.
1232        assert_eq!(tree.children(flow), ids);
1233    }
1234
1235    #[test]
1236    fn column_count_signal_fires_only_on_a_real_change() {
1237        let mut tree = WidgetTree::new();
1238        let ids: Vec<_> = (0..6).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
1239        let flow = ColumnFlow::new().min_column_width(100.0);
1240        let count = flow.column_count_signal();
1241        let mut f = flow;
1242        for &id in &ids {
1243            f = f.add_child(id);
1244        }
1245        tree.add(f);
1246
1247        let fires = std::rc::Rc::new(Cell::new(0usize));
1248        let seen = fires.clone();
1249        let _guard = count.observe(move |_| seen.set(seen.get() + 1));
1250
1251        tree.layout(SizeProposal::exact(300.0, 400.0));
1252        assert_eq!(count.get(), 3);
1253        let after_first = fires.get();
1254
1255        // Same width twice: no further notification.
1256        tree.layout(SizeProposal::exact(300.0, 400.0));
1257        assert_eq!(
1258            fires.get(),
1259            after_first,
1260            "re-layout at the same width is silent"
1261        );
1262
1263        // Crossing to 2 columns fires exactly once.
1264        tree.layout(SizeProposal::exact(200.0, 400.0));
1265        assert_eq!(count.get(), 2);
1266        assert_eq!(fires.get(), after_first + 1);
1267    }
1268
1269    // ── accessibility ────────────────────────────────────────────────
1270
1271    fn find_node(
1272        update: &teksilo_core::accesskit::TreeUpdate,
1273        id: WidgetId,
1274    ) -> Option<&teksilo_core::accesskit::Node> {
1275        let nid = teksilo_core::accessibility::widget_id_to_node_id(id);
1276        update
1277            .nodes
1278            .iter()
1279            .find(|(n, _)| *n == nid)
1280            .map(|(_, node)| node)
1281    }
1282
1283    fn nodes_with_role(
1284        update: &teksilo_core::accesskit::TreeUpdate,
1285        role: teksilo_core::accesskit::Role,
1286    ) -> Vec<&teksilo_core::accesskit::Node> {
1287        update
1288            .nodes
1289            .iter()
1290            .filter(|(_, n)| n.role() == role)
1291            .map(|(_, n)| n)
1292            .collect()
1293    }
1294
1295    #[test]
1296    fn default_container_is_pruned_and_children_promoted_in_source_order() {
1297        let mut tree = WidgetTree::new();
1298        let labels = ["one", "two", "three", "four"];
1299        let ids: Vec<_> = labels
1300            .iter()
1301            .map(|&l| tree.add(LabeledLeaf(50.0, 40.0, l)))
1302            .collect();
1303        let mut flow = ColumnFlow::new().min_column_width(100.0);
1304        for &id in &ids {
1305            flow = flow.add_child(id);
1306        }
1307        let flow_id = tree.add(flow);
1308        tree.layout(SizeProposal::exact(200.0, 400.0));
1309        let update = tree.sync_accessibility();
1310
1311        // A bare GenericContainer carries no semantics, so the walker drops it
1312        // and promotes the children — the correct result for a layout, which
1313        // contributes geometry rather than meaning.
1314        assert!(
1315            find_node(&update, flow_id).is_none(),
1316            "a property-free layout container must not reach assistive tech"
1317        );
1318        // The children survive; only the empty box went away.
1319        for &id in &ids {
1320            assert!(find_node(&update, id).is_some(), "child kept");
1321        }
1322
1323        // And they are read in source order, not visual column order — the
1324        // keystone invariant. At 2 columns the visual layout is
1325        // [one, two] [three, four]; the reading order is still one..four.
1326        // ColumnFlow was the tree root, so its children promote all the way to
1327        // the synthetic Window root.
1328        let root = update
1329            .nodes
1330            .iter()
1331            .find(|(n, _)| *n == teksilo_core::accessibility::root_node_id())
1332            .map(|(_, node)| node)
1333            .expect("window root node");
1334        let order: Vec<_> = root
1335            .children()
1336            .iter()
1337            .filter_map(|nid| {
1338                update
1339                    .nodes
1340                    .iter()
1341                    .find(|(n, _)| n == nid)
1342                    .and_then(|(_, n)| n.label())
1343            })
1344            .collect();
1345        assert_eq!(order, labels, "promoted children keep source order");
1346    }
1347
1348    #[test]
1349    fn semantic_list_emits_list_and_positioned_items() {
1350        let mut tree = WidgetTree::new();
1351        let ids: Vec<_> = (0..3).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
1352        let mut flow = ColumnFlow::new()
1353            .min_column_width(100.0)
1354            .semantic_list(true);
1355        for &id in &ids {
1356            flow = flow.add_child(id);
1357        }
1358        let flow_id = tree.add(flow);
1359        tree.layout(SizeProposal::exact(300.0, 400.0));
1360        let update = tree.sync_accessibility();
1361
1362        let list = find_node(&update, flow_id).expect("List node survives pruning");
1363        assert_eq!(list.role(), teksilo_core::accesskit::Role::List);
1364
1365        let items = nodes_with_role(&update, teksilo_core::accesskit::Role::ListItem);
1366        assert_eq!(items.len(), 3, "one ListItem per child");
1367        // Announced as "item N of 3", in source order.
1368        let mut seen: Vec<(usize, usize)> = items
1369            .iter()
1370            .map(|n| (n.position_in_set().unwrap(), n.size_of_set().unwrap()))
1371            .collect();
1372        seen.sort();
1373        assert_eq!(seen, vec![(1, 3), (2, 3), (3, 3)]);
1374    }
1375
1376    // ── paint ────────────────────────────────────────────────────────
1377
1378    /// Every x the column rule was drawn at, from a real render pass.
1379    /// `draw_line` lands in `decorations` or `cosmetic_lines` depending on the
1380    /// stroke space, so check both rather than assume.
1381    fn rule_xs(tree: &mut WidgetTree) -> Vec<f32> {
1382        let frame = tree.render();
1383        let mut xs: Vec<f32> = frame
1384            .cosmetic_lines
1385            .iter()
1386            .filter(|l| (l.from[0] - l.to[0]).abs() < 0.01) // vertical only
1387            .map(|l| l.from[0])
1388            .chain(
1389                frame
1390                    .decorations
1391                    .iter()
1392                    .filter(|d| d.rect[2] > 0.0 && d.rect[2] <= 2.0 && d.rect[3] > 10.0)
1393                    .map(|d| d.rect[0] + d.rect[2] / 2.0),
1394            )
1395            .collect();
1396        xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
1397        xs
1398    }
1399
1400    fn flow_with_rule(tree: &mut WidgetTree, rule: bool) -> WidgetId {
1401        let ids: Vec<_> = (0..6).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
1402        let mut flow = ColumnFlow::new().min_column_width(100.0);
1403        if rule {
1404            flow = flow.column_rule(1.0, teksilo_tokens::BorderRole::Divider);
1405        }
1406        for &id in &ids {
1407            flow = flow.add_child(id);
1408        }
1409        tree.add(flow)
1410    }
1411
1412    #[test]
1413    fn column_rule_paints_one_line_centred_in_each_gap() {
1414        let mut tree = WidgetTree::new();
1415        flow_with_rule(&mut tree, true);
1416        // 3 columns of 100 in 300, no spacing -> gaps centred at 100 and 200.
1417        tree.layout(SizeProposal::exact(300.0, 400.0));
1418        let xs = rule_xs(&mut tree);
1419        assert_eq!(xs.len(), 2, "columns - 1 rules, got {xs:?}");
1420        assert!((xs[0] - 100.0).abs() < 0.01, "got {xs:?}");
1421        assert!((xs[1] - 200.0).abs() < 0.01, "got {xs:?}");
1422    }
1423
1424    #[test]
1425    fn column_rule_follows_the_reflow() {
1426        let mut tree = WidgetTree::new();
1427        flow_with_rule(&mut tree, true);
1428        tree.layout(SizeProposal::exact(300.0, 400.0));
1429        assert_eq!(rule_xs(&mut tree).len(), 2, "3 columns -> 2 rules");
1430
1431        tree.layout(SizeProposal::exact(200.0, 400.0));
1432        assert_eq!(rule_xs(&mut tree).len(), 1, "2 columns -> 1 rule");
1433
1434        tree.layout(SizeProposal::exact(100.0, 400.0));
1435        assert!(
1436            rule_xs(&mut tree).is_empty(),
1437            "a single column has no gap to rule"
1438        );
1439    }
1440
1441    #[test]
1442    fn no_rule_paints_nothing() {
1443        let mut tree = WidgetTree::new();
1444        flow_with_rule(&mut tree, false);
1445        tree.layout(SizeProposal::exact(300.0, 400.0));
1446        assert!(
1447            rule_xs(&mut tree).is_empty(),
1448            "column_rule is opt-in; the default layout paints nothing"
1449        );
1450    }
1451
1452    #[test]
1453    fn column_rule_sits_in_the_gap_when_spacing_is_wide() {
1454        let mut tree = WidgetTree::new();
1455        let ids: Vec<_> = (0..4).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
1456        let mut flow = ColumnFlow::new()
1457            .min_column_width(100.0)
1458            .column_spacing(20.0)
1459            .column_rule(1.0, teksilo_tokens::BorderRole::Divider);
1460        for &id in &ids {
1461            flow = flow.add_child(id);
1462        }
1463        tree.add(flow);
1464        // floor((340 + 20) / 120) = 3 columns; width = (340 - 40)/3 = 100.
1465        // Gap 0 spans 100..120 -> rule at 110. Gap 1 spans 220..240 -> 230.
1466        tree.layout(SizeProposal::exact(340.0, 400.0));
1467        let xs = rule_xs(&mut tree);
1468        assert_eq!(xs.len(), 2, "got {xs:?}");
1469        assert!((xs[0] - 110.0).abs() < 0.01, "centred in gap 0, got {xs:?}");
1470        assert!((xs[1] - 230.0).abs() < 0.01, "centred in gap 1, got {xs:?}");
1471    }
1472
1473    #[test]
1474    fn semantic_list_wrapper_is_layout_transparent() {
1475        // The wrapper must not perturb geometry.
1476        let mut tree = WidgetTree::new();
1477        let ids: Vec<_> = (0..4).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
1478        let mut flow = ColumnFlow::new()
1479            .min_column_width(100.0)
1480            .semantic_list(true);
1481        for &id in &ids {
1482            flow = flow.add_child(id);
1483        }
1484        tree.add(flow);
1485        tree.layout(SizeProposal::exact(200.0, 400.0));
1486
1487        assert!((tree.bounds(ids[0]).x - 0.0).abs() < 0.01);
1488        assert!((tree.bounds(ids[0]).width - 100.0).abs() < 0.01);
1489        assert!((tree.bounds(ids[2]).x - 100.0).abs() < 0.01);
1490        assert!((tree.bounds(ids[1]).y - 40.0).abs() < 0.01);
1491    }
1492}
1493
1494/// Property-based tests for [`balance_columns`].
1495///
1496/// `balance_columns` is `pub(crate)`, so this suite lives inline rather than
1497/// in `tests/` (an integration test cannot see it). The module docs above
1498/// state a handful of unusually crisp, checkable guarantees: children are
1499/// distributed as **contiguous source-order runs** (the property that keeps
1500/// visual order == focus order == the a11y walk order — see the "Reading
1501/// order" section at the top of this file), the partition uses **exactly**
1502/// `k` columns whenever `n >= k` with **no column left empty**, the result is
1503/// **deterministic** across repeated calls (`layout_response` and
1504/// `place_children` each re-run the search from scratch with no persisted
1505/// state, so a disagreement between two calls would desynchronise measurement
1506/// from placement), and the balanced tallest column is never worse than a
1507/// naive same-count-per-column split (the oracle this bisection search
1508/// replaces).
1509///
1510/// `cargo-fuzz` needs nightly + libfuzzer-sys, which isn't assumed here;
1511/// proptest with 256–512 cases per property (override with
1512/// `PROPTEST_CASES=N`) gives the "never panics / never regresses on a weird
1513/// shape" coverage a fuzz corpus would, plus shrinking. See `mod tests` above
1514/// for the example-based regression coverage this suite deliberately does not
1515/// repeat (the empty-trailing-column bug, the zero-height-still-pays-the-gap
1516/// bug, etc.).
1517#[cfg(test)]
1518mod proptests {
1519    use super::*;
1520    use proptest::prelude::*;
1521
1522    // Zero and small heights are the specific edge case `columns_needed`
1523    // guards against (a run of zero-height items is still `n` items with
1524    // `n-1` gaps between them) — bias toward hitting them.
1525    fn arb_height() -> impl Strategy<Value = f32> {
1526        prop_oneof![Just(0.0_f32), 0.0f32..500.0_f32,]
1527    }
1528
1529    fn arb_heights() -> impl Strategy<Value = Vec<f32>> {
1530        prop::collection::vec(arb_height(), 0..24)
1531    }
1532
1533    // Zero, negative (clamped), and huge gaps are the documented edge cases;
1534    // a mid-range gap is the common case.
1535    fn arb_gap() -> impl Strategy<Value = f32> {
1536        prop_oneof![
1537            Just(0.0_f32),
1538            Just(-5.0_f32),
1539            0.0f32..50.0_f32,
1540            Just(10_000.0_f32),
1541        ]
1542    }
1543
1544    // A non-negative gap for properties that compare against an oracle
1545    // computed with the same, unclamped gap value.
1546    fn arb_nonneg_gap() -> impl Strategy<Value = f32> {
1547        prop_oneof![Just(0.0_f32), 0.0f32..50.0_f32, Just(5_000.0_f32),]
1548    }
1549
1550    // k == 0 and k > n are the documented degenerate cases; small k is the
1551    // common case.
1552    fn arb_k() -> impl Strategy<Value = usize> {
1553        prop_oneof![Just(0usize), 1usize..8usize,]
1554    }
1555
1556    /// Same-count-per-column split: assign `heights` to `k` columns as
1557    /// contiguous runs of near-equal *count*, ignoring the heights entirely.
1558    /// This is the textbook naive multi-column partition.
1559    ///
1560    /// Why this (rather than literally re-implementing the "greedy fill"
1561    /// mentioned in the module docs) is a sound comparison oracle:
1562    /// `columns_needed` is a monotone feasibility check (a higher limit
1563    /// never needs more columns), so bisecting it finds the smallest limit
1564    /// any contiguous partition into `k_eff` runs can achieve — i.e. the
1565    /// *true minimum* possible tallest-column extent over **every** valid
1566    /// `k_eff`-way contiguous partition, not just ones `balance_columns`
1567    /// happens to construct. (The reserve tweak in `balance_columns` only
1568    /// ever makes an earlier column take *fewer* items to keep every column
1569    /// non-empty — it can't push a column's extent past the bisected limit,
1570    /// since splitting a feasible run into two contiguous sub-runs can only
1571    /// keep or shrink each half's extent.) Given that, `balance_columns`'
1572    /// tallest column is, by construction, less than or equal to *any*
1573    /// specific `k`-way contiguous partition — the even-count split above,
1574    /// a hand-rolled greedy-fill-at-the-average, or anything else — so this
1575    /// oracle is valid regardless of which "naive" strategy is picked; the
1576    /// even-count split is simply the simplest one to implement correctly.
1577    fn naive_even_split_extents(heights: &[f32], gap: f32, k: usize) -> Vec<f32> {
1578        let n = heights.len();
1579        if n == 0 {
1580            return Vec::new();
1581        }
1582        let k_eff = k.min(n).max(1);
1583        let base = n / k_eff;
1584        let extra = n % k_eff;
1585        let mut extents = Vec::with_capacity(k_eff);
1586        let mut idx = 0usize;
1587        for col in 0..k_eff {
1588            let take = base + usize::from(col < extra);
1589            let slice = &heights[idx..idx + take];
1590            let sum: f32 = slice.iter().sum();
1591            extents.push(run_extent(sum, take, gap));
1592            idx += take;
1593        }
1594        extents
1595    }
1596
1597    // ── 1. partition is a set of contiguous, source-order runs ──
1598    proptest! {
1599        #[test]
1600        fn column_indices_never_decrease_across_the_source_order(
1601            heights in arb_heights(), gap in arb_gap(), k in arb_k(),
1602        ) {
1603            // column_of is non-decreasing in i, which is exactly what makes
1604            // each column's original indices a contiguous block, and
1605            // concatenating the columns in order reproduces 0..n exactly —
1606            // the property that keeps visual order == focus order == the
1607            // a11y walk order (see the "Reading order" module docs above).
1608            let r = balance_columns(&heights, gap, k);
1609            for w in r.column_of.windows(2) {
1610                prop_assert!(
1611                    w[1] >= w[0],
1612                    "column index went backwards in {:?}", r.column_of
1613                );
1614            }
1615        }
1616    }
1617
1618    // ── 2. exactly k columns are used whenever n >= k ──
1619    proptest! {
1620        #[test]
1621        fn uses_exactly_k_columns_when_there_are_enough_items(
1622            heights in arb_heights(), gap in arb_gap(), k in 1usize..8usize,
1623        ) {
1624            let n = heights.len();
1625            prop_assume!(n >= k);
1626            let r = balance_columns(&heights, gap, k);
1627            let used = r.column_of.iter().copied().max().map_or(0, |m| m + 1);
1628            prop_assert_eq!(
1629                used, k,
1630                "expected exactly {} columns for {} items, used {}", k, n, used
1631            );
1632        }
1633    }
1634
1635    // ── 3. no column is left empty when n >= k ──
1636    proptest! {
1637        #[test]
1638        fn no_column_is_empty_when_there_are_enough_items(
1639            heights in arb_heights(), gap in arb_gap(), k in 1usize..8usize,
1640        ) {
1641            let n = heights.len();
1642            prop_assume!(n >= k);
1643            let r = balance_columns(&heights, gap, k);
1644            for col in 0..k {
1645                prop_assert!(
1646                    r.column_of.contains(&col),
1647                    "column {} is empty in partition {:?}", col, r.column_of
1648                );
1649            }
1650        }
1651    }
1652
1653    // ── 4. balance never does worse than a naive even-count split ──
1654    proptest! {
1655        #![proptest_config(ProptestConfig { cases: 512, ..ProptestConfig::default() })]
1656        #[test]
1657        fn tallest_column_is_at_most_the_naive_even_split(
1658            heights in arb_heights(), gap in arb_nonneg_gap(), k in arb_k(),
1659        ) {
1660            let r = balance_columns(&heights, gap, k);
1661            let naive_tallest = naive_even_split_extents(&heights, gap, k)
1662                .into_iter()
1663                .fold(0.0_f32, f32::max);
1664            prop_assert!(
1665                r.height <= naive_tallest + 0.01,
1666                "balanced height {} exceeds naive even-split height {} for {:?} gap {} k {}",
1667                r.height, naive_tallest, heights, gap, k
1668            );
1669        }
1670    }
1671
1672    // ── 5. determinism across repeated calls ──
1673    proptest! {
1674        #[test]
1675        fn repeated_calls_on_the_same_input_agree_bit_for_bit(
1676            heights in arb_heights(), gap in arb_gap(), k in arb_k(),
1677        ) {
1678            // layout_response and place_children each run the bisection from
1679            // scratch; a disagreement here would desynchronise measured size
1680            // from placed geometry.
1681            let a = balance_columns(&heights, gap, k);
1682            let b = balance_columns(&heights, gap, k);
1683            prop_assert_eq!(
1684                &a, &b,
1685                "two calls with identical input ({:?}, gap {}, k {}) produced different partitions: {:?} vs {:?}",
1686                heights, gap, k, a, b
1687            );
1688        }
1689    }
1690
1691    // ── 6. reported height matches the reconstructed tallest column ──
1692    proptest! {
1693        #[test]
1694        fn reported_height_matches_the_reconstructed_tallest_column(
1695            heights in arb_heights(), gap in arb_gap(), k in arb_k(),
1696        ) {
1697            let r = balance_columns(&heights, gap, k);
1698            let cols = r.column_of.iter().copied().max().map_or(0, |m| m + 1);
1699            let mut sums = vec![0.0f32; cols];
1700            let mut counts = vec![0usize; cols];
1701            for (i, &h) in heights.iter().enumerate() {
1702                counts[r.column_of[i]] += 1;
1703                sums[r.column_of[i]] += h;
1704            }
1705            let clamped_gap = gap.max(0.0);
1706            let tallest = (0..cols)
1707                .map(|c| run_extent(sums[c], counts[c], clamped_gap))
1708                .fold(0.0_f32, f32::max);
1709            prop_assert!(
1710                (r.height - tallest).abs() < 0.05,
1711                "reported height {} disagrees with reconstructed tallest column {}",
1712                r.height, tallest
1713            );
1714        }
1715    }
1716
1717    // ── 7. no column ever exceeds the reported height ──
1718    proptest! {
1719        #[test]
1720        fn no_column_extent_exceeds_the_reported_height(
1721            heights in arb_heights(), gap in arb_gap(), k in arb_k(),
1722        ) {
1723            let r = balance_columns(&heights, gap, k);
1724            let cols = r.column_of.iter().copied().max().map_or(0, |m| m + 1);
1725            let mut sums = vec![0.0f32; cols];
1726            let mut counts = vec![0usize; cols];
1727            for (i, &h) in heights.iter().enumerate() {
1728                counts[r.column_of[i]] += 1;
1729                sums[r.column_of[i]] += h;
1730            }
1731            let clamped_gap = gap.max(0.0);
1732            for c in 0..cols {
1733                let extent = run_extent(sums[c], counts[c], clamped_gap);
1734                prop_assert!(
1735                    extent <= r.height + 0.05,
1736                    "column {} extent {} exceeds reported height {}", c, extent, r.height
1737                );
1738            }
1739        }
1740    }
1741
1742    // ── 8. never panics on degenerate shapes (n == 0, k == 0, k > n, huge gap) ──
1743    proptest! {
1744        #[test]
1745        fn never_panics_on_degenerate_input(
1746            heights in prop::collection::vec(arb_height(), 0..3),
1747            gap in prop_oneof![Just(0.0_f32), Just(-1.0_f32), Just(1.0e6_f32)],
1748            k in prop_oneof![Just(0usize), Just(1usize), Just(100usize)],
1749        ) {
1750            let n = heights.len();
1751            let r = balance_columns(&heights, gap, k);
1752            prop_assert_eq!(
1753                r.column_of.len(), n,
1754                "every child must be assigned a column: heights {:?} gap {} k {} -> {:?}",
1755                heights, gap, k, r.column_of
1756            );
1757            // Every assigned column index must be a valid index into the
1758            // partition (`k_eff = k.min(n).max(1) <= n` whenever n >= 1), even
1759            // when k wildly overshoots n (k = 100 against at most 2 items).
1760            prop_assert!(
1761                r.column_of.iter().all(|&c| c < n.max(1)),
1762                "out-of-range column index in {:?} for {} items (gap {} k {})",
1763                r.column_of, n, gap, k
1764            );
1765            prop_assert!(
1766                r.height.is_finite() && r.height >= 0.0,
1767                "height {} is not a finite, non-negative number for heights {:?} gap {} k {}",
1768                r.height, heights, gap, k
1769            );
1770        }
1771    }
1772}