1use 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
78const DEFAULT_MIN_COLUMN_WIDTH: f32 = 240.0;
81
82const BISECTION_STEPS: u32 = 48;
96
97#[derive(Debug, Clone, PartialEq)]
99pub(crate) struct BalanceResult {
100 pub height: f32,
102 pub column_of: Vec<usize>,
104}
105
106#[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
117fn 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
142pub(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 let k_eff = k.min(n).max(1);
169
170 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 let limit = hi;
186
187 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 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
238pub 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 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 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 pub fn min_column_width(mut self, width: f32) -> Self {
310 self.min_column_width = width;
311 self
312 }
313
314 pub fn max_column_width(mut self, width: f32) -> Self {
323 self.max_column_width = Some(width);
324 self
325 }
326
327 pub fn max_columns(mut self, max: usize) -> Self {
334 self.max_columns = Some(max.max(1));
335 self
336 }
337
338 pub fn column_spacing(mut self, spacing: impl Into<Prop<f32>>) -> Self {
340 self.column_spacing = spacing.into();
341 self
342 }
343
344 pub fn item_spacing(mut self, spacing: impl Into<Prop<f32>>) -> Self {
350 self.item_spacing = spacing.into();
351 self
352 }
353
354 pub fn alignment(mut self, alignment: HAlignment) -> Self {
361 self.alignment = alignment;
362 self
363 }
364
365 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 pub fn semantic_list(mut self, enabled: bool) -> Self {
387 self.semantic_list = enabled;
388 self
389 }
390
391 pub fn add_child(mut self, id: WidgetId) -> Self {
393 self.pending.push(PendingChild::Id(id));
394 self
395 }
396
397 pub fn child(mut self, widget: impl Widget + 'static) -> Self {
399 self.pending.push(PendingChild::Deferred(Box::new(widget)));
400 self
401 }
402
403 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 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 pub fn column_count_signal(&self) -> Signal<usize> {
436 self.column_count.clone()
437 }
438
439 fn width_policy(&self) -> WidthPolicy {
441 WidthPolicy::Adaptive {
442 min: self.min_column_width,
443 max: self.max_column_width,
444 }
445 }
446
447 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 fn column_count_at(&self, width: f32, col_spacing: f32) -> usize {
461 self.geometry(col_spacing).column_count(width)
462 }
463
464 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 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 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 w,
559 self.column_count_at(w, col_spacing),
560 self.geometry(col_spacing).column_width(w),
561 ),
562 None => {
563 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 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 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 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 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 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 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#[derive(Debug)]
700struct ColumnFlowItem {
701 child: WidgetId,
702 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 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 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 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 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 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 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 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 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 #[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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert!((tree.bounds(ids[2]).x - 100.0).abs() < 0.01);
1203
1204 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 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 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 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 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 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 assert!(
1315 find_node(&update, flow_id).is_none(),
1316 "a property-free layout container must not reach assistive tech"
1317 );
1318 for &id in &ids {
1320 assert!(find_node(&update, id).is_some(), "child kept");
1321 }
1322
1323 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 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 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) .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 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 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 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#[cfg(test)]
1518mod proptests {
1519 use super::*;
1520 use proptest::prelude::*;
1521
1522 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 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 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 fn arb_k() -> impl Strategy<Value = usize> {
1553 prop_oneof![Just(0usize), 1usize..8usize,]
1554 }
1555
1556 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 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 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 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 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 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 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 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 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 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 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 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}