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 if !self.child_ids.is_empty() {
671 builder.set_size_of_set(self.child_ids.len());
672 }
673 } else {
674 builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
679 }
680 }
681
682 fn children(&self) -> Vec<WidgetId> {
683 self.child_ids.clone()
684 }
685}
686
687impl ColumnFlow {
688 fn publish_column_count(&self, columns: usize) {
692 if self.last_count.get() != columns {
693 self.last_count.set(columns);
694 self.column_count.set(columns);
695 }
696 }
697}
698
699#[derive(Debug)]
707struct ColumnFlowItem {
708 child: WidgetId,
709 position: usize,
711 total: usize,
712}
713
714impl ColumnFlowItem {
715 fn new(child: WidgetId, position_1based: usize, total: usize) -> Self {
716 Self {
717 child,
718 position: position_1based,
719 total,
720 }
721 }
722}
723
724impl Widget for ColumnFlowItem {
725 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
726 ctx.child_size(self.child, proposal)
727 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
728 .into()
729 }
730
731 fn place_children(
732 &self,
733 bounds: Rect,
734 _proposal: SizeProposal,
735 children: &mut [WidgetPlacement],
736 _ctx: &LayoutContext,
737 ) {
738 for child in children.iter_mut() {
739 child.origin = bounds.origin();
740 child.size = bounds.size();
741 }
742 }
743
744 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
745 builder.set_role(teksilo_core::accesskit::Role::ListItem);
746 builder.set_position_in_set(self.position);
747 }
749
750 fn children(&self) -> Vec<WidgetId> {
751 vec![self.child]
752 }
753}
754
755#[cfg(test)]
756mod tests {
757 use super::*;
758 use teksilo_core::widget_tree::WidgetTree;
759
760 fn column_extents(heights: &[f32], gap: f32, r: &BalanceResult) -> Vec<f32> {
765 let cols = r.column_of.iter().copied().max().map_or(0, |m| m + 1);
766 (0..cols)
767 .map(|c| {
768 let (mut count, mut sum) = (0usize, 0.0_f32);
769 for (i, &h) in heights.iter().enumerate() {
770 if r.column_of[i] == c {
771 count += 1;
772 sum += h;
773 }
774 }
775 run_extent(sum, count, gap)
776 })
777 .collect()
778 }
779
780 #[test]
781 fn uses_every_column_instead_of_stranding_a_trailing_one() {
782 let h = [10.0, 10.0, 10.0, 10.0];
785 let r = balance_columns(&h, 0.0, 3);
786 assert_eq!(r.column_of, vec![0, 0, 1, 2]);
787 assert_eq!(column_extents(&h, 0.0, &r), vec![20.0, 10.0, 10.0]);
788 assert!((r.height - 20.0).abs() < 0.01);
789 }
790
791 #[test]
792 fn evenly_divisible_input_splits_evenly() {
793 let h = [10.0; 9];
794 let r = balance_columns(&h, 0.0, 3);
795 assert_eq!(r.column_of, vec![0, 0, 0, 1, 1, 1, 2, 2, 2]);
796 assert!((r.height - 30.0).abs() < 0.01);
797 }
798
799 #[test]
800 fn single_column_extent_includes_every_gap() {
801 let h = [10.0, 10.0, 10.0];
804 let r = balance_columns(&h, 5.0, 1);
805 assert_eq!(r.column_of, vec![0, 0, 0]);
806 assert!((r.height - 40.0).abs() < 0.01, "height was {}", r.height);
807 }
808
809 #[test]
810 fn zero_height_items_still_pay_the_gap() {
811 let h = [0.0, 0.0, 0.0, 0.0];
814 let r = balance_columns(&h, 8.0, 2);
815 assert!(
816 (r.height - 8.0).abs() < 0.01,
817 "two zero-height items in a column still span one gap, got {}",
818 r.height
819 );
820 }
821
822 #[test]
823 fn more_columns_than_items_does_not_panic() {
824 let h = [10.0, 20.0];
825 let r = balance_columns(&h, 0.0, 5);
826 assert_eq!(r.column_of, vec![0, 1], "clamped to one column per item");
827 assert!((r.height - 20.0).abs() < 0.01);
828 }
829
830 #[test]
831 fn empty_input_is_zero() {
832 let r = balance_columns(&[], 4.0, 3);
833 assert!(r.column_of.is_empty());
834 assert_eq!(r.height, 0.0);
835 }
836
837 #[test]
838 fn single_item() {
839 let r = balance_columns(&[50.0], 0.0, 3);
840 assert_eq!(r.column_of, vec![0]);
841 assert!((r.height - 50.0).abs() < 0.01);
842 }
843
844 #[test]
845 fn one_giant_item_sets_the_floor() {
846 let h = [200.0, 10.0, 10.0, 10.0];
848 let r = balance_columns(&h, 0.0, 3);
849 assert!(r.height >= 200.0 - 0.01, "height was {}", r.height);
850 assert_eq!(r.column_of[0], 0);
851 }
852
853 #[test]
854 fn negative_gap_is_clamped() {
855 let h = [10.0, 10.0];
856 let r = balance_columns(&h, -100.0, 1);
857 assert!((r.height - 20.0).abs() < 0.01, "height was {}", r.height);
858 }
859
860 #[test]
861 fn partition_is_contiguous_and_ordered() {
862 let h = [10.0, 10.0, 10.0, 40.0, 10.0, 10.0];
864 let r = balance_columns(&h, 0.0, 2);
865 for w in r.column_of.windows(2) {
866 assert!(
867 w[1] >= w[0],
868 "column index must never go backwards: {:?}",
869 r.column_of
870 );
871 }
872 }
873
874 #[test]
875 fn reported_height_matches_reconstructed_columns() {
876 let cases: &[(&[f32], f32, usize)] = &[
879 (&[10.0, 10.0, 10.0, 10.0], 0.0, 3),
880 (
881 &[
882 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 40.0, 1.0, 1.0,
883 ],
884 4.0,
885 5,
886 ),
887 (&[5.0, 100.0, 5.0], 2.0, 2),
888 (&[7.0; 13], 3.0, 4),
889 (&[0.0, 5.0, 0.0, 5.0], 1.0, 2),
890 (&[33.0, 12.0, 90.0, 4.0, 61.0, 8.0], 6.0, 3),
891 ];
892 for (h, gap, k) in cases {
893 let r = balance_columns(h, *gap, *k);
894 let extents = column_extents(h, *gap, &r);
895 let tallest = extents.iter().copied().fold(0.0_f32, f32::max);
896 assert!(
897 (r.height - tallest).abs() < 0.01,
898 "reported {} vs reconstructed {} for {:?} gap {} k {}",
899 r.height,
900 tallest,
901 h,
902 gap,
903 k
904 );
905 assert_eq!(h.len(), r.column_of.len());
906 }
907 }
908
909 #[test]
910 fn no_column_exceeds_the_reported_height() {
911 let h = [
912 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 40.0, 1.0, 1.0,
913 ];
914 let r = balance_columns(&h, 4.0, 5);
915 for (c, extent) in column_extents(&h, 4.0, &r).iter().enumerate() {
916 assert!(
917 *extent <= r.height + 0.01,
918 "column {c} extent {extent} exceeds reported {}",
919 r.height
920 );
921 }
922 }
923
924 #[test]
925 fn is_deterministic_across_repeated_calls() {
926 let h = [33.0, 12.0, 90.0, 4.0, 61.0, 8.0, 17.0];
929 let a = balance_columns(&h, 6.0, 3);
930 let b = balance_columns(&h, 6.0, 3);
931 assert_eq!(a, b);
932 }
933
934 #[derive(Debug)]
937 struct FixedLeaf(f32, f32);
938 impl Widget for FixedLeaf {
939 fn layout_response(&self, _p: SizeProposal, _c: &LayoutContext) -> LayoutResponse {
940 Size::new(self.0, self.1).into()
941 }
942 }
943
944 #[derive(Debug)]
949 struct LabeledLeaf(f32, f32, &'static str);
950 impl Widget for LabeledLeaf {
951 fn layout_response(&self, _p: SizeProposal, _c: &LayoutContext) -> LayoutResponse {
952 Size::new(self.0, self.1).into()
953 }
954 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
955 builder.set_role(teksilo_core::accesskit::Role::Button);
956 builder.set_name(self.2);
957 }
958 }
959
960 fn six_children(tree: &mut WidgetTree) -> (Vec<WidgetId>, WidgetId) {
962 let ids: Vec<_> = (0..6).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
963 let mut flow = ColumnFlow::new().min_column_width(100.0);
964 for &id in &ids {
965 flow = flow.add_child(id);
966 }
967 let flow_id = tree.add(flow);
968 (ids, flow_id)
969 }
970
971 #[test]
972 fn column_count_follows_width() {
973 let mut tree = WidgetTree::new();
974 let (ids, _) = six_children(&mut tree);
975
976 tree.layout(SizeProposal::exact(300.0, 400.0));
978 assert!((tree.bounds(ids[0]).x - 0.0).abs() < 0.01);
979 assert!((tree.bounds(ids[2]).x - 100.0).abs() < 0.01);
980 assert!((tree.bounds(ids[4]).x - 200.0).abs() < 0.01);
981 }
982
983 #[test]
984 fn losing_a_column_repartitions_every_child() {
985 let mut tree = WidgetTree::new();
986 let (ids, _) = six_children(&mut tree);
987
988 tree.layout(SizeProposal::exact(300.0, 400.0));
990 assert!((tree.bounds(ids[2]).x - 100.0).abs() < 0.01);
991 assert!((tree.bounds(ids[3]).y - 40.0).abs() < 0.01);
992
993 tree.layout(SizeProposal::exact(200.0, 400.0));
996 assert!(
997 (tree.bounds(ids[2]).x - 0.0).abs() < 0.01,
998 "child 2 -> col 0"
999 );
1000 assert!((tree.bounds(ids[2]).y - 80.0).abs() < 0.01);
1001 assert!(
1002 (tree.bounds(ids[3]).x - 100.0).abs() < 0.01,
1003 "child 3 -> col 1"
1004 );
1005 assert!(
1006 (tree.bounds(ids[3]).y - 0.0).abs() < 0.01,
1007 "child 3 tops col 1"
1008 );
1009
1010 tree.layout(SizeProposal::exact(100.0, 400.0));
1012 for (i, &id) in ids.iter().enumerate() {
1013 assert!((tree.bounds(id).x - 0.0).abs() < 0.01);
1014 assert!((tree.bounds(id).y - (i as f32 * 40.0)).abs() < 0.01);
1015 }
1016 }
1017
1018 #[test]
1019 fn reported_height_matches_placed_content() {
1020 let mut tree = WidgetTree::new();
1023 let heights = [30.0, 70.0, 20.0, 55.0, 45.0];
1024 let ids: Vec<_> = heights
1025 .iter()
1026 .map(|&h| tree.add(FixedLeaf(50.0, h)))
1027 .collect();
1028 let mut flow = ColumnFlow::new().min_column_width(100.0).item_spacing(8.0);
1029 for &id in &ids {
1030 flow = flow.add_child(id);
1031 }
1032 let flow_id = tree.add(flow);
1033
1034 for width in [100.0, 200.0, 300.0, 400.0, 500.0] {
1035 tree.layout(SizeProposal {
1036 width: Some(width),
1037 height: None,
1038 });
1039 let reported = tree.bounds(flow_id).height;
1040 let top = tree.bounds(flow_id).y;
1041 let deepest = ids
1042 .iter()
1043 .map(|&id| tree.bounds(id).bottom() - top)
1044 .fold(0.0_f32, f32::max);
1045 assert!(
1046 (reported - deepest).abs() < 0.01,
1047 "at width {width}: reported {reported}, content reaches {deepest}"
1048 );
1049 }
1050 }
1051
1052 #[test]
1053 fn children_receive_the_column_width() {
1054 let mut tree = WidgetTree::new();
1055 let (ids, _) = six_children(&mut tree);
1056 tree.layout(SizeProposal::exact(300.0, 400.0));
1057 assert!((tree.bounds(ids[0]).width - 100.0).abs() < 0.01);
1059 }
1060
1061 #[test]
1062 fn column_spacing_applied() {
1063 let mut tree = WidgetTree::new();
1064 let ids: Vec<_> = (0..4).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
1065 let mut flow = ColumnFlow::new()
1066 .min_column_width(100.0)
1067 .column_spacing(10.0);
1068 for &id in &ids {
1069 flow = flow.add_child(id);
1070 }
1071 tree.add(flow);
1072 tree.layout(SizeProposal::exact(320.0, 400.0));
1074 assert!((tree.bounds(ids[0]).x - 0.0).abs() < 0.01);
1075 assert!((tree.bounds(ids[2]).x - 110.0).abs() < 0.01);
1076 assert!((tree.bounds(ids[3]).x - 220.0).abs() < 0.01);
1077 }
1078
1079 #[test]
1080 fn item_spacing_applied() {
1081 let mut tree = WidgetTree::new();
1082 let ids: Vec<_> = (0..4).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
1083 let mut flow = ColumnFlow::new().min_column_width(100.0).item_spacing(8.0);
1084 for &id in &ids {
1085 flow = flow.add_child(id);
1086 }
1087 tree.add(flow);
1088 tree.layout(SizeProposal::exact(200.0, 400.0));
1090 assert!((tree.bounds(ids[1]).y - 48.0).abs() < 0.01);
1091 assert!((tree.bounds(ids[3]).y - 48.0).abs() < 0.01);
1092 }
1093
1094 #[test]
1095 fn max_columns_caps_the_count() {
1096 let mut tree = WidgetTree::new();
1097 let (ids, _) = {
1098 let ids: Vec<_> = (0..6).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
1099 let mut flow = ColumnFlow::new().min_column_width(100.0).max_columns(2);
1100 for &id in &ids {
1101 flow = flow.add_child(id);
1102 }
1103 let flow_id = tree.add(flow);
1104 (ids, flow_id)
1105 };
1106 tree.layout(SizeProposal::exact(600.0, 400.0));
1108 assert!((tree.bounds(ids[0]).x - 0.0).abs() < 0.01);
1109 assert!((tree.bounds(ids[3]).x - 300.0).abs() < 0.01);
1110 assert!((tree.bounds(ids[5]).x - 300.0).abs() < 0.01);
1111 }
1112
1113 #[test]
1114 fn max_column_width_clamps_and_alignment_places_the_block() {
1115 let mut tree = WidgetTree::new();
1116 let ids: Vec<_> = (0..2).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
1117 let mut flow = ColumnFlow::new()
1118 .min_column_width(400.0)
1119 .max_column_width(300.0)
1120 .max_columns(2)
1121 .alignment(HAlignment::Center);
1122 for &id in &ids {
1123 flow = flow.add_child(id);
1124 }
1125 tree.add(flow);
1126 tree.layout(SizeProposal::exact(1000.0, 400.0));
1129 assert!((tree.bounds(ids[0]).width - 300.0).abs() < 0.01);
1130 assert!(
1131 (tree.bounds(ids[0]).x - 200.0).abs() < 0.01,
1132 "centred block, got x = {}",
1133 tree.bounds(ids[0]).x
1134 );
1135 assert!((tree.bounds(ids[1]).x - 500.0).abs() < 0.01);
1136 }
1137
1138 #[test]
1139 fn unbounded_width_reports_one_column_by_default() {
1140 let mut tree = WidgetTree::new();
1141 let a = tree.add(FixedLeaf(80.0, 40.0));
1142 let b = tree.add(FixedLeaf(60.0, 30.0));
1143 let flow = tree.add(
1144 ColumnFlow::new()
1145 .min_column_width(50.0)
1146 .add_child(a)
1147 .add_child(b),
1148 );
1149 tree.layout(SizeProposal {
1150 width: None,
1151 height: Some(400.0),
1152 });
1153 assert!(
1156 (tree.bounds(flow).width - 80.0).abs() < 0.01,
1157 "got {}",
1158 tree.bounds(flow).width
1159 );
1160 }
1161
1162 #[test]
1163 fn unbounded_width_honours_max_columns() {
1164 let mut tree = WidgetTree::new();
1165 let a = tree.add(FixedLeaf(80.0, 40.0));
1166 let b = tree.add(FixedLeaf(60.0, 30.0));
1167 let flow = tree.add(
1168 ColumnFlow::new()
1169 .min_column_width(50.0)
1170 .max_columns(3)
1171 .column_spacing(10.0)
1172 .add_child(a)
1173 .add_child(b),
1174 );
1175 tree.layout(SizeProposal {
1176 width: None,
1177 height: Some(400.0),
1178 });
1179 assert!(
1181 (tree.bounds(flow).width - 260.0).abs() < 0.01,
1182 "got {}",
1183 tree.bounds(flow).width
1184 );
1185 }
1186
1187 #[test]
1188 fn empty_flow_has_zero_height() {
1189 let mut tree = WidgetTree::new();
1190 let flow = tree.add(ColumnFlow::new());
1191 tree.layout(SizeProposal {
1192 width: Some(300.0),
1193 height: None,
1194 });
1195 assert!((tree.bounds(flow).height - 0.0).abs() < 0.01);
1196 }
1197
1198 #[test]
1199 fn dormant_child_excluded_and_partition_stays_stable() {
1200 let mut tree = WidgetTree::new();
1201 let ids: Vec<_> = (0..4).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
1202 let mut flow = ColumnFlow::new().min_column_width(100.0);
1203 for &id in &ids {
1204 flow = flow.add_child(id);
1205 }
1206 tree.add(flow);
1207 tree.layout(SizeProposal::exact(200.0, 400.0));
1208 assert!((tree.bounds(ids[2]).x - 100.0).abs() < 0.01);
1210
1211 tree.set_dormant(ids[1]);
1213 tree.layout(SizeProposal::exact(200.0, 400.0));
1214 assert!((tree.bounds(ids[0]).x - 0.0).abs() < 0.01);
1215 assert!(
1216 (tree.bounds(ids[2]).x - 0.0).abs() < 0.01,
1217 "child 2 -> col 0"
1218 );
1219 assert!((tree.bounds(ids[2]).y - 40.0).abs() < 0.01);
1220 assert!(
1221 (tree.bounds(ids[3]).x - 100.0).abs() < 0.01,
1222 "child 3 -> col 1"
1223 );
1224 }
1225
1226 #[test]
1227 fn rtl_mirrors_columns_without_touching_source_order() {
1228 let mut tree = WidgetTree::new();
1229 tree.set_layout_direction(teksilo_core::environment::LayoutDirection::RightToLeft);
1230 let (ids, flow) = six_children(&mut tree);
1231 tree.layout(SizeProposal::exact(300.0, 400.0));
1232
1233 assert!((tree.bounds(ids[0]).x - 200.0).abs() < 0.01);
1235 assert!((tree.bounds(ids[2]).x - 100.0).abs() < 0.01);
1236 assert!((tree.bounds(ids[4]).x - 0.0).abs() < 0.01);
1237 assert_eq!(tree.children(flow), ids);
1240 }
1241
1242 #[test]
1243 fn column_count_signal_fires_only_on_a_real_change() {
1244 let mut tree = WidgetTree::new();
1245 let ids: Vec<_> = (0..6).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
1246 let flow = ColumnFlow::new().min_column_width(100.0);
1247 let count = flow.column_count_signal();
1248 let mut f = flow;
1249 for &id in &ids {
1250 f = f.add_child(id);
1251 }
1252 tree.add(f);
1253
1254 let fires = std::rc::Rc::new(Cell::new(0usize));
1255 let seen = fires.clone();
1256 let _guard = count.observe(move |_| seen.set(seen.get() + 1));
1257
1258 tree.layout(SizeProposal::exact(300.0, 400.0));
1259 assert_eq!(count.get(), 3);
1260 let after_first = fires.get();
1261
1262 tree.layout(SizeProposal::exact(300.0, 400.0));
1264 assert_eq!(
1265 fires.get(),
1266 after_first,
1267 "re-layout at the same width is silent"
1268 );
1269
1270 tree.layout(SizeProposal::exact(200.0, 400.0));
1272 assert_eq!(count.get(), 2);
1273 assert_eq!(fires.get(), after_first + 1);
1274 }
1275
1276 fn find_node(
1279 update: &teksilo_core::accesskit::TreeUpdate,
1280 id: WidgetId,
1281 ) -> Option<&teksilo_core::accesskit::Node> {
1282 let nid = teksilo_core::accessibility::widget_id_to_node_id(id);
1283 update
1284 .nodes
1285 .iter()
1286 .find(|(n, _)| *n == nid)
1287 .map(|(_, node)| node)
1288 }
1289
1290 fn nodes_with_role_ids(
1291 update: &teksilo_core::accesskit::TreeUpdate,
1292 role: teksilo_core::accesskit::Role,
1293 ) -> Vec<teksilo_core::accesskit::NodeId> {
1294 update
1295 .nodes
1296 .iter()
1297 .filter(|(_, n)| n.role() == role)
1298 .map(|(id, _)| *id)
1299 .collect()
1300 }
1301
1302 fn nodes_with_role(
1303 update: &teksilo_core::accesskit::TreeUpdate,
1304 role: teksilo_core::accesskit::Role,
1305 ) -> Vec<&teksilo_core::accesskit::Node> {
1306 update
1307 .nodes
1308 .iter()
1309 .filter(|(_, n)| n.role() == role)
1310 .map(|(_, n)| n)
1311 .collect()
1312 }
1313
1314 #[test]
1315 fn default_container_is_pruned_and_children_promoted_in_source_order() {
1316 let mut tree = WidgetTree::new();
1317 let labels = ["one", "two", "three", "four"];
1318 let ids: Vec<_> = labels
1319 .iter()
1320 .map(|&l| tree.add(LabeledLeaf(50.0, 40.0, l)))
1321 .collect();
1322 let mut flow = ColumnFlow::new().min_column_width(100.0);
1323 for &id in &ids {
1324 flow = flow.add_child(id);
1325 }
1326 let flow_id = tree.add(flow);
1327 tree.layout(SizeProposal::exact(200.0, 400.0));
1328 let update = tree.sync_accessibility();
1329
1330 assert!(
1334 find_node(&update, flow_id).is_none(),
1335 "a property-free layout container must not reach assistive tech"
1336 );
1337 for &id in &ids {
1339 assert!(find_node(&update, id).is_some(), "child kept");
1340 }
1341
1342 let root = update
1348 .nodes
1349 .iter()
1350 .find(|(n, _)| *n == teksilo_core::accessibility::root_node_id())
1351 .map(|(_, node)| node)
1352 .expect("window root node");
1353 let order: Vec<_> = root
1354 .children()
1355 .iter()
1356 .filter_map(|nid| {
1357 update
1358 .nodes
1359 .iter()
1360 .find(|(n, _)| n == nid)
1361 .and_then(|(_, n)| n.label())
1362 })
1363 .collect();
1364 assert_eq!(order, labels, "promoted children keep source order");
1365 }
1366
1367 #[test]
1368 fn semantic_list_emits_list_and_positioned_items() {
1369 let mut tree = WidgetTree::new();
1370 let ids: Vec<_> = (0..3).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
1371 let mut flow = ColumnFlow::new()
1372 .min_column_width(100.0)
1373 .semantic_list(true);
1374 for &id in &ids {
1375 flow = flow.add_child(id);
1376 }
1377 let flow_id = tree.add(flow);
1378 tree.layout(SizeProposal::exact(300.0, 400.0));
1379 let update = tree.sync_accessibility();
1380
1381 let list = find_node(&update, flow_id).expect("List node survives pruning");
1382 assert_eq!(list.role(), teksilo_core::accesskit::Role::List);
1383
1384 let item_ids = nodes_with_role_ids(&update, teksilo_core::accesskit::Role::ListItem);
1385 assert_eq!(item_ids.len(), 3, "one ListItem per child");
1386 let mut seen: Vec<(Option<usize>, Option<usize>)> = item_ids
1390 .iter()
1391 .map(|id| crate::a11y_set_semantics::announced_set_position(&update, *id))
1392 .collect();
1393 seen.sort();
1394 assert_eq!(
1395 seen,
1396 vec![(Some(1), Some(3)), (Some(2), Some(3)), (Some(3), Some(3))]
1397 );
1398 }
1399
1400 fn rule_xs(tree: &mut WidgetTree) -> Vec<f32> {
1406 let frame = tree.render();
1407 let mut xs: Vec<f32> = frame
1408 .cosmetic_lines
1409 .iter()
1410 .filter(|l| (l.from[0] - l.to[0]).abs() < 0.01) .map(|l| l.from[0])
1412 .chain(
1413 frame
1414 .decorations
1415 .iter()
1416 .filter(|d| d.rect[2] > 0.0 && d.rect[2] <= 2.0 && d.rect[3] > 10.0)
1417 .map(|d| d.rect[0] + d.rect[2] / 2.0),
1418 )
1419 .collect();
1420 xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
1421 xs
1422 }
1423
1424 fn flow_with_rule(tree: &mut WidgetTree, rule: bool) -> WidgetId {
1425 let ids: Vec<_> = (0..6).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
1426 let mut flow = ColumnFlow::new().min_column_width(100.0);
1427 if rule {
1428 flow = flow.column_rule(1.0, teksilo_tokens::BorderRole::Divider);
1429 }
1430 for &id in &ids {
1431 flow = flow.add_child(id);
1432 }
1433 tree.add(flow)
1434 }
1435
1436 #[test]
1437 fn column_rule_paints_one_line_centred_in_each_gap() {
1438 let mut tree = WidgetTree::new();
1439 flow_with_rule(&mut tree, true);
1440 tree.layout(SizeProposal::exact(300.0, 400.0));
1442 let xs = rule_xs(&mut tree);
1443 assert_eq!(xs.len(), 2, "columns - 1 rules, got {xs:?}");
1444 assert!((xs[0] - 100.0).abs() < 0.01, "got {xs:?}");
1445 assert!((xs[1] - 200.0).abs() < 0.01, "got {xs:?}");
1446 }
1447
1448 #[test]
1449 fn column_rule_follows_the_reflow() {
1450 let mut tree = WidgetTree::new();
1451 flow_with_rule(&mut tree, true);
1452 tree.layout(SizeProposal::exact(300.0, 400.0));
1453 assert_eq!(rule_xs(&mut tree).len(), 2, "3 columns -> 2 rules");
1454
1455 tree.layout(SizeProposal::exact(200.0, 400.0));
1456 assert_eq!(rule_xs(&mut tree).len(), 1, "2 columns -> 1 rule");
1457
1458 tree.layout(SizeProposal::exact(100.0, 400.0));
1459 assert!(
1460 rule_xs(&mut tree).is_empty(),
1461 "a single column has no gap to rule"
1462 );
1463 }
1464
1465 #[test]
1466 fn no_rule_paints_nothing() {
1467 let mut tree = WidgetTree::new();
1468 flow_with_rule(&mut tree, false);
1469 tree.layout(SizeProposal::exact(300.0, 400.0));
1470 assert!(
1471 rule_xs(&mut tree).is_empty(),
1472 "column_rule is opt-in; the default layout paints nothing"
1473 );
1474 }
1475
1476 #[test]
1477 fn column_rule_sits_in_the_gap_when_spacing_is_wide() {
1478 let mut tree = WidgetTree::new();
1479 let ids: Vec<_> = (0..4).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
1480 let mut flow = ColumnFlow::new()
1481 .min_column_width(100.0)
1482 .column_spacing(20.0)
1483 .column_rule(1.0, teksilo_tokens::BorderRole::Divider);
1484 for &id in &ids {
1485 flow = flow.add_child(id);
1486 }
1487 tree.add(flow);
1488 tree.layout(SizeProposal::exact(340.0, 400.0));
1491 let xs = rule_xs(&mut tree);
1492 assert_eq!(xs.len(), 2, "got {xs:?}");
1493 assert!((xs[0] - 110.0).abs() < 0.01, "centred in gap 0, got {xs:?}");
1494 assert!((xs[1] - 230.0).abs() < 0.01, "centred in gap 1, got {xs:?}");
1495 }
1496
1497 #[test]
1498 fn semantic_list_wrapper_is_layout_transparent() {
1499 let mut tree = WidgetTree::new();
1501 let ids: Vec<_> = (0..4).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
1502 let mut flow = ColumnFlow::new()
1503 .min_column_width(100.0)
1504 .semantic_list(true);
1505 for &id in &ids {
1506 flow = flow.add_child(id);
1507 }
1508 tree.add(flow);
1509 tree.layout(SizeProposal::exact(200.0, 400.0));
1510
1511 assert!((tree.bounds(ids[0]).x - 0.0).abs() < 0.01);
1512 assert!((tree.bounds(ids[0]).width - 100.0).abs() < 0.01);
1513 assert!((tree.bounds(ids[2]).x - 100.0).abs() < 0.01);
1514 assert!((tree.bounds(ids[1]).y - 40.0).abs() < 0.01);
1515 }
1516}
1517
1518#[cfg(test)]
1542mod proptests {
1543 use super::*;
1544 use proptest::prelude::*;
1545
1546 fn arb_height() -> impl Strategy<Value = f32> {
1550 prop_oneof![Just(0.0_f32), 0.0f32..500.0_f32,]
1551 }
1552
1553 fn arb_heights() -> impl Strategy<Value = Vec<f32>> {
1554 prop::collection::vec(arb_height(), 0..24)
1555 }
1556
1557 fn arb_gap() -> impl Strategy<Value = f32> {
1560 prop_oneof![
1561 Just(0.0_f32),
1562 Just(-5.0_f32),
1563 0.0f32..50.0_f32,
1564 Just(10_000.0_f32),
1565 ]
1566 }
1567
1568 fn arb_nonneg_gap() -> impl Strategy<Value = f32> {
1571 prop_oneof![Just(0.0_f32), 0.0f32..50.0_f32, Just(5_000.0_f32),]
1572 }
1573
1574 fn arb_k() -> impl Strategy<Value = usize> {
1577 prop_oneof![Just(0usize), 1usize..8usize,]
1578 }
1579
1580 fn naive_even_split_extents(heights: &[f32], gap: f32, k: usize) -> Vec<f32> {
1602 let n = heights.len();
1603 if n == 0 {
1604 return Vec::new();
1605 }
1606 let k_eff = k.min(n).max(1);
1607 let base = n / k_eff;
1608 let extra = n % k_eff;
1609 let mut extents = Vec::with_capacity(k_eff);
1610 let mut idx = 0usize;
1611 for col in 0..k_eff {
1612 let take = base + usize::from(col < extra);
1613 let slice = &heights[idx..idx + take];
1614 let sum: f32 = slice.iter().sum();
1615 extents.push(run_extent(sum, take, gap));
1616 idx += take;
1617 }
1618 extents
1619 }
1620
1621 proptest! {
1623 #[test]
1624 fn column_indices_never_decrease_across_the_source_order(
1625 heights in arb_heights(), gap in arb_gap(), k in arb_k(),
1626 ) {
1627 let r = balance_columns(&heights, gap, k);
1633 for w in r.column_of.windows(2) {
1634 prop_assert!(
1635 w[1] >= w[0],
1636 "column index went backwards in {:?}", r.column_of
1637 );
1638 }
1639 }
1640 }
1641
1642 proptest! {
1644 #[test]
1645 fn uses_exactly_k_columns_when_there_are_enough_items(
1646 heights in arb_heights(), gap in arb_gap(), k in 1usize..8usize,
1647 ) {
1648 let n = heights.len();
1649 prop_assume!(n >= k);
1650 let r = balance_columns(&heights, gap, k);
1651 let used = r.column_of.iter().copied().max().map_or(0, |m| m + 1);
1652 prop_assert_eq!(
1653 used, k,
1654 "expected exactly {} columns for {} items, used {}", k, n, used
1655 );
1656 }
1657 }
1658
1659 proptest! {
1661 #[test]
1662 fn no_column_is_empty_when_there_are_enough_items(
1663 heights in arb_heights(), gap in arb_gap(), k in 1usize..8usize,
1664 ) {
1665 let n = heights.len();
1666 prop_assume!(n >= k);
1667 let r = balance_columns(&heights, gap, k);
1668 for col in 0..k {
1669 prop_assert!(
1670 r.column_of.contains(&col),
1671 "column {} is empty in partition {:?}", col, r.column_of
1672 );
1673 }
1674 }
1675 }
1676
1677 proptest! {
1679 #![proptest_config(ProptestConfig { cases: 512, ..ProptestConfig::default() })]
1680 #[test]
1681 fn tallest_column_is_at_most_the_naive_even_split(
1682 heights in arb_heights(), gap in arb_nonneg_gap(), k in arb_k(),
1683 ) {
1684 let r = balance_columns(&heights, gap, k);
1685 let naive_tallest = naive_even_split_extents(&heights, gap, k)
1686 .into_iter()
1687 .fold(0.0_f32, f32::max);
1688 prop_assert!(
1689 r.height <= naive_tallest + 0.01,
1690 "balanced height {} exceeds naive even-split height {} for {:?} gap {} k {}",
1691 r.height, naive_tallest, heights, gap, k
1692 );
1693 }
1694 }
1695
1696 proptest! {
1698 #[test]
1699 fn repeated_calls_on_the_same_input_agree_bit_for_bit(
1700 heights in arb_heights(), gap in arb_gap(), k in arb_k(),
1701 ) {
1702 let a = balance_columns(&heights, gap, k);
1706 let b = balance_columns(&heights, gap, k);
1707 prop_assert_eq!(
1708 &a, &b,
1709 "two calls with identical input ({:?}, gap {}, k {}) produced different partitions: {:?} vs {:?}",
1710 heights, gap, k, a, b
1711 );
1712 }
1713 }
1714
1715 proptest! {
1717 #[test]
1718 fn reported_height_matches_the_reconstructed_tallest_column(
1719 heights in arb_heights(), gap in arb_gap(), k in arb_k(),
1720 ) {
1721 let r = balance_columns(&heights, gap, k);
1722 let cols = r.column_of.iter().copied().max().map_or(0, |m| m + 1);
1723 let mut sums = vec![0.0f32; cols];
1724 let mut counts = vec![0usize; cols];
1725 for (i, &h) in heights.iter().enumerate() {
1726 counts[r.column_of[i]] += 1;
1727 sums[r.column_of[i]] += h;
1728 }
1729 let clamped_gap = gap.max(0.0);
1730 let tallest = (0..cols)
1731 .map(|c| run_extent(sums[c], counts[c], clamped_gap))
1732 .fold(0.0_f32, f32::max);
1733 prop_assert!(
1734 (r.height - tallest).abs() < 0.05,
1735 "reported height {} disagrees with reconstructed tallest column {}",
1736 r.height, tallest
1737 );
1738 }
1739 }
1740
1741 proptest! {
1743 #[test]
1744 fn no_column_extent_exceeds_the_reported_height(
1745 heights in arb_heights(), gap in arb_gap(), k in arb_k(),
1746 ) {
1747 let r = balance_columns(&heights, gap, k);
1748 let cols = r.column_of.iter().copied().max().map_or(0, |m| m + 1);
1749 let mut sums = vec![0.0f32; cols];
1750 let mut counts = vec![0usize; cols];
1751 for (i, &h) in heights.iter().enumerate() {
1752 counts[r.column_of[i]] += 1;
1753 sums[r.column_of[i]] += h;
1754 }
1755 let clamped_gap = gap.max(0.0);
1756 for c in 0..cols {
1757 let extent = run_extent(sums[c], counts[c], clamped_gap);
1758 prop_assert!(
1759 extent <= r.height + 0.05,
1760 "column {} extent {} exceeds reported height {}", c, extent, r.height
1761 );
1762 }
1763 }
1764 }
1765
1766 proptest! {
1768 #[test]
1769 fn never_panics_on_degenerate_input(
1770 heights in prop::collection::vec(arb_height(), 0..3),
1771 gap in prop_oneof![Just(0.0_f32), Just(-1.0_f32), Just(1.0e6_f32)],
1772 k in prop_oneof![Just(0usize), Just(1usize), Just(100usize)],
1773 ) {
1774 let n = heights.len();
1775 let r = balance_columns(&heights, gap, k);
1776 prop_assert_eq!(
1777 r.column_of.len(), n,
1778 "every child must be assigned a column: heights {:?} gap {} k {} -> {:?}",
1779 heights, gap, k, r.column_of
1780 );
1781 prop_assert!(
1785 r.column_of.iter().all(|&c| c < n.max(1)),
1786 "out-of-range column index in {:?} for {} items (gap {} k {})",
1787 r.column_of, n, gap, k
1788 );
1789 prop_assert!(
1790 r.height.is_finite() && r.height >= 0.0,
1791 "height {} is not a finite, non-negative number for heights {:?} gap {} k {}",
1792 r.height, heights, gap, k
1793 );
1794 }
1795 }
1796}