1use retroglyph_core::Rect;
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub enum Constraint {
25 Fixed(u16),
27 Percent(u16),
29 Fill(u16),
35 Min(u16),
38 Max(u16),
41}
42
43impl Constraint {
44 fn base(self, total: u16) -> u16 {
49 match self {
50 Self::Fixed(n) | Self::Min(n) => n.min(total),
51 Self::Percent(p) => {
52 let p = u32::from(p.min(100));
53 #[allow(clippy::cast_possible_truncation)]
54 {
55 (u32::from(total) * p / 100) as u16
56 }
57 }
58 Self::Fill(_) | Self::Max(_) => 0,
59 }
60 }
61}
62
63const STACK_CAP: usize = 8;
68
69enum SmallBuf<T: Copy + Default, const N: usize> {
75 Stack([T; N], usize),
76 Heap(Vec<T>),
77}
78
79impl<T: Copy + Default, const N: usize> SmallBuf<T, N> {
80 fn with_capacity(cap: usize) -> Self {
83 if cap <= N {
84 Self::Stack([T::default(); N], 0)
85 } else {
86 Self::Heap(Vec::with_capacity(cap))
87 }
88 }
89
90 fn push(&mut self, value: T) {
97 match self {
98 Self::Stack(buf, len) => {
99 buf[*len] = value;
100 *len += 1;
101 }
102 Self::Heap(vec) => vec.push(value),
103 }
104 }
105}
106
107impl<T: Copy + Default, const N: usize> std::ops::Deref for SmallBuf<T, N> {
108 type Target = [T];
109
110 fn deref(&self) -> &[T] {
111 match self {
112 Self::Stack(buf, len) => &buf[..*len],
113 Self::Heap(vec) => vec,
114 }
115 }
116}
117
118impl<T: Copy + Default, const N: usize> std::ops::DerefMut for SmallBuf<T, N> {
119 fn deref_mut(&mut self) -> &mut [T] {
120 match self {
121 Self::Stack(buf, len) => &mut buf[..*len],
122 Self::Heap(vec) => vec,
123 }
124 }
125}
126
127impl<T: Copy + Default, const N: usize> std::ops::Index<usize> for SmallBuf<T, N> {
128 type Output = T;
129
130 fn index(&self, idx: usize) -> &T {
131 &(**self)[idx]
132 }
133}
134
135impl<T: Copy + Default, const N: usize> std::ops::IndexMut<usize> for SmallBuf<T, N> {
136 fn index_mut(&mut self, idx: usize) -> &mut T {
137 &mut (**self)[idx]
138 }
139}
140
141fn solve(total: u16, constraints: &[Constraint]) -> SmallBuf<u16, STACK_CAP> {
143 let mut sizes: SmallBuf<u16, STACK_CAP> = SmallBuf::with_capacity(constraints.len());
144 for c in constraints {
145 sizes.push(c.base(total));
146 }
147
148 let mut used: u16 = 0;
151 for size in sizes.iter_mut() {
152 let room = total.saturating_sub(used);
153 *size = (*size).min(room);
154 used += *size;
155 }
156
157 let mut flexible: SmallBuf<(usize, u16, Option<u16>), STACK_CAP> =
163 SmallBuf::with_capacity(constraints.len());
164 for (i, c) in constraints.iter().enumerate() {
165 match c {
166 Constraint::Fill(weight) => flexible.push((i, *weight, None)),
167 Constraint::Min(_) => flexible.push((i, 1, None)),
168 Constraint::Max(cap) => flexible.push((i, 1, Some(*cap))),
169 Constraint::Fixed(_) | Constraint::Percent(_) => {}
170 }
171 }
172 if !flexible.is_empty() {
173 let remainder = total.saturating_sub(used);
174 let total_weight: u32 = flexible.iter().map(|&(_, w, _)| u32::from(w)).sum();
175 if let Some(total_weight) = std::num::NonZeroU32::new(total_weight) {
176 let mut shares: SmallBuf<u32, STACK_CAP> = SmallBuf::with_capacity(flexible.len());
182 let mut fracs: SmallBuf<u32, STACK_CAP> = SmallBuf::with_capacity(flexible.len());
183 let mut floor_sum: u32 = 0;
184 for &(_, weight, _) in flexible.iter() {
185 let product = u32::from(remainder) * u32::from(weight);
186 let share = product / total_weight;
187 fracs.push(product % total_weight);
188 shares.push(share);
189 floor_sum += share;
190 }
191 let mut leftover = u32::from(remainder).saturating_sub(floor_sum);
192 let mut order: SmallBuf<usize, STACK_CAP> = SmallBuf::with_capacity(flexible.len());
193 for idx in 0..flexible.len() {
194 order.push(idx);
195 }
196 order.sort_by(|&a, &b| fracs[b].cmp(&fracs[a]).then(a.cmp(&b)));
197 for &idx in order.iter() {
198 if leftover == 0 {
199 break;
200 }
201 shares[idx] += 1;
202 leftover -= 1;
203 }
204 for (k, &(i, _, cap)) in flexible.iter().enumerate() {
205 #[allow(clippy::cast_possible_truncation)]
206 let share = shares[k] as u16;
207 let grown = sizes[i].saturating_add(share);
208 sizes[i] = cap.map_or(grown, |max| grown.min(max));
209 }
210 }
211 }
212
213 sizes
214}
215
216#[must_use]
232pub fn split_v(area: Rect, constraints: &[Constraint]) -> Vec<Rect> {
233 let sizes = solve(area.height(), constraints);
234 let mut y = area.top();
235 sizes
236 .iter()
237 .copied()
238 .map(|h| {
239 let rect = Rect::new(area.left(), y, area.width(), h);
240 y = y.saturating_add(h);
241 rect
242 })
243 .collect()
244}
245
246#[must_use]
262pub fn split_h(area: Rect, constraints: &[Constraint]) -> Vec<Rect> {
263 let sizes = solve(area.width(), constraints);
264 let mut x = area.left();
265 sizes
266 .iter()
267 .copied()
268 .map(|w| {
269 let rect = Rect::new(x, area.top(), w, area.height());
270 x = x.saturating_add(w);
271 rect
272 })
273 .collect()
274}
275
276fn interleave_gaps(constraints: &[Constraint], spacing: u16) -> Vec<Constraint> {
282 let mut out = Vec::with_capacity(constraints.len().saturating_mul(2).saturating_sub(1));
283 for (i, &c) in constraints.iter().enumerate() {
284 if i > 0 {
285 out.push(Constraint::Fixed(spacing));
286 }
287 out.push(c);
288 }
289 out
290}
291
292#[must_use]
314pub fn split_h_spaced(area: Rect, constraints: &[Constraint], spacing: u16) -> Vec<Rect> {
315 if spacing == 0 || constraints.len() < 2 {
316 return split_h(area, constraints);
317 }
318 split_h(area, &interleave_gaps(constraints, spacing))
319 .into_iter()
320 .step_by(2)
321 .collect()
322}
323
324#[must_use]
330pub fn split_v_spaced(area: Rect, constraints: &[Constraint], spacing: u16) -> Vec<Rect> {
331 if spacing == 0 || constraints.len() < 2 {
332 return split_v(area, constraints);
333 }
334 split_v(area, &interleave_gaps(constraints, spacing))
335 .into_iter()
336 .step_by(2)
337 .collect()
338}
339
340#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
350pub enum Flex {
351 #[default]
354 Start,
355 End,
358 Center,
360 SpaceBetween,
363 SpaceAround,
366}
367
368fn place(total: u16, sizes: &[u16], flex: Flex) -> Vec<u16> {
372 let content: u16 = sizes.iter().fold(0u16, |a, &b| a.saturating_add(b));
373 let slack = total.saturating_sub(content);
374 let n = sizes.len();
375 let mut offsets = Vec::with_capacity(n);
376
377 let packed_from = |start: u16| {
378 let mut pos = start;
379 sizes
380 .iter()
381 .map(|&s| {
382 let at = pos;
383 pos = pos.saturating_add(s);
384 at
385 })
386 .collect::<Vec<u16>>()
387 };
388
389 match flex {
390 Flex::End => offsets = packed_from(slack),
391 Flex::Center => offsets = packed_from(slack / 2),
392 Flex::SpaceBetween if n > 1 => {
393 #[allow(clippy::cast_possible_truncation)]
394 let gaps = n as u16 - 1;
395 let gap = slack / gaps;
396 let mut extra = slack % gaps;
397 let mut pos = 0;
398 for (i, &s) in sizes.iter().enumerate() {
399 offsets.push(pos);
400 pos = pos.saturating_add(s);
401 if i + 1 < n {
402 pos = pos.saturating_add(gap + u16::from(extra > 0));
403 extra = extra.saturating_sub(1);
404 }
405 }
406 }
407 Flex::Start | Flex::SpaceBetween => offsets = packed_from(0),
408 Flex::SpaceAround => {
409 #[allow(clippy::cast_possible_truncation)]
410 let gaps = n as u16 + 1;
411 let unit = slack / gaps;
412 let mut extra = slack % gaps;
413 let mut pos = unit + u16::from(extra > 0);
414 extra = extra.saturating_sub(u16::from(extra > 0));
415 for &s in sizes {
416 offsets.push(pos);
417 pos = pos.saturating_add(s);
418 pos = pos.saturating_add(unit + u16::from(extra > 0));
419 extra = extra.saturating_sub(u16::from(extra > 0));
420 }
421 }
422 }
423
424 offsets
425}
426
427#[must_use]
430pub fn split_v_flex(area: Rect, constraints: &[Constraint], flex: Flex) -> Vec<Rect> {
431 let sizes = solve(area.height(), constraints);
432 let offsets = place(area.height(), &sizes, flex);
433 offsets
434 .into_iter()
435 .zip(sizes.iter().copied())
436 .map(|(y, h)| Rect::new(area.left(), area.top().saturating_add(y), area.width(), h))
437 .collect()
438}
439
440#[must_use]
443pub fn split_h_flex(area: Rect, constraints: &[Constraint], flex: Flex) -> Vec<Rect> {
444 let sizes = solve(area.width(), constraints);
445 let offsets = place(area.width(), &sizes, flex);
446 offsets
447 .into_iter()
448 .zip(sizes.iter().copied())
449 .map(|(x, w)| Rect::new(area.left().saturating_add(x), area.top(), w, area.height()))
450 .collect()
451}
452
453#[must_use]
462pub fn centered_rect(screen: Rect, width: u16, height: u16) -> Rect {
463 let width = width.min(screen.width());
464 let height = height.min(screen.height());
465 let x = screen.left() + (screen.width() - width) / 2;
466 let y = screen.top() + (screen.height() - height) / 2;
467 Rect::new(x, y, width, height)
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473
474 #[test]
475 fn vertical_split_sums_and_clamps() {
476 let area = Rect::new(0, 0, 20, 10);
477 let panes = split_v(
478 area,
479 &[
480 Constraint::Fixed(1),
481 Constraint::Fill(1),
482 Constraint::Fixed(1),
483 ],
484 );
485 assert_eq!(panes.len(), 3);
486 assert_eq!(panes[0].height(), 1);
488 assert_eq!(panes[1].height(), 8);
489 assert_eq!(panes[2].height(), 1);
490 assert_eq!(panes[0].top(), 0);
492 assert_eq!(panes[1].top(), 1);
493 assert_eq!(panes[2].top(), 9);
494 assert_eq!(panes[2].bottom(), area.bottom());
495 for p in &panes {
497 assert_eq!(p.width(), 20);
498 }
499 }
500
501 #[test]
502 fn horizontal_percent_and_fill() {
503 let area = Rect::new(0, 0, 100, 5);
504 let panes = split_h(area, &[Constraint::Percent(30), Constraint::Fill(1)]);
505 assert_eq!(panes[0].width(), 30);
506 assert_eq!(panes[1].width(), 70);
507 assert_eq!(panes[0].left(), 0);
508 assert_eq!(panes[1].left(), 30);
509 assert_eq!(panes[1].right(), area.right());
510 }
511
512 #[test]
513 fn fill_remainder_distributes_evenly() {
514 let area = Rect::new(0, 0, 10, 1);
515 let panes = split_h(
517 area,
518 &[
519 Constraint::Fill(1),
520 Constraint::Fill(1),
521 Constraint::Fill(1),
522 ],
523 );
524 let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
525 assert_eq!(widths, vec![4, 3, 3]);
526 assert_eq!(widths.iter().sum::<u16>(), 10);
527 }
528
529 #[test]
530 fn oversized_fixed_is_clamped() {
531 let area = Rect::new(0, 0, 5, 3);
532 let panes = split_h(area, &[Constraint::Fixed(10), Constraint::Fixed(10)]);
534 assert_eq!(panes[0].width(), 5);
535 assert_eq!(panes[1].width(), 0);
536 for p in &panes {
538 assert!(p.right() <= area.right());
539 }
540 }
541
542 #[test]
543 fn no_fill_leaves_gap() {
544 let area = Rect::new(0, 0, 10, 4);
545 let panes = split_v(area, &[Constraint::Fixed(2), Constraint::Fixed(2)]);
546 assert_eq!(panes[0].height(), 2);
548 assert_eq!(panes[1].height(), 2);
549 assert_eq!(panes[1].bottom(), 4);
550 }
551
552 #[test]
553 fn min_gets_at_least_its_floor_plus_a_share() {
554 let area = Rect::new(0, 0, 10, 1);
555 let panes = split_h(area, &[Constraint::Min(3), Constraint::Fill(1)]);
560 let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
561 assert_eq!(widths, vec![7, 3]);
562 assert_eq!(widths.iter().sum::<u16>(), 10);
563 }
564
565 #[test]
566 fn min_floor_holds_when_share_would_be_smaller() {
567 let area = Rect::new(0, 0, 10, 1);
568 let panes = split_h(
573 area,
574 &[Constraint::Min(4), Constraint::Fill(1), Constraint::Fill(1)],
575 );
576 let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
577 assert_eq!(widths[0], 6);
578 assert_eq!(widths[1], 2);
579 assert_eq!(widths[2], 2);
580 assert_eq!(widths.iter().sum::<u16>(), 10);
581 }
582
583 #[test]
584 fn max_caps_its_share_and_leaves_the_rest_unclaimed() {
585 let area = Rect::new(0, 0, 10, 1);
586 let panes = split_h(area, &[Constraint::Fill(1), Constraint::Max(2)]);
589 let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
590 assert_eq!(widths, vec![5, 2]);
591 assert_eq!(widths.iter().sum::<u16>(), 7);
592 }
593
594 #[test]
595 fn weighted_fill_splits_proportionally() {
596 let area = Rect::new(0, 0, 12, 1);
597 let panes = split_h(area, &[Constraint::Fill(1), Constraint::Fill(2)]);
599 let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
600 assert_eq!(widths, vec![4, 8]);
601 assert_eq!(widths.iter().sum::<u16>(), 12);
602 }
603
604 #[test]
605 fn weighted_fill_at_weight_one_matches_equal_distribution() {
606 let area = Rect::new(0, 0, 10, 1);
607 let panes = split_h(
610 area,
611 &[
612 Constraint::Fill(5),
613 Constraint::Fill(5),
614 Constraint::Fill(5),
615 ],
616 );
617 let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
618 assert_eq!(widths, vec![4, 3, 3]);
619 assert_eq!(widths.iter().sum::<u16>(), 10);
620 }
621
622 #[test]
623 fn weighted_fill_leftover_goes_to_the_largest_fractional_share() {
624 let area = Rect::new(0, 0, 10, 1);
625 let panes = split_h(
630 area,
631 &[
632 Constraint::Fill(3),
633 Constraint::Fill(2),
634 Constraint::Fill(2),
635 ],
636 );
637 let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
638 assert_eq!(widths, vec![4, 3, 3]);
639 assert_eq!(widths.iter().sum::<u16>(), 10);
640 }
641
642 #[test]
643 fn fill_weight_zero_claims_no_share_of_the_remainder() {
644 let area = Rect::new(0, 0, 10, 1);
645 let panes = split_h(area, &[Constraint::Fill(0), Constraint::Fill(1)]);
646 let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
647 assert_eq!(widths, vec![0, 10]);
648 }
649
650 #[test]
651 fn all_fill_weights_zero_leaves_the_remainder_unclaimed() {
652 let area = Rect::new(0, 0, 10, 1);
653 let panes = split_h(area, &[Constraint::Fill(0), Constraint::Fill(0)]);
654 let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
655 assert_eq!(widths, vec![0, 0]);
656 }
657
658 #[test]
659 fn weighted_fill_mixes_with_min_and_max_at_weight_one() {
660 let area = Rect::new(0, 0, 20, 1);
661 let panes = split_h(
666 area,
667 &[
668 Constraint::Fill(3),
669 Constraint::Min(2),
670 Constraint::Fill(1),
671 Constraint::Max(10),
672 ],
673 );
674 let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
675 assert_eq!(widths, vec![9, 5, 3, 3]);
676 assert_eq!(widths.iter().sum::<u16>(), 20);
677 }
678
679 #[test]
680 fn flex_start_matches_split_v() {
681 let area = Rect::new(0, 0, 10, 4);
682 let constraints = [Constraint::Fixed(2), Constraint::Fixed(2)];
683 let legacy = split_v(area, &constraints);
684 let flexed = split_v_flex(area, &constraints, Flex::Start);
685 assert_eq!(legacy, flexed);
686 }
687
688 #[test]
689 fn flex_end_pushes_leftover_before_the_panes() {
690 let area = Rect::new(0, 0, 10, 10);
691 let panes = split_v_flex(
692 area,
693 &[Constraint::Fixed(2), Constraint::Fixed(2)],
694 Flex::End,
695 );
696 assert_eq!(panes[0].top(), 6);
698 assert_eq!(panes[1].top(), 8);
699 assert_eq!(panes[1].bottom(), 10);
700 }
701
702 #[test]
703 fn flex_center_splits_leftover_around_the_panes() {
704 let area = Rect::new(0, 0, 10, 10);
705 let panes = split_v_flex(area, &[Constraint::Fixed(4)], Flex::Center);
706 assert_eq!(panes[0].top(), 3);
708 assert_eq!(panes[0].bottom(), 7);
709 }
710
711 #[test]
712 fn flex_space_between_puts_leftover_between_panes_only() {
713 let area = Rect::new(0, 0, 10, 1);
714 let panes = split_h_flex(
715 area,
716 &[Constraint::Fixed(2), Constraint::Fixed(2)],
717 Flex::SpaceBetween,
718 );
719 assert_eq!(panes[0].left(), 0);
721 assert_eq!(panes[0].right(), 2);
722 assert_eq!(panes[1].left(), 8);
723 assert_eq!(panes[1].right(), 10);
724 }
725
726 #[test]
727 fn flex_space_around_puts_equal_gaps_at_both_edges() {
728 let area = Rect::new(0, 0, 9, 1);
729 let panes = split_h_flex(area, &[Constraint::Fixed(3)], Flex::SpaceAround);
730 assert_eq!(panes[0].left(), 3);
732 assert_eq!(panes[0].right(), 6);
733 }
734
735 #[test]
736 fn spaced_split_carves_out_gaps_between_panes() {
737 let area = Rect::new(0, 0, 59, 6);
738 let panes = split_h_spaced(
739 area,
740 &[
741 Constraint::Fill(1),
742 Constraint::Fill(1),
743 Constraint::Fill(1),
744 ],
745 1,
746 );
747 assert_eq!(panes.len(), 3);
748 let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
749 assert_eq!(widths, vec![19, 19, 19]);
750 assert_eq!(panes[1].left(), panes[0].right() + 1);
752 assert_eq!(panes[2].left(), panes[1].right() + 1);
753 }
754
755 #[test]
756 fn spaced_split_falls_back_with_one_pane_or_no_spacing() {
757 let area = Rect::new(0, 0, 10, 1);
758 assert_eq!(
759 split_h_spaced(area, &[Constraint::Fill(1)], 1),
760 split_h(area, &[Constraint::Fill(1)])
761 );
762 assert_eq!(
763 split_h_spaced(area, &[Constraint::Fill(1), Constraint::Fill(1)], 0),
764 split_h(area, &[Constraint::Fill(1), Constraint::Fill(1)])
765 );
766 }
767
768 #[test]
769 fn vertical_spaced_split_matches_horizontal_shape() {
770 let area = Rect::new(0, 0, 6, 59);
771 let panes = split_v_spaced(
772 area,
773 &[
774 Constraint::Fill(1),
775 Constraint::Fill(1),
776 Constraint::Fill(1),
777 ],
778 1,
779 );
780 let heights: Vec<u16> = panes.iter().map(Rect::height).collect();
781 assert_eq!(heights, vec![19, 19, 19]);
782 assert_eq!(panes[1].top(), panes[0].bottom() + 1);
783 }
784
785 #[test]
786 fn centered_rect_centers_within_the_screen() {
787 let screen = Rect::new(0, 0, 20, 10);
788 let r = centered_rect(screen, 10, 4);
789 assert_eq!(r, Rect::new(5, 3, 10, 4));
790 }
791
792 #[test]
793 fn centered_rect_clamps_to_the_screen_size_when_larger() {
794 let screen = Rect::new(0, 0, 20, 10);
795 let r = centered_rect(screen, 100, 100);
796 assert_eq!(r, Rect::new(0, 0, 20, 10));
797 }
798
799 #[test]
800 fn centered_rect_respects_a_non_origin_screen() {
801 let screen = Rect::new(5, 5, 20, 10);
802 let r = centered_rect(screen, 10, 4);
803 assert_eq!(r, Rect::new(10, 8, 10, 4));
804 }
805
806 #[test]
812 fn split_beyond_stack_cap_matches_small_case_behavior() {
813 let panes = 20; let area = Rect::new(0, 0, panes as u16, 1);
815 let constraints = vec![Constraint::Fixed(1); panes];
816 let widths: Vec<u16> = split_h(area, &constraints)
817 .iter()
818 .map(Rect::width)
819 .collect();
820 assert_eq!(widths, vec![1u16; panes]);
821 assert_eq!(widths.iter().sum::<u16>(), panes as u16);
822 }
823
824 #[test]
828 fn weighted_fill_beyond_stack_cap_matches_small_case_proportions() {
829 let area = Rect::new(0, 0, 100, 1);
830 let constraints = vec![Constraint::Fill(1); 20];
833 let widths: Vec<u16> = split_h(area, &constraints)
834 .iter()
835 .map(Rect::width)
836 .collect();
837 assert_eq!(widths.len(), 20);
838 assert_eq!(widths.iter().sum::<u16>(), 100);
839 assert!(widths.iter().all(|&w| w == 5));
841 }
842}