use ratatui::layout::Rect;
pub const MIN_GROUP_HEIGHT: u16 = 4;
pub const SPLITTER_HIT_HEIGHT: u16 = 3;
pub fn boundary_row(rect: Rect) -> u16 {
rect.y + rect.height.saturating_sub(1)
}
pub fn split_groups(area: Rect, weights: &[u16]) -> Vec<Rect> {
if weights.is_empty() || area.height == 0 {
return Vec::new();
}
let capacity = (area.height / MIN_GROUP_HEIGHT).max(1) as usize;
let count = weights.len().min(capacity);
let weights = &weights[..count];
let total: u32 = weights.iter().map(|w| u32::from((*w).max(1))).sum();
let height = u32::from(area.height);
let mut heights: Vec<u16> = weights
.iter()
.map(|w| {
let raw = height * u32::from((*w).max(1)) / total;
u16::try_from(raw).unwrap_or(u16::MAX)
})
.collect();
let assigned: u16 = heights.iter().copied().sum();
heights[0] += area.height.saturating_sub(assigned);
for i in 0..heights.len() {
while heights[i] < MIN_GROUP_HEIGHT {
let Some(donor) = tallest_donor(&heights, i) else {
break;
};
heights[donor] -= 1;
heights[i] += 1;
}
}
let mut y = area.y;
let mut rects = Vec::with_capacity(count);
for height in heights {
rects.push(Rect::new(area.x, y, area.width, height));
y += height;
}
rects
}
fn tallest_donor(heights: &[u16], skip: usize) -> Option<usize> {
heights
.iter()
.enumerate()
.filter(|(i, h)| *i != skip && **h > MIN_GROUP_HEIGHT)
.max_by_key(|(_, h)| **h)
.map(|(i, _)| i)
}
pub fn drag_splitter(area: Rect, weights: &[u16], index: usize, y: u16) -> Vec<u16> {
let mut next = weights.to_vec();
let rects = split_groups(area, weights);
if index + 1 >= rects.len() {
return next;
}
let (upper, lower) = (rects[index], rects[index + 1]);
let span = upper.height + lower.height;
if span < MIN_GROUP_HEIGHT * 2 {
return next; }
let wanted = y.saturating_sub(upper.y) + 1;
let upper_height = wanted.clamp(MIN_GROUP_HEIGHT, span - MIN_GROUP_HEIGHT);
for (slot, rect) in next.iter_mut().zip(rects.iter()) {
*slot = rect.height.max(1);
}
next[index] = upper_height;
next[index + 1] = span - upper_height;
next
}
pub fn even_weights(count: usize) -> Vec<u16> {
vec![1; count]
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn heights(rects: &[Rect]) -> Vec<u16> {
rects.iter().map(|r| r.height).collect()
}
#[test]
fn one_group_takes_the_whole_area() {
let area = Rect::new(10, 2, 40, 20);
let rects = split_groups(area, &[1]);
assert_eq!(rects, vec![area]);
}
#[test]
fn groups_tile_the_area_exactly_with_no_gap_or_overlap() {
let area = Rect::new(0, 5, 30, 21);
for weights in [vec![1, 1], vec![2, 1], vec![1, 3, 1], vec![5, 1, 1, 2]] {
let rects = split_groups(area, &weights);
assert_eq!(rects.len(), weights.len(), "{weights:?}");
assert_eq!(rects[0].y, area.y, "{weights:?}");
let total: u16 = heights(&rects).iter().sum();
assert_eq!(total, area.height, "{weights:?}");
for pair in rects.windows(2) {
assert_eq!(pair[1].y, pair[0].y + pair[0].height, "{weights:?}");
}
assert!(rects.iter().all(|r| r.height >= MIN_GROUP_HEIGHT));
}
}
#[test]
fn weights_are_respected_in_proportion() {
let rects = split_groups(Rect::new(0, 0, 20, 30), &[2, 1]);
assert_eq!(heights(&rects), vec![20, 10]);
}
#[test]
fn a_group_below_the_minimum_is_lifted_out_of_the_tallest() {
let rects = split_groups(Rect::new(0, 0, 20, 40), &[1, 99]);
assert_eq!(rects[0].height, MIN_GROUP_HEIGHT);
assert_eq!(rects[1].height, 40 - MIN_GROUP_HEIGHT);
}
#[test]
fn groups_that_cannot_fit_the_minimum_are_dropped() {
let rects = split_groups(Rect::new(0, 0, 20, 9), &[1, 1, 1]);
assert_eq!(rects.len(), 2);
assert_eq!(heights(&rects).iter().sum::<u16>(), 9);
assert_eq!(split_groups(Rect::new(0, 0, 20, 1), &[1, 1]).len(), 1);
assert!(split_groups(Rect::new(0, 0, 20, 0), &[1]).is_empty());
assert!(split_groups(Rect::new(0, 0, 20, 10), &[]).is_empty());
}
#[test]
fn the_boundary_row_is_a_groups_last_row() {
let area = Rect::new(0, 0, 20, 30);
let rects = split_groups(area, &[1, 1]);
assert_eq!(boundary_row(rects[0]), 14);
assert_eq!(boundary_row(rects[0]), rects[1].y - 1);
assert_eq!(boundary_row(Rect::new(0, 5, 10, 0)), 5, "an empty rect");
}
#[test]
fn dragging_a_splitter_moves_only_its_two_groups() {
let area = Rect::new(0, 0, 20, 30);
let weights = [1, 1, 1];
let before = split_groups(area, &weights);
let after = split_groups(area, &drag_splitter(area, &weights, 0, 3));
assert_eq!(after[2].height, before[2].height);
assert!(after[0].height < before[0].height);
assert_eq!(
after[0].height + after[1].height,
before[0].height + before[1].height
);
}
#[test]
fn dragging_to_either_extreme_clamps_the_opposite_group_to_the_minimum() {
let area = Rect::new(0, 0, 20, 30);
let weights = [1, 1];
let up = split_groups(area, &drag_splitter(area, &weights, 0, 0));
assert_eq!(up[0].height, MIN_GROUP_HEIGHT);
assert_eq!(up[0].height + up[1].height, 30);
let down = split_groups(area, &drag_splitter(area, &weights, 0, 29));
assert_eq!(down[1].height, MIN_GROUP_HEIGHT);
assert_eq!(down[0].height + down[1].height, 30);
let way_down = split_groups(area, &drag_splitter(area, &weights, 0, 500));
assert_eq!(heights(&way_down), heights(&down));
}
#[test]
fn dragging_a_splitter_that_does_not_exist_changes_nothing() {
let area = Rect::new(0, 0, 20, 30);
assert_eq!(drag_splitter(area, &[1, 1], 5, 10), vec![1, 1]);
assert_eq!(drag_splitter(area, &[1], 0, 10), vec![1]);
assert_eq!(
drag_splitter(Rect::new(0, 0, 20, 0), &[1, 1], 0, 0),
vec![1, 1]
);
}
#[test]
fn a_drag_round_trips_back_to_where_it_started() {
let area = Rect::new(0, 0, 20, 40);
let weights = vec![1, 1];
let original = boundary_row(split_groups(area, &weights)[0]);
let dragged = drag_splitter(area, &weights, 0, 10);
let back = drag_splitter(area, &dragged, 0, original);
assert_eq!(boundary_row(split_groups(area, &back)[0]), original);
}
#[test]
fn even_weights_resets_the_stack() {
assert_eq!(even_weights(3), vec![1, 1, 1]);
let area = Rect::new(0, 0, 20, 30);
let rects = split_groups(area, &even_weights(3));
assert_eq!(heights(&rects), vec![10, 10, 10]);
}
}