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 child(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
393 self.pending
394 .push(teksilo_core::IntoTeksiChild::into_pending(widget));
395 self
396 }
397
398 pub fn children(
400 mut self,
401 iter: impl IntoIterator<Item = impl teksilo_core::IntoTeksiChild>,
402 ) -> Self {
403 for widget in iter {
404 self.pending
405 .push(teksilo_core::IntoTeksiChild::into_pending(widget));
406 }
407 self
408 }
409
410 pub fn child_opt(mut self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
412 if let Some(w) = widget {
413 self.pending
414 .push(teksilo_core::IntoTeksiChild::into_pending(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.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.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.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.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.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.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(ColumnFlow::new().min_column_width(50.0).child(a).child(b));
1144 tree.layout(SizeProposal {
1145 width: None,
1146 height: Some(400.0),
1147 });
1148 assert!(
1151 (tree.bounds(flow).width - 80.0).abs() < 0.01,
1152 "got {}",
1153 tree.bounds(flow).width
1154 );
1155 }
1156
1157 #[test]
1158 fn unbounded_width_honours_max_columns() {
1159 let mut tree = WidgetTree::new();
1160 let a = tree.add(FixedLeaf(80.0, 40.0));
1161 let b = tree.add(FixedLeaf(60.0, 30.0));
1162 let flow = tree.add(
1163 ColumnFlow::new()
1164 .min_column_width(50.0)
1165 .max_columns(3)
1166 .column_spacing(10.0)
1167 .child(a)
1168 .child(b),
1169 );
1170 tree.layout(SizeProposal {
1171 width: None,
1172 height: Some(400.0),
1173 });
1174 assert!(
1176 (tree.bounds(flow).width - 260.0).abs() < 0.01,
1177 "got {}",
1178 tree.bounds(flow).width
1179 );
1180 }
1181
1182 #[test]
1183 fn empty_flow_has_zero_height() {
1184 let mut tree = WidgetTree::new();
1185 let flow = tree.add(ColumnFlow::new());
1186 tree.layout(SizeProposal {
1187 width: Some(300.0),
1188 height: None,
1189 });
1190 assert!((tree.bounds(flow).height - 0.0).abs() < 0.01);
1191 }
1192
1193 #[test]
1194 fn dormant_child_excluded_and_partition_stays_stable() {
1195 let mut tree = WidgetTree::new();
1196 let ids: Vec<_> = (0..4).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
1197 let mut flow = ColumnFlow::new().min_column_width(100.0);
1198 for &id in &ids {
1199 flow = flow.child(id);
1200 }
1201 tree.add(flow);
1202 tree.layout(SizeProposal::exact(200.0, 400.0));
1203 assert!((tree.bounds(ids[2]).x - 100.0).abs() < 0.01);
1205
1206 tree.set_dormant(ids[1]);
1208 tree.layout(SizeProposal::exact(200.0, 400.0));
1209 assert!((tree.bounds(ids[0]).x - 0.0).abs() < 0.01);
1210 assert!(
1211 (tree.bounds(ids[2]).x - 0.0).abs() < 0.01,
1212 "child 2 -> col 0"
1213 );
1214 assert!((tree.bounds(ids[2]).y - 40.0).abs() < 0.01);
1215 assert!(
1216 (tree.bounds(ids[3]).x - 100.0).abs() < 0.01,
1217 "child 3 -> col 1"
1218 );
1219 }
1220
1221 #[test]
1222 fn rtl_mirrors_columns_without_touching_source_order() {
1223 let mut tree = WidgetTree::new();
1224 tree.set_layout_direction(teksilo_core::environment::LayoutDirection::RightToLeft);
1225 let (ids, flow) = six_children(&mut tree);
1226 tree.layout(SizeProposal::exact(300.0, 400.0));
1227
1228 assert!((tree.bounds(ids[0]).x - 200.0).abs() < 0.01);
1230 assert!((tree.bounds(ids[2]).x - 100.0).abs() < 0.01);
1231 assert!((tree.bounds(ids[4]).x - 0.0).abs() < 0.01);
1232 assert_eq!(tree.children(flow), ids);
1235 }
1236
1237 #[test]
1238 fn column_count_signal_fires_only_on_a_real_change() {
1239 let mut tree = WidgetTree::new();
1240 let ids: Vec<_> = (0..6).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
1241 let flow = ColumnFlow::new().min_column_width(100.0);
1242 let count = flow.column_count_signal();
1243 let mut f = flow;
1244 for &id in &ids {
1245 f = f.child(id);
1246 }
1247 tree.add(f);
1248
1249 let fires = std::rc::Rc::new(Cell::new(0usize));
1250 let seen = fires.clone();
1251 let _guard = count.observe(move |_| seen.set(seen.get() + 1));
1252
1253 tree.layout(SizeProposal::exact(300.0, 400.0));
1254 assert_eq!(count.get(), 3);
1255 let after_first = fires.get();
1256
1257 tree.layout(SizeProposal::exact(300.0, 400.0));
1259 assert_eq!(
1260 fires.get(),
1261 after_first,
1262 "re-layout at the same width is silent"
1263 );
1264
1265 tree.layout(SizeProposal::exact(200.0, 400.0));
1267 assert_eq!(count.get(), 2);
1268 assert_eq!(fires.get(), after_first + 1);
1269 }
1270
1271 fn find_node(
1274 update: &teksilo_core::accesskit::TreeUpdate,
1275 id: WidgetId,
1276 ) -> Option<&teksilo_core::accesskit::Node> {
1277 let nid = teksilo_core::accessibility::widget_id_to_node_id(id);
1278 update
1279 .nodes
1280 .iter()
1281 .find(|(n, _)| *n == nid)
1282 .map(|(_, node)| node)
1283 }
1284
1285 fn nodes_with_role_ids(
1286 update: &teksilo_core::accesskit::TreeUpdate,
1287 role: teksilo_core::accesskit::Role,
1288 ) -> Vec<teksilo_core::accesskit::NodeId> {
1289 update
1290 .nodes
1291 .iter()
1292 .filter(|(_, n)| n.role() == role)
1293 .map(|(id, _)| *id)
1294 .collect()
1295 }
1296
1297 fn nodes_with_role(
1298 update: &teksilo_core::accesskit::TreeUpdate,
1299 role: teksilo_core::accesskit::Role,
1300 ) -> Vec<&teksilo_core::accesskit::Node> {
1301 update
1302 .nodes
1303 .iter()
1304 .filter(|(_, n)| n.role() == role)
1305 .map(|(_, n)| n)
1306 .collect()
1307 }
1308
1309 #[test]
1310 fn default_container_is_pruned_and_children_promoted_in_source_order() {
1311 let mut tree = WidgetTree::new();
1312 let labels = ["one", "two", "three", "four"];
1313 let ids: Vec<_> = labels
1314 .iter()
1315 .map(|&l| tree.add(LabeledLeaf(50.0, 40.0, l)))
1316 .collect();
1317 let mut flow = ColumnFlow::new().min_column_width(100.0);
1318 for &id in &ids {
1319 flow = flow.child(id);
1320 }
1321 let flow_id = tree.add(flow);
1322 tree.layout(SizeProposal::exact(200.0, 400.0));
1323 let update = tree.sync_accessibility();
1324
1325 assert!(
1329 find_node(&update, flow_id).is_none(),
1330 "a property-free layout container must not reach assistive tech"
1331 );
1332 for &id in &ids {
1334 assert!(find_node(&update, id).is_some(), "child kept");
1335 }
1336
1337 let root = update
1343 .nodes
1344 .iter()
1345 .find(|(n, _)| *n == teksilo_core::accessibility::root_node_id())
1346 .map(|(_, node)| node)
1347 .expect("window root node");
1348 let order: Vec<_> = root
1349 .children()
1350 .iter()
1351 .filter_map(|nid| {
1352 update
1353 .nodes
1354 .iter()
1355 .find(|(n, _)| n == nid)
1356 .and_then(|(_, n)| n.label())
1357 })
1358 .collect();
1359 assert_eq!(order, labels, "promoted children keep source order");
1360 }
1361
1362 #[test]
1363 fn semantic_list_emits_list_and_positioned_items() {
1364 let mut tree = WidgetTree::new();
1365 let ids: Vec<_> = (0..3).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
1366 let mut flow = ColumnFlow::new()
1367 .min_column_width(100.0)
1368 .semantic_list(true);
1369 for &id in &ids {
1370 flow = flow.child(id);
1371 }
1372 let flow_id = tree.add(flow);
1373 tree.layout(SizeProposal::exact(300.0, 400.0));
1374 let update = tree.sync_accessibility();
1375
1376 let list = find_node(&update, flow_id).expect("List node survives pruning");
1377 assert_eq!(list.role(), teksilo_core::accesskit::Role::List);
1378
1379 let item_ids = nodes_with_role_ids(&update, teksilo_core::accesskit::Role::ListItem);
1380 assert_eq!(item_ids.len(), 3, "one ListItem per child");
1381 let mut seen: Vec<(Option<usize>, Option<usize>)> = item_ids
1385 .iter()
1386 .map(|id| crate::a11y_set_semantics::announced_set_position(&update, *id))
1387 .collect();
1388 seen.sort();
1389 assert_eq!(
1390 seen,
1391 vec![(Some(1), Some(3)), (Some(2), Some(3)), (Some(3), Some(3))]
1392 );
1393 }
1394
1395 fn rule_xs(tree: &mut WidgetTree) -> Vec<f32> {
1401 let frame = tree.render();
1402 let mut xs: Vec<f32> = frame
1403 .cosmetic_lines
1404 .iter()
1405 .filter(|l| (l.from[0] - l.to[0]).abs() < 0.01) .map(|l| l.from[0])
1407 .chain(
1408 frame
1409 .decorations
1410 .iter()
1411 .filter(|d| d.rect[2] > 0.0 && d.rect[2] <= 2.0 && d.rect[3] > 10.0)
1412 .map(|d| d.rect[0] + d.rect[2] / 2.0),
1413 )
1414 .collect();
1415 xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
1416 xs
1417 }
1418
1419 fn flow_with_rule(tree: &mut WidgetTree, rule: bool) -> WidgetId {
1420 let ids: Vec<_> = (0..6).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
1421 let mut flow = ColumnFlow::new().min_column_width(100.0);
1422 if rule {
1423 flow = flow.column_rule(1.0, teksilo_tokens::BorderRole::Divider);
1424 }
1425 for &id in &ids {
1426 flow = flow.child(id);
1427 }
1428 tree.add(flow)
1429 }
1430
1431 #[test]
1432 fn column_rule_paints_one_line_centred_in_each_gap() {
1433 let mut tree = WidgetTree::new();
1434 flow_with_rule(&mut tree, true);
1435 tree.layout(SizeProposal::exact(300.0, 400.0));
1437 let xs = rule_xs(&mut tree);
1438 assert_eq!(xs.len(), 2, "columns - 1 rules, got {xs:?}");
1439 assert!((xs[0] - 100.0).abs() < 0.01, "got {xs:?}");
1440 assert!((xs[1] - 200.0).abs() < 0.01, "got {xs:?}");
1441 }
1442
1443 #[test]
1444 fn column_rule_follows_the_reflow() {
1445 let mut tree = WidgetTree::new();
1446 flow_with_rule(&mut tree, true);
1447 tree.layout(SizeProposal::exact(300.0, 400.0));
1448 assert_eq!(rule_xs(&mut tree).len(), 2, "3 columns -> 2 rules");
1449
1450 tree.layout(SizeProposal::exact(200.0, 400.0));
1451 assert_eq!(rule_xs(&mut tree).len(), 1, "2 columns -> 1 rule");
1452
1453 tree.layout(SizeProposal::exact(100.0, 400.0));
1454 assert!(
1455 rule_xs(&mut tree).is_empty(),
1456 "a single column has no gap to rule"
1457 );
1458 }
1459
1460 #[test]
1461 fn no_rule_paints_nothing() {
1462 let mut tree = WidgetTree::new();
1463 flow_with_rule(&mut tree, false);
1464 tree.layout(SizeProposal::exact(300.0, 400.0));
1465 assert!(
1466 rule_xs(&mut tree).is_empty(),
1467 "column_rule is opt-in; the default layout paints nothing"
1468 );
1469 }
1470
1471 #[test]
1472 fn column_rule_sits_in_the_gap_when_spacing_is_wide() {
1473 let mut tree = WidgetTree::new();
1474 let ids: Vec<_> = (0..4).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
1475 let mut flow = ColumnFlow::new()
1476 .min_column_width(100.0)
1477 .column_spacing(20.0)
1478 .column_rule(1.0, teksilo_tokens::BorderRole::Divider);
1479 for &id in &ids {
1480 flow = flow.child(id);
1481 }
1482 tree.add(flow);
1483 tree.layout(SizeProposal::exact(340.0, 400.0));
1486 let xs = rule_xs(&mut tree);
1487 assert_eq!(xs.len(), 2, "got {xs:?}");
1488 assert!((xs[0] - 110.0).abs() < 0.01, "centred in gap 0, got {xs:?}");
1489 assert!((xs[1] - 230.0).abs() < 0.01, "centred in gap 1, got {xs:?}");
1490 }
1491
1492 #[test]
1493 fn semantic_list_wrapper_is_layout_transparent() {
1494 let mut tree = WidgetTree::new();
1496 let ids: Vec<_> = (0..4).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
1497 let mut flow = ColumnFlow::new()
1498 .min_column_width(100.0)
1499 .semantic_list(true);
1500 for &id in &ids {
1501 flow = flow.child(id);
1502 }
1503 tree.add(flow);
1504 tree.layout(SizeProposal::exact(200.0, 400.0));
1505
1506 assert!((tree.bounds(ids[0]).x - 0.0).abs() < 0.01);
1507 assert!((tree.bounds(ids[0]).width - 100.0).abs() < 0.01);
1508 assert!((tree.bounds(ids[2]).x - 100.0).abs() < 0.01);
1509 assert!((tree.bounds(ids[1]).y - 40.0).abs() < 0.01);
1510 }
1511}
1512
1513#[cfg(test)]
1537mod proptests {
1538 use super::*;
1539 use proptest::prelude::*;
1540
1541 fn arb_height() -> impl Strategy<Value = f32> {
1545 prop_oneof![Just(0.0_f32), 0.0f32..500.0_f32,]
1546 }
1547
1548 fn arb_heights() -> impl Strategy<Value = Vec<f32>> {
1549 prop::collection::vec(arb_height(), 0..24)
1550 }
1551
1552 fn arb_gap() -> impl Strategy<Value = f32> {
1555 prop_oneof![
1556 Just(0.0_f32),
1557 Just(-5.0_f32),
1558 0.0f32..50.0_f32,
1559 Just(10_000.0_f32),
1560 ]
1561 }
1562
1563 fn arb_nonneg_gap() -> impl Strategy<Value = f32> {
1566 prop_oneof![Just(0.0_f32), 0.0f32..50.0_f32, Just(5_000.0_f32),]
1567 }
1568
1569 fn arb_k() -> impl Strategy<Value = usize> {
1572 prop_oneof![Just(0usize), 1usize..8usize,]
1573 }
1574
1575 fn naive_even_split_extents(heights: &[f32], gap: f32, k: usize) -> Vec<f32> {
1597 let n = heights.len();
1598 if n == 0 {
1599 return Vec::new();
1600 }
1601 let k_eff = k.min(n).max(1);
1602 let base = n / k_eff;
1603 let extra = n % k_eff;
1604 let mut extents = Vec::with_capacity(k_eff);
1605 let mut idx = 0usize;
1606 for col in 0..k_eff {
1607 let take = base + usize::from(col < extra);
1608 let slice = &heights[idx..idx + take];
1609 let sum: f32 = slice.iter().sum();
1610 extents.push(run_extent(sum, take, gap));
1611 idx += take;
1612 }
1613 extents
1614 }
1615
1616 proptest! {
1618 #[test]
1619 fn column_indices_never_decrease_across_the_source_order(
1620 heights in arb_heights(), gap in arb_gap(), k in arb_k(),
1621 ) {
1622 let r = balance_columns(&heights, gap, k);
1628 for w in r.column_of.windows(2) {
1629 prop_assert!(
1630 w[1] >= w[0],
1631 "column index went backwards in {:?}", r.column_of
1632 );
1633 }
1634 }
1635 }
1636
1637 proptest! {
1639 #[test]
1640 fn uses_exactly_k_columns_when_there_are_enough_items(
1641 heights in arb_heights(), gap in arb_gap(), k in 1usize..8usize,
1642 ) {
1643 let n = heights.len();
1644 prop_assume!(n >= k);
1645 let r = balance_columns(&heights, gap, k);
1646 let used = r.column_of.iter().copied().max().map_or(0, |m| m + 1);
1647 prop_assert_eq!(
1648 used, k,
1649 "expected exactly {} columns for {} items, used {}", k, n, used
1650 );
1651 }
1652 }
1653
1654 proptest! {
1656 #[test]
1657 fn no_column_is_empty_when_there_are_enough_items(
1658 heights in arb_heights(), gap in arb_gap(), k in 1usize..8usize,
1659 ) {
1660 let n = heights.len();
1661 prop_assume!(n >= k);
1662 let r = balance_columns(&heights, gap, k);
1663 for col in 0..k {
1664 prop_assert!(
1665 r.column_of.contains(&col),
1666 "column {} is empty in partition {:?}", col, r.column_of
1667 );
1668 }
1669 }
1670 }
1671
1672 proptest! {
1674 #![proptest_config(ProptestConfig { cases: 512, ..ProptestConfig::default() })]
1675 #[test]
1676 fn tallest_column_is_at_most_the_naive_even_split(
1677 heights in arb_heights(), gap in arb_nonneg_gap(), k in arb_k(),
1678 ) {
1679 let r = balance_columns(&heights, gap, k);
1680 let naive_tallest = naive_even_split_extents(&heights, gap, k)
1681 .into_iter()
1682 .fold(0.0_f32, f32::max);
1683 prop_assert!(
1684 r.height <= naive_tallest + 0.01,
1685 "balanced height {} exceeds naive even-split height {} for {:?} gap {} k {}",
1686 r.height, naive_tallest, heights, gap, k
1687 );
1688 }
1689 }
1690
1691 proptest! {
1693 #[test]
1694 fn repeated_calls_on_the_same_input_agree_bit_for_bit(
1695 heights in arb_heights(), gap in arb_gap(), k in arb_k(),
1696 ) {
1697 let a = balance_columns(&heights, gap, k);
1701 let b = balance_columns(&heights, gap, k);
1702 prop_assert_eq!(
1703 &a, &b,
1704 "two calls with identical input ({:?}, gap {}, k {}) produced different partitions: {:?} vs {:?}",
1705 heights, gap, k, a, b
1706 );
1707 }
1708 }
1709
1710 proptest! {
1712 #[test]
1713 fn reported_height_matches_the_reconstructed_tallest_column(
1714 heights in arb_heights(), gap in arb_gap(), k in arb_k(),
1715 ) {
1716 let r = balance_columns(&heights, gap, k);
1717 let cols = r.column_of.iter().copied().max().map_or(0, |m| m + 1);
1718 let mut sums = vec![0.0f32; cols];
1719 let mut counts = vec![0usize; cols];
1720 for (i, &h) in heights.iter().enumerate() {
1721 counts[r.column_of[i]] += 1;
1722 sums[r.column_of[i]] += h;
1723 }
1724 let clamped_gap = gap.max(0.0);
1725 let tallest = (0..cols)
1726 .map(|c| run_extent(sums[c], counts[c], clamped_gap))
1727 .fold(0.0_f32, f32::max);
1728 prop_assert!(
1729 (r.height - tallest).abs() < 0.05,
1730 "reported height {} disagrees with reconstructed tallest column {}",
1731 r.height, tallest
1732 );
1733 }
1734 }
1735
1736 proptest! {
1738 #[test]
1739 fn no_column_extent_exceeds_the_reported_height(
1740 heights in arb_heights(), gap in arb_gap(), k in arb_k(),
1741 ) {
1742 let r = balance_columns(&heights, gap, k);
1743 let cols = r.column_of.iter().copied().max().map_or(0, |m| m + 1);
1744 let mut sums = vec![0.0f32; cols];
1745 let mut counts = vec![0usize; cols];
1746 for (i, &h) in heights.iter().enumerate() {
1747 counts[r.column_of[i]] += 1;
1748 sums[r.column_of[i]] += h;
1749 }
1750 let clamped_gap = gap.max(0.0);
1751 for c in 0..cols {
1752 let extent = run_extent(sums[c], counts[c], clamped_gap);
1753 prop_assert!(
1754 extent <= r.height + 0.05,
1755 "column {} extent {} exceeds reported height {}", c, extent, r.height
1756 );
1757 }
1758 }
1759 }
1760
1761 proptest! {
1763 #[test]
1764 fn never_panics_on_degenerate_input(
1765 heights in prop::collection::vec(arb_height(), 0..3),
1766 gap in prop_oneof![Just(0.0_f32), Just(-1.0_f32), Just(1.0e6_f32)],
1767 k in prop_oneof![Just(0usize), Just(1usize), Just(100usize)],
1768 ) {
1769 let n = heights.len();
1770 let r = balance_columns(&heights, gap, k);
1771 prop_assert_eq!(
1772 r.column_of.len(), n,
1773 "every child must be assigned a column: heights {:?} gap {} k {} -> {:?}",
1774 heights, gap, k, r.column_of
1775 );
1776 prop_assert!(
1780 r.column_of.iter().all(|&c| c < n.max(1)),
1781 "out-of-range column index in {:?} for {} items (gap {} k {})",
1782 r.column_of, n, gap, k
1783 );
1784 prop_assert!(
1785 r.height.is_finite() && r.height >= 0.0,
1786 "height {} is not a finite, non-negative number for heights {:?} gap {} k {}",
1787 r.height, heights, gap, k
1788 );
1789 }
1790 }
1791}