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
63fn solve(total: u16, constraints: &[Constraint]) -> Vec<u16> {
65 let mut sizes: Vec<u16> = constraints.iter().map(|c| c.base(total)).collect();
66
67 let mut used: u16 = 0;
70 for size in &mut sizes {
71 let room = total.saturating_sub(used);
72 *size = (*size).min(room);
73 used += *size;
74 }
75
76 let flexible: Vec<(usize, u16, Option<u16>)> = constraints
82 .iter()
83 .enumerate()
84 .filter_map(|(i, c)| match c {
85 Constraint::Fill(weight) => Some((i, *weight, None)),
86 Constraint::Min(_) => Some((i, 1, None)),
87 Constraint::Max(cap) => Some((i, 1, Some(*cap))),
88 Constraint::Fixed(_) | Constraint::Percent(_) => None,
89 })
90 .collect();
91 if !flexible.is_empty() {
92 let remainder = total.saturating_sub(used);
93 let total_weight: u32 = flexible.iter().map(|&(_, w, _)| u32::from(w)).sum();
94 if let Some(total_weight) = std::num::NonZeroU32::new(total_weight) {
95 let mut shares: Vec<u32> = Vec::with_capacity(flexible.len());
101 let mut fracs: Vec<u32> = Vec::with_capacity(flexible.len());
102 let mut floor_sum: u32 = 0;
103 for &(_, weight, _) in &flexible {
104 let product = u32::from(remainder) * u32::from(weight);
105 let share = product / total_weight;
106 fracs.push(product % total_weight);
107 shares.push(share);
108 floor_sum += share;
109 }
110 let mut leftover = u32::from(remainder).saturating_sub(floor_sum);
111 let mut order: Vec<usize> = (0..flexible.len()).collect();
112 order.sort_by(|&a, &b| fracs[b].cmp(&fracs[a]).then(a.cmp(&b)));
113 for idx in order {
114 if leftover == 0 {
115 break;
116 }
117 shares[idx] += 1;
118 leftover -= 1;
119 }
120 for (k, &(i, _, cap)) in flexible.iter().enumerate() {
121 #[allow(clippy::cast_possible_truncation)]
122 let share = shares[k] as u16;
123 let grown = sizes[i].saturating_add(share);
124 sizes[i] = cap.map_or(grown, |max| grown.min(max));
125 }
126 }
127 }
128
129 sizes
130}
131
132#[must_use]
148pub fn split_v(area: Rect, constraints: &[Constraint]) -> Vec<Rect> {
149 let sizes = solve(area.height(), constraints);
150 let mut y = area.top();
151 sizes
152 .into_iter()
153 .map(|h| {
154 let rect = Rect::new(area.left(), y, area.width(), h);
155 y = y.saturating_add(h);
156 rect
157 })
158 .collect()
159}
160
161#[must_use]
177pub fn split_h(area: Rect, constraints: &[Constraint]) -> Vec<Rect> {
178 let sizes = solve(area.width(), constraints);
179 let mut x = area.left();
180 sizes
181 .into_iter()
182 .map(|w| {
183 let rect = Rect::new(x, area.top(), w, area.height());
184 x = x.saturating_add(w);
185 rect
186 })
187 .collect()
188}
189
190fn interleave_gaps(constraints: &[Constraint], spacing: u16) -> Vec<Constraint> {
196 let mut out = Vec::with_capacity(constraints.len().saturating_mul(2).saturating_sub(1));
197 for (i, &c) in constraints.iter().enumerate() {
198 if i > 0 {
199 out.push(Constraint::Fixed(spacing));
200 }
201 out.push(c);
202 }
203 out
204}
205
206#[must_use]
228pub fn split_h_spaced(area: Rect, constraints: &[Constraint], spacing: u16) -> Vec<Rect> {
229 if spacing == 0 || constraints.len() < 2 {
230 return split_h(area, constraints);
231 }
232 split_h(area, &interleave_gaps(constraints, spacing))
233 .into_iter()
234 .step_by(2)
235 .collect()
236}
237
238#[must_use]
244pub fn split_v_spaced(area: Rect, constraints: &[Constraint], spacing: u16) -> Vec<Rect> {
245 if spacing == 0 || constraints.len() < 2 {
246 return split_v(area, constraints);
247 }
248 split_v(area, &interleave_gaps(constraints, spacing))
249 .into_iter()
250 .step_by(2)
251 .collect()
252}
253
254#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
264pub enum Flex {
265 #[default]
268 Start,
269 End,
272 Center,
274 SpaceBetween,
277 SpaceAround,
280}
281
282fn place(total: u16, sizes: &[u16], flex: Flex) -> Vec<u16> {
286 let content: u16 = sizes.iter().fold(0u16, |a, &b| a.saturating_add(b));
287 let slack = total.saturating_sub(content);
288 let n = sizes.len();
289 let mut offsets = Vec::with_capacity(n);
290
291 let packed_from = |start: u16| {
292 let mut pos = start;
293 sizes
294 .iter()
295 .map(|&s| {
296 let at = pos;
297 pos = pos.saturating_add(s);
298 at
299 })
300 .collect::<Vec<u16>>()
301 };
302
303 match flex {
304 Flex::End => offsets = packed_from(slack),
305 Flex::Center => offsets = packed_from(slack / 2),
306 Flex::SpaceBetween if n > 1 => {
307 #[allow(clippy::cast_possible_truncation)]
308 let gaps = n as u16 - 1;
309 let gap = slack / gaps;
310 let mut extra = slack % gaps;
311 let mut pos = 0;
312 for (i, &s) in sizes.iter().enumerate() {
313 offsets.push(pos);
314 pos = pos.saturating_add(s);
315 if i + 1 < n {
316 pos = pos.saturating_add(gap + u16::from(extra > 0));
317 extra = extra.saturating_sub(1);
318 }
319 }
320 }
321 Flex::Start | Flex::SpaceBetween => offsets = packed_from(0),
322 Flex::SpaceAround => {
323 #[allow(clippy::cast_possible_truncation)]
324 let gaps = n as u16 + 1;
325 let unit = slack / gaps;
326 let mut extra = slack % gaps;
327 let mut pos = unit + u16::from(extra > 0);
328 extra = extra.saturating_sub(u16::from(extra > 0));
329 for &s in sizes {
330 offsets.push(pos);
331 pos = pos.saturating_add(s);
332 pos = pos.saturating_add(unit + u16::from(extra > 0));
333 extra = extra.saturating_sub(u16::from(extra > 0));
334 }
335 }
336 }
337
338 offsets
339}
340
341#[must_use]
344pub fn split_v_flex(area: Rect, constraints: &[Constraint], flex: Flex) -> Vec<Rect> {
345 let sizes = solve(area.height(), constraints);
346 let offsets = place(area.height(), &sizes, flex);
347 offsets
348 .into_iter()
349 .zip(sizes)
350 .map(|(y, h)| Rect::new(area.left(), area.top().saturating_add(y), area.width(), h))
351 .collect()
352}
353
354#[must_use]
357pub fn split_h_flex(area: Rect, constraints: &[Constraint], flex: Flex) -> Vec<Rect> {
358 let sizes = solve(area.width(), constraints);
359 let offsets = place(area.width(), &sizes, flex);
360 offsets
361 .into_iter()
362 .zip(sizes)
363 .map(|(x, w)| Rect::new(area.left().saturating_add(x), area.top(), w, area.height()))
364 .collect()
365}
366
367#[must_use]
376pub fn centered_rect(screen: Rect, width: u16, height: u16) -> Rect {
377 let width = width.min(screen.width());
378 let height = height.min(screen.height());
379 let x = screen.left() + (screen.width() - width) / 2;
380 let y = screen.top() + (screen.height() - height) / 2;
381 Rect::new(x, y, width, height)
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387
388 #[test]
389 fn vertical_split_sums_and_clamps() {
390 let area = Rect::new(0, 0, 20, 10);
391 let panes = split_v(
392 area,
393 &[
394 Constraint::Fixed(1),
395 Constraint::Fill(1),
396 Constraint::Fixed(1),
397 ],
398 );
399 assert_eq!(panes.len(), 3);
400 assert_eq!(panes[0].height(), 1);
402 assert_eq!(panes[1].height(), 8);
403 assert_eq!(panes[2].height(), 1);
404 assert_eq!(panes[0].top(), 0);
406 assert_eq!(panes[1].top(), 1);
407 assert_eq!(panes[2].top(), 9);
408 assert_eq!(panes[2].bottom(), area.bottom());
409 for p in &panes {
411 assert_eq!(p.width(), 20);
412 }
413 }
414
415 #[test]
416 fn horizontal_percent_and_fill() {
417 let area = Rect::new(0, 0, 100, 5);
418 let panes = split_h(area, &[Constraint::Percent(30), Constraint::Fill(1)]);
419 assert_eq!(panes[0].width(), 30);
420 assert_eq!(panes[1].width(), 70);
421 assert_eq!(panes[0].left(), 0);
422 assert_eq!(panes[1].left(), 30);
423 assert_eq!(panes[1].right(), area.right());
424 }
425
426 #[test]
427 fn fill_remainder_distributes_evenly() {
428 let area = Rect::new(0, 0, 10, 1);
429 let panes = split_h(
431 area,
432 &[
433 Constraint::Fill(1),
434 Constraint::Fill(1),
435 Constraint::Fill(1),
436 ],
437 );
438 let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
439 assert_eq!(widths, vec![4, 3, 3]);
440 assert_eq!(widths.iter().sum::<u16>(), 10);
441 }
442
443 #[test]
444 fn oversized_fixed_is_clamped() {
445 let area = Rect::new(0, 0, 5, 3);
446 let panes = split_h(area, &[Constraint::Fixed(10), Constraint::Fixed(10)]);
448 assert_eq!(panes[0].width(), 5);
449 assert_eq!(panes[1].width(), 0);
450 for p in &panes {
452 assert!(p.right() <= area.right());
453 }
454 }
455
456 #[test]
457 fn no_fill_leaves_gap() {
458 let area = Rect::new(0, 0, 10, 4);
459 let panes = split_v(area, &[Constraint::Fixed(2), Constraint::Fixed(2)]);
460 assert_eq!(panes[0].height(), 2);
462 assert_eq!(panes[1].height(), 2);
463 assert_eq!(panes[1].bottom(), 4);
464 }
465
466 #[test]
467 fn min_gets_at_least_its_floor_plus_a_share() {
468 let area = Rect::new(0, 0, 10, 1);
469 let panes = split_h(area, &[Constraint::Min(3), Constraint::Fill(1)]);
474 let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
475 assert_eq!(widths, vec![7, 3]);
476 assert_eq!(widths.iter().sum::<u16>(), 10);
477 }
478
479 #[test]
480 fn min_floor_holds_when_share_would_be_smaller() {
481 let area = Rect::new(0, 0, 10, 1);
482 let panes = split_h(
487 area,
488 &[Constraint::Min(4), Constraint::Fill(1), Constraint::Fill(1)],
489 );
490 let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
491 assert_eq!(widths[0], 6);
492 assert_eq!(widths[1], 2);
493 assert_eq!(widths[2], 2);
494 assert_eq!(widths.iter().sum::<u16>(), 10);
495 }
496
497 #[test]
498 fn max_caps_its_share_and_leaves_the_rest_unclaimed() {
499 let area = Rect::new(0, 0, 10, 1);
500 let panes = split_h(area, &[Constraint::Fill(1), Constraint::Max(2)]);
503 let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
504 assert_eq!(widths, vec![5, 2]);
505 assert_eq!(widths.iter().sum::<u16>(), 7);
506 }
507
508 #[test]
509 fn weighted_fill_splits_proportionally() {
510 let area = Rect::new(0, 0, 12, 1);
511 let panes = split_h(area, &[Constraint::Fill(1), Constraint::Fill(2)]);
513 let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
514 assert_eq!(widths, vec![4, 8]);
515 assert_eq!(widths.iter().sum::<u16>(), 12);
516 }
517
518 #[test]
519 fn weighted_fill_at_weight_one_matches_equal_distribution() {
520 let area = Rect::new(0, 0, 10, 1);
521 let panes = split_h(
524 area,
525 &[
526 Constraint::Fill(5),
527 Constraint::Fill(5),
528 Constraint::Fill(5),
529 ],
530 );
531 let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
532 assert_eq!(widths, vec![4, 3, 3]);
533 assert_eq!(widths.iter().sum::<u16>(), 10);
534 }
535
536 #[test]
537 fn weighted_fill_leftover_goes_to_the_largest_fractional_share() {
538 let area = Rect::new(0, 0, 10, 1);
539 let panes = split_h(
544 area,
545 &[
546 Constraint::Fill(3),
547 Constraint::Fill(2),
548 Constraint::Fill(2),
549 ],
550 );
551 let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
552 assert_eq!(widths, vec![4, 3, 3]);
553 assert_eq!(widths.iter().sum::<u16>(), 10);
554 }
555
556 #[test]
557 fn fill_weight_zero_claims_no_share_of_the_remainder() {
558 let area = Rect::new(0, 0, 10, 1);
559 let panes = split_h(area, &[Constraint::Fill(0), Constraint::Fill(1)]);
560 let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
561 assert_eq!(widths, vec![0, 10]);
562 }
563
564 #[test]
565 fn all_fill_weights_zero_leaves_the_remainder_unclaimed() {
566 let area = Rect::new(0, 0, 10, 1);
567 let panes = split_h(area, &[Constraint::Fill(0), Constraint::Fill(0)]);
568 let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
569 assert_eq!(widths, vec![0, 0]);
570 }
571
572 #[test]
573 fn weighted_fill_mixes_with_min_and_max_at_weight_one() {
574 let area = Rect::new(0, 0, 20, 1);
575 let panes = split_h(
580 area,
581 &[
582 Constraint::Fill(3),
583 Constraint::Min(2),
584 Constraint::Fill(1),
585 Constraint::Max(10),
586 ],
587 );
588 let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
589 assert_eq!(widths, vec![9, 5, 3, 3]);
590 assert_eq!(widths.iter().sum::<u16>(), 20);
591 }
592
593 #[test]
594 fn flex_start_matches_split_v() {
595 let area = Rect::new(0, 0, 10, 4);
596 let constraints = [Constraint::Fixed(2), Constraint::Fixed(2)];
597 let legacy = split_v(area, &constraints);
598 let flexed = split_v_flex(area, &constraints, Flex::Start);
599 assert_eq!(legacy, flexed);
600 }
601
602 #[test]
603 fn flex_end_pushes_leftover_before_the_panes() {
604 let area = Rect::new(0, 0, 10, 10);
605 let panes = split_v_flex(
606 area,
607 &[Constraint::Fixed(2), Constraint::Fixed(2)],
608 Flex::End,
609 );
610 assert_eq!(panes[0].top(), 6);
612 assert_eq!(panes[1].top(), 8);
613 assert_eq!(panes[1].bottom(), 10);
614 }
615
616 #[test]
617 fn flex_center_splits_leftover_around_the_panes() {
618 let area = Rect::new(0, 0, 10, 10);
619 let panes = split_v_flex(area, &[Constraint::Fixed(4)], Flex::Center);
620 assert_eq!(panes[0].top(), 3);
622 assert_eq!(panes[0].bottom(), 7);
623 }
624
625 #[test]
626 fn flex_space_between_puts_leftover_between_panes_only() {
627 let area = Rect::new(0, 0, 10, 1);
628 let panes = split_h_flex(
629 area,
630 &[Constraint::Fixed(2), Constraint::Fixed(2)],
631 Flex::SpaceBetween,
632 );
633 assert_eq!(panes[0].left(), 0);
635 assert_eq!(panes[0].right(), 2);
636 assert_eq!(panes[1].left(), 8);
637 assert_eq!(panes[1].right(), 10);
638 }
639
640 #[test]
641 fn flex_space_around_puts_equal_gaps_at_both_edges() {
642 let area = Rect::new(0, 0, 9, 1);
643 let panes = split_h_flex(area, &[Constraint::Fixed(3)], Flex::SpaceAround);
644 assert_eq!(panes[0].left(), 3);
646 assert_eq!(panes[0].right(), 6);
647 }
648
649 #[test]
650 fn spaced_split_carves_out_gaps_between_panes() {
651 let area = Rect::new(0, 0, 59, 6);
652 let panes = split_h_spaced(
653 area,
654 &[
655 Constraint::Fill(1),
656 Constraint::Fill(1),
657 Constraint::Fill(1),
658 ],
659 1,
660 );
661 assert_eq!(panes.len(), 3);
662 let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
663 assert_eq!(widths, vec![19, 19, 19]);
664 assert_eq!(panes[1].left(), panes[0].right() + 1);
666 assert_eq!(panes[2].left(), panes[1].right() + 1);
667 }
668
669 #[test]
670 fn spaced_split_falls_back_with_one_pane_or_no_spacing() {
671 let area = Rect::new(0, 0, 10, 1);
672 assert_eq!(
673 split_h_spaced(area, &[Constraint::Fill(1)], 1),
674 split_h(area, &[Constraint::Fill(1)])
675 );
676 assert_eq!(
677 split_h_spaced(area, &[Constraint::Fill(1), Constraint::Fill(1)], 0),
678 split_h(area, &[Constraint::Fill(1), Constraint::Fill(1)])
679 );
680 }
681
682 #[test]
683 fn vertical_spaced_split_matches_horizontal_shape() {
684 let area = Rect::new(0, 0, 6, 59);
685 let panes = split_v_spaced(
686 area,
687 &[
688 Constraint::Fill(1),
689 Constraint::Fill(1),
690 Constraint::Fill(1),
691 ],
692 1,
693 );
694 let heights: Vec<u16> = panes.iter().map(Rect::height).collect();
695 assert_eq!(heights, vec![19, 19, 19]);
696 assert_eq!(panes[1].top(), panes[0].bottom() + 1);
697 }
698
699 #[test]
700 fn centered_rect_centers_within_the_screen() {
701 let screen = Rect::new(0, 0, 20, 10);
702 let r = centered_rect(screen, 10, 4);
703 assert_eq!(r, Rect::new(5, 3, 10, 4));
704 }
705
706 #[test]
707 fn centered_rect_clamps_to_the_screen_size_when_larger() {
708 let screen = Rect::new(0, 0, 20, 10);
709 let r = centered_rect(screen, 100, 100);
710 assert_eq!(r, Rect::new(0, 0, 20, 10));
711 }
712
713 #[test]
714 fn centered_rect_respects_a_non_origin_screen() {
715 let screen = Rect::new(5, 5, 20, 10);
716 let r = centered_rect(screen, 10, 4);
717 assert_eq!(r, Rect::new(10, 8, 10, 4));
718 }
719}