1use std::collections::HashMap;
27
28use teksilo_canvas::Rect;
29
30use super::PaneBoundaries;
31use super::column::{Column, ColumnWidth};
32
33pub(crate) struct ColumnSolver;
37
38impl ColumnSolver {
39 #[cfg(test)]
41 pub(crate) fn resolve<T: 'static>(
42 columns: &[Column<T>],
43 available_width: f32,
44 min_width_default: f32,
45 overrides: &HashMap<String, f32>,
46 ) -> Vec<f32> {
47 let order: Vec<usize> = (0..columns.len()).collect();
48 Self::resolve_in_order(
49 columns,
50 &order,
51 available_width,
52 min_width_default,
53 overrides,
54 )
55 }
56
57 pub(crate) fn resolve_in_order<T: 'static>(
61 columns: &[Column<T>],
62 display_order: &[usize],
63 available_width: f32,
64 min_width_default: f32,
65 overrides: &HashMap<String, f32>,
66 ) -> Vec<f32> {
67 if display_order.is_empty() {
68 return Vec::new();
69 }
70
71 let mut widths = vec![0.0_f32; display_order.len()];
72 let mut flex_total: f32 = 0.0;
73 let mut consumed: f32 = 0.0;
74
75 for (slot, &col_idx) in display_order.iter().enumerate() {
78 let col = &columns[col_idx];
79 let floor = col.min_width.unwrap_or(min_width_default);
80 if let Some(&override_w) = overrides.get(&col.id) {
81 let clamped = clamp(override_w, floor, col.max_width);
82 widths[slot] = clamped;
83 consumed += clamped;
84 continue;
85 }
86 match col.width {
87 ColumnWidth::Fixed(px) => {
88 let clamped = clamp(px, floor, col.max_width);
89 widths[slot] = clamped;
90 consumed += clamped;
91 }
92 ColumnWidth::Auto => {
93 let clamped = clamp(floor, floor, col.max_width);
94 widths[slot] = clamped;
95 consumed += clamped;
96 }
97 ColumnWidth::Flex(factor) => {
98 flex_total += factor.max(0.0);
99 }
100 }
101 }
102
103 let leftover = (available_width - consumed).max(0.0);
111 if flex_total > 0.0 {
112 struct FlexSlot {
113 slot: usize,
114 factor: f32,
115 floor: f32,
116 max: Option<f32>,
117 }
118 let mut pool: Vec<FlexSlot> = display_order
119 .iter()
120 .enumerate()
121 .filter_map(|(slot, &col_idx)| {
122 let col = &columns[col_idx];
123 if overrides.contains_key(&col.id) {
124 return None;
125 }
126 match col.width {
127 ColumnWidth::Flex(factor) => Some(FlexSlot {
128 slot,
129 factor: factor.max(0.0),
130 floor: col.min_width.unwrap_or(min_width_default),
131 max: col.max_width,
132 }),
133 _ => None,
134 }
135 })
136 .collect();
137
138 let mut pool_leftover = leftover;
139 let mut pool_flex_total: f32 = pool.iter().map(|s| s.factor).sum();
140
141 while !pool.is_empty() {
142 if pool_flex_total <= 0.0 {
143 for slot in &pool {
147 widths[slot.slot] = slot.floor;
148 }
149 break;
150 }
151 let round_leftover = pool_leftover;
156 let round_flex_total = pool_flex_total;
157 let mut next_pool = Vec::with_capacity(pool.len());
158 let mut any_clamped = false;
159 for slot in pool {
160 let share = round_leftover * (slot.factor / round_flex_total);
161 let violates = share < slot.floor || slot.max.is_some_and(|m| share > m);
162 if violates {
163 let clamped = clamp(share, slot.floor, slot.max);
164 widths[slot.slot] = clamped;
165 pool_leftover -= clamped;
166 pool_flex_total -= slot.factor;
167 any_clamped = true;
168 } else {
169 next_pool.push(slot);
170 }
171 }
172 if !any_clamped {
173 for slot in &next_pool {
176 let share = pool_leftover * (slot.factor / pool_flex_total);
177 widths[slot.slot] = share;
178 }
179 break;
180 }
181 pool_leftover = pool_leftover.max(0.0);
182 pool = next_pool;
183 }
184 }
185
186 widths
187 }
188
189 #[allow(dead_code)]
191 pub(crate) fn total_width(widths: &[f32]) -> f32 {
192 widths.iter().sum()
193 }
194
195 #[allow(dead_code)]
198 pub(crate) fn x_offset(widths: &[f32], i: usize) -> f32 {
199 widths.iter().take(i).sum()
200 }
201}
202
203fn clamp(value: f32, min: f32, max: Option<f32>) -> f32 {
204 let m = max.unwrap_or(f32::INFINITY);
205 value.max(min).min(m)
206}
207
208fn sum_range(widths: &[f32], range: std::ops::Range<usize>) -> f32 {
227 let start = range.start.min(widths.len());
228 let end = range.end.min(widths.len()).max(start);
229 widths[start..end].iter().sum()
230}
231
232pub(crate) fn pane_widths(widths: &[f32], boundaries: PaneBoundaries) -> (f32, f32, f32) {
238 let leading = sum_range(widths, 0..boundaries.leading_count);
239 let middle = sum_range(widths, boundaries.leading_count..boundaries.middle_end);
240 let trailing = sum_range(widths, boundaries.middle_end..widths.len());
241 (leading, middle, trailing)
242}
243
244pub(crate) fn middle_viewport_width(
249 band_width: f32,
250 widths: &[f32],
251 boundaries: PaneBoundaries,
252) -> f32 {
253 let (leading, _, trailing) = pane_widths(widths, boundaries);
254 (band_width - leading - trailing).max(0.0)
255}
256
257pub(crate) fn max_scroll_x(band_width: f32, widths: &[f32], boundaries: PaneBoundaries) -> f32 {
260 let (_, middle_content, _) = pane_widths(widths, boundaries);
261 let viewport = middle_viewport_width(band_width, widths, boundaries);
262 (middle_content - viewport).max(0.0)
263}
264
265pub(crate) fn band_rects(
276 bounds: Rect,
277 widths: &[f32],
278 boundaries: PaneBoundaries,
279 rtl: bool,
280) -> (Rect, Rect, Rect) {
281 let (leading_w, _, trailing_w) = pane_widths(widths, boundaries);
282 let middle_w = middle_viewport_width(bounds.width, widths, boundaries);
283 if rtl {
284 let leading = Rect::new(
285 bounds.right() - leading_w,
286 bounds.y,
287 leading_w,
288 bounds.height,
289 );
290 let trailing = Rect::new(bounds.x, bounds.y, trailing_w, bounds.height);
291 let middle = Rect::new(bounds.x + trailing_w, bounds.y, middle_w, bounds.height);
292 (leading, middle, trailing)
293 } else {
294 let leading = Rect::new(bounds.x, bounds.y, leading_w, bounds.height);
295 let middle = Rect::new(bounds.x + leading_w, bounds.y, middle_w, bounds.height);
296 let trailing = Rect::new(
297 bounds.x + bounds.width - trailing_w,
298 bounds.y,
299 trailing_w,
300 bounds.height,
301 );
302 (leading, middle, trailing)
303 }
304}
305
306pub(crate) fn column_logical_x(
312 widths: &[f32],
313 boundaries: PaneBoundaries,
314 scroll_x: f32,
315 band_width: f32,
316 slot: usize,
317) -> Option<f32> {
318 if slot >= widths.len() {
319 return None;
320 }
321 if slot < boundaries.leading_count {
322 return Some(sum_range(widths, 0..slot));
323 }
324 let (leading_w, _, trailing_w) = pane_widths(widths, boundaries);
325 if slot < boundaries.middle_end {
326 let within = sum_range(widths, boundaries.leading_count..slot);
327 return Some(leading_w - scroll_x + within);
328 }
329 let within = sum_range(widths, boundaries.middle_end..slot);
330 Some(band_width - trailing_w + within)
331}
332
333pub(crate) fn insertion_slot_at_x(
340 widths: &[f32],
341 boundaries: PaneBoundaries,
342 scroll_x: f32,
343 band_width: f32,
344 x: f32,
345) -> usize {
346 let leading_end = boundaries.leading_count.min(widths.len());
347 let mut cursor = 0.0;
348 for i in 0..leading_end {
349 let w = widths[i];
350 if x < cursor + w * 0.5 {
351 return i;
352 }
353 cursor += w;
354 }
355 let (leading_w, _, trailing_w) = pane_widths(widths, boundaries);
356 let middle_end = boundaries.middle_end.min(widths.len()).max(leading_end);
357 let mut cursor = leading_w - scroll_x;
358 for i in leading_end..middle_end {
359 let w = widths[i];
360 if x < cursor + w * 0.5 {
361 return i;
362 }
363 cursor += w;
364 }
365 let mut cursor = band_width - trailing_w;
366 for i in middle_end..widths.len() {
367 let w = widths[i];
368 if x < cursor + w * 0.5 {
369 return i;
370 }
371 cursor += w;
372 }
373 widths.len()
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379 use crate::primitives::TextWidget;
380 use crate::table_view::column::{CellContext, Column};
381 use teksilo_i18n::lit;
382
383 fn col(id: &str, w: ColumnWidth) -> Column<&'static str> {
384 Column::<&str>::new(id, lit!("h"), |_, _: &CellContext| {
385 Box::new(TextWidget::new(lit!("x")))
386 })
387 .width(w)
388 }
389
390 #[test]
391 fn fixed_widths_pass_through() {
392 let cols = vec![
393 col("a", ColumnWidth::Fixed(80.0)),
394 col("b", ColumnWidth::Fixed(120.0)),
395 ];
396 let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
397 assert_eq!(widths, vec![80.0, 120.0]);
398 }
399
400 #[test]
401 fn flex_columns_split_leftover() {
402 let cols = vec![
403 col("a", ColumnWidth::Fixed(100.0)),
404 col("b", ColumnWidth::Flex(1.0)),
405 col("c", ColumnWidth::Flex(2.0)),
406 ];
407 let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
409 assert_eq!(widths[0], 100.0);
410 assert!((widths[1] - 100.0).abs() < 0.01);
411 assert!((widths[2] - 200.0).abs() < 0.01);
412 }
413
414 #[test]
415 fn flex_clamps_to_min_width() {
416 let cols = vec![
417 col("a", ColumnWidth::Fixed(380.0)),
418 col("b", ColumnWidth::Flex(1.0)).min_width(60.0),
419 ];
420 let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
422 assert_eq!(widths[1], 60.0);
423 }
424
425 #[test]
426 fn flex_clamps_to_max_width() {
427 let cols = vec![col("a", ColumnWidth::Flex(1.0)).max_width(120.0)];
428 let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
429 assert_eq!(widths[0], 120.0);
430 }
431
432 #[test]
433 fn fixed_clamps_to_min_when_below() {
434 let cols = vec![col("a", ColumnWidth::Fixed(10.0)).min_width(60.0)];
435 let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
436 assert_eq!(widths[0], 60.0);
437 }
438
439 #[test]
440 fn fixed_clamps_to_max_when_above() {
441 let cols = vec![col("a", ColumnWidth::Fixed(500.0)).max_width(180.0)];
442 let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
443 assert_eq!(widths[0], 180.0);
444 }
445
446 #[test]
447 fn auto_falls_back_to_min_default() {
448 let cols = vec![col("a", ColumnWidth::Auto)];
449 let widths = ColumnSolver::resolve(&cols, 400.0, 48.0, &HashMap::new());
450 assert_eq!(widths[0], 48.0);
451 }
452
453 #[test]
454 fn auto_with_min_uses_min() {
455 let cols = vec![col("a", ColumnWidth::Auto).min_width(100.0)];
456 let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
457 assert_eq!(widths[0], 100.0);
458 }
459
460 #[test]
461 fn no_flex_no_overflow() {
462 let cols = vec![
466 col("a", ColumnWidth::Fixed(80.0)),
467 col("b", ColumnWidth::Fixed(120.0)),
468 ];
469 let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
470 assert_eq!(ColumnSolver::total_width(&widths), 200.0);
471 }
472
473 #[test]
474 fn x_offset_walks_widths() {
475 let widths = vec![80.0, 120.0, 60.0];
476 assert_eq!(ColumnSolver::x_offset(&widths, 0), 0.0);
477 assert_eq!(ColumnSolver::x_offset(&widths, 1), 80.0);
478 assert_eq!(ColumnSolver::x_offset(&widths, 2), 200.0);
479 assert_eq!(ColumnSolver::x_offset(&widths, 3), 260.0);
480 }
481
482 #[test]
483 fn empty_columns_returns_empty() {
484 let cols: Vec<Column<&'static str>> = vec![];
485 let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
486 assert!(widths.is_empty());
487 }
488
489 #[test]
490 fn override_pins_column_regardless_of_width_policy() {
491 let cols = vec![
492 col("a", ColumnWidth::Flex(1.0)),
493 col("b", ColumnWidth::Flex(1.0)),
494 ];
495 let mut over = HashMap::new();
496 over.insert("a".to_string(), 250.0);
497 let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &over);
498 assert_eq!(widths[0], 250.0);
500 assert!((widths[1] - 150.0).abs() < 0.01, "got {}", widths[1]);
501 }
502
503 #[test]
504 fn override_clamps_to_min_max() {
505 let cols = vec![
506 col("a", ColumnWidth::Flex(1.0))
507 .min_width(80.0)
508 .max_width(200.0),
509 ];
510 let mut over = HashMap::new();
511 over.insert("a".to_string(), 5.0); let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &over);
513 assert_eq!(widths[0], 80.0);
514
515 let mut over = HashMap::new();
516 over.insert("a".to_string(), 999.0); let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &over);
518 assert_eq!(widths[0], 200.0);
519 }
520
521 #[test]
522 fn negative_leftover_keeps_min() {
523 let cols = vec![
524 col("a", ColumnWidth::Fixed(500.0)),
525 col("b", ColumnWidth::Flex(1.0)).min_width(50.0),
526 ];
527 let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
529 assert_eq!(widths[1], 50.0);
530 }
531
532 #[test]
533 fn zero_flex_factor_treated_as_zero_share() {
534 let cols = vec![
535 col("a", ColumnWidth::Flex(0.0)).min_width(40.0),
536 col("b", ColumnWidth::Flex(1.0)),
537 ];
538 let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
539 assert_eq!(widths[0], 40.0);
546 assert_eq!(widths[1], 360.0);
547 }
548
549 #[test]
550 fn flex_min_width_redistributes_to_siblings() {
551 let cols = vec![
552 col("fixed", ColumnWidth::Fixed(100.0)),
553 col("a", ColumnWidth::Flex(1.0)),
554 col("b", ColumnWidth::Flex(1.0)).min_width(200.0),
555 ];
556 let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
565 assert_eq!(widths[0], 100.0);
566 assert_eq!(widths[1], 100.0);
567 assert_eq!(widths[2], 200.0);
568 assert_eq!(ColumnSolver::total_width(&widths), 400.0);
569 }
570
571 #[test]
572 fn flex_min_widths_that_oversubscribe_the_pane_still_overflow() {
573 let cols = vec![
577 col("a", ColumnWidth::Flex(1.0)).min_width(300.0),
578 col("b", ColumnWidth::Flex(1.0)).min_width(300.0),
579 ];
580 let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
581 assert_eq!(widths[0], 300.0);
582 assert_eq!(widths[1], 300.0);
583 }
584
585 #[test]
588 fn pane_widths_splits_leading_middle_trailing() {
589 let widths = [50.0, 60.0, 70.0, 80.0, 90.0];
590 let b = PaneBoundaries::new(1, 4);
592 assert_eq!(pane_widths(&widths, b), (50.0, 210.0, 90.0));
593 }
594
595 #[test]
596 fn pane_widths_all_middle_when_unpinned() {
597 let widths = [50.0, 60.0, 70.0];
598 let b = PaneBoundaries::new(0, 3);
599 assert_eq!(pane_widths(&widths, b), (0.0, 180.0, 0.0));
600 }
601
602 #[test]
603 fn middle_viewport_width_is_band_minus_pinned_panes() {
604 let widths = [60.0, 100.0, 100.0, 100.0, 60.0];
605 let b = PaneBoundaries::new(1, 4);
606 assert_eq!(middle_viewport_width(400.0, &widths, b), 280.0);
608 }
609
610 #[test]
611 fn middle_viewport_width_floors_at_zero_when_pinned_panes_overflow() {
612 let widths = [300.0, 100.0, 300.0];
613 let b = PaneBoundaries::new(1, 2);
614 assert_eq!(middle_viewport_width(400.0, &widths, b), 0.0);
616 }
617
618 #[test]
619 fn max_scroll_x_is_zero_when_content_fits() {
620 let widths = [60.0, 100.0, 60.0];
621 let b = PaneBoundaries::new(1, 2);
622 assert_eq!(max_scroll_x(400.0, &widths, b), 0.0);
624 }
625
626 #[test]
627 fn max_scroll_x_clamps_after_a_pane_shrink() {
628 let widths = [60.0, 500.0, 60.0];
630 let b = PaneBoundaries::new(1, 2);
631 assert_eq!(max_scroll_x(400.0, &widths, b), 500.0 - 280.0);
632 let narrower = max_scroll_x(300.0, &widths, b);
637 assert_eq!(narrower, 500.0 - (300.0 - 120.0));
638 assert!(narrower > 0.0);
639 assert_eq!(max_scroll_x(50.0, &widths, b), 500.0);
643 }
644
645 #[test]
646 fn band_rects_ltr_places_leading_left_middle_center_trailing_right() {
647 let widths = [60.0, 200.0, 60.0];
648 let b = PaneBoundaries::new(1, 2);
649 let bounds = Rect::new(10.0, 20.0, 400.0, 30.0);
650 let (leading, middle, trailing) = band_rects(bounds, &widths, b, false);
651 assert_eq!(leading, Rect::new(10.0, 20.0, 60.0, 30.0));
652 assert_eq!(middle, Rect::new(70.0, 20.0, 280.0, 30.0));
653 assert_eq!(trailing, Rect::new(350.0, 20.0, 60.0, 30.0));
654 }
655
656 #[test]
657 fn band_rects_rtl_mirrors_leading_to_the_physical_right() {
658 let widths = [60.0, 200.0, 60.0];
659 let b = PaneBoundaries::new(1, 2);
660 let bounds = Rect::new(10.0, 20.0, 400.0, 30.0);
661 let (leading, middle, trailing) = band_rects(bounds, &widths, b, true);
662 assert_eq!(leading, Rect::new(350.0, 20.0, 60.0, 30.0));
664 assert_eq!(trailing, Rect::new(10.0, 20.0, 60.0, 30.0));
666 assert_eq!(middle, Rect::new(70.0, 20.0, 280.0, 30.0));
667 }
668
669 #[test]
670 fn column_logical_x_pinned_columns_ignore_scroll() {
671 let widths = [60.0, 80.0, 200.0, 60.0];
672 let b = PaneBoundaries::new(1, 3);
673 for scroll in [0.0, 40.0, 999.0] {
674 assert_eq!(
675 column_logical_x(&widths, b, scroll, 400.0, 0),
676 Some(0.0),
677 "leading column never moves"
678 );
679 assert_eq!(
680 column_logical_x(&widths, b, scroll, 400.0, 3),
681 Some(400.0 - 60.0),
682 "trailing column never moves"
683 );
684 }
685 }
686
687 #[test]
688 fn column_logical_x_middle_column_shifts_left_by_scroll() {
689 let widths = [60.0, 80.0, 200.0, 60.0];
690 let b = PaneBoundaries::new(1, 3);
691 assert_eq!(column_logical_x(&widths, b, 0.0, 400.0, 1), Some(60.0));
693 assert_eq!(column_logical_x(&widths, b, 25.0, 400.0, 1), Some(35.0));
694 assert_eq!(
695 column_logical_x(&widths, b, 25.0, 400.0, 2),
696 Some(60.0 - 25.0 + 80.0)
697 );
698 }
699
700 #[test]
701 fn column_logical_x_out_of_range_is_none() {
702 let widths = [60.0, 80.0];
703 let b = PaneBoundaries::new(0, 2);
704 assert_eq!(column_logical_x(&widths, b, 0.0, 400.0, 2), None);
705 }
706
707 #[test]
708 fn insertion_slot_at_x_finds_pinned_and_scrolled_columns() {
709 let widths = [60.0, 80.0, 200.0, 60.0];
710 let b = PaneBoundaries::new(1, 3);
711 assert_eq!(insertion_slot_at_x(&widths, b, 0.0, 400.0, 0.0), 0);
713 assert_eq!(insertion_slot_at_x(&widths, b, 0.0, 400.0, 10_000.0), 4);
715 assert_eq!(insertion_slot_at_x(&widths, b, 0.0, 400.0, 65.0), 1);
718 assert_eq!(insertion_slot_at_x(&widths, b, 70.0, 400.0, 65.0), 2);
723 }
724}