use std::collections::HashSet;
use std::hash::BuildHasher;
use ratatui::layout::Rect;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Composition(pub Vec<usize>);
impl Composition {
#[must_use]
pub fn total(&self) -> usize {
self.0.iter().sum()
}
}
#[derive(Debug, Clone)]
pub struct PanelRect {
pub rect: Rect,
pub row: usize,
pub col: usize,
pub index: usize,
}
#[derive(Debug)]
pub struct PanelLayout {
pub panels: Vec<PanelRect>,
}
#[must_use]
pub fn compute_layout<S: BuildHasher>(
area: Rect,
composition: &Composition,
pinned: &HashSet<usize, S>,
pin_ratio: f64,
) -> PanelLayout {
let num_rows = composition.0.len();
if num_rows == 0 || area.height == 0 || area.width == 0 {
return PanelLayout { panels: Vec::new() };
}
let mut global_idx = 0usize;
let row_has_pin: Vec<bool> = composition
.0
.iter()
.map(|&cols| {
let has = (global_idx..global_idx + cols).any(|i| pinned.contains(&i));
global_idx += cols;
has
})
.collect();
let row_weights: Vec<f64> = row_has_pin
.iter()
.map(|&has_pin| if has_pin { pin_ratio } else { 1.0 })
.collect();
let row_sizes = distribute_weighted(area.height, &row_weights);
let mut panels = Vec::with_capacity(composition.total());
let mut y = area.y;
let mut panel_index = 0usize;
for (row_idx, &cols_in_row) in composition.0.iter().enumerate() {
let h = row_sizes[row_idx];
let col_weights: Vec<f64> = (0..cols_in_row)
.map(|c| {
if pinned.contains(&(panel_index + c)) {
pin_ratio
} else {
1.0
}
})
.collect();
let col_widths = distribute_weighted(area.width, &col_weights);
let mut x = area.x;
for (col_idx, &w) in col_widths.iter().enumerate() {
panels.push(PanelRect {
rect: Rect::new(x, y, w, h),
row: row_idx,
col: col_idx,
index: panel_index,
});
x += w;
panel_index += 1;
}
y += h;
}
PanelLayout { panels }
}
fn distribute_weighted(total: u16, weights: &[f64]) -> Vec<u16> {
let n = weights.len();
if n == 0 {
return Vec::new();
}
let sum: f64 = weights.iter().sum();
if sum <= 0.0 {
return distribute_weighted(total, &vec![1.0; n]);
}
let mut sizes = Vec::with_capacity(n);
let mut remainders = Vec::with_capacity(n);
let mut allocated = 0u16;
for (i, &w) in weights.iter().enumerate() {
let exact = f64::from(total) * w / sum;
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "exact is non-negative and bounded by u16::MAX"
)]
let floor = exact.floor() as u16;
sizes.push(floor);
remainders.push((exact - f64::from(floor), i));
allocated += floor;
}
let mut leftover = total - allocated;
remainders.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
for &(_, idx) in &remainders {
if leftover == 0 {
break;
}
sizes[idx] += 1;
leftover -= 1;
}
sizes
}
const fn isqrt_ceil(n: usize) -> usize {
if n == 0 {
return 0;
}
let s = n.isqrt();
if s * s == n { s } else { s + 1 }
}
#[must_use]
pub fn curated_layouts(n: usize) -> Vec<Composition> {
match n {
0 => vec![],
1 => vec![Composition(vec![1])],
2 => vec![Composition(vec![1, 1]), Composition(vec![2])],
3 => vec![
Composition(vec![1, 1, 1]),
Composition(vec![3]),
Composition(vec![2, 1]),
Composition(vec![1, 2]),
],
4 => vec![
Composition(vec![2, 2]),
Composition(vec![1, 1, 1, 1]),
Composition(vec![4]),
Composition(vec![2, 1, 1]),
Composition(vec![1, 1, 2]),
Composition(vec![3, 1]),
Composition(vec![1, 3]),
],
_ => {
let mut layouts = Vec::new();
let cols = isqrt_ceil(n);
let full_rows = n / cols;
let remainder = n % cols;
let mut balanced = vec![cols; full_rows];
if remainder > 0 {
balanced.push(remainder);
}
layouts.push(Composition(balanced));
layouts.push(Composition(vec![1; n]));
layouts.push(Composition(vec![n]));
let top = n.div_ceil(2);
let bottom = n - top;
if bottom > 0 {
layouts.push(Composition(vec![top, bottom]));
}
let top2 = n / 2;
let bottom2 = n - top2;
if top2 > 0 && vec![top2, bottom2] != vec![top, bottom] {
layouts.push(Composition(vec![top2, bottom2]));
}
layouts
}
}
}
#[must_use]
pub fn closest_composition(old: &Composition, new_n: usize) -> Composition {
if new_n == 0 {
return Composition(vec![]);
}
let old_total = old.total();
if new_n == old_total {
return old.clone();
}
let mut rows = old.0.clone();
if new_n < old_total {
let mut to_remove = old_total - new_n;
while to_remove > 0 {
let Some(last) = rows.last_mut() else {
break;
};
if *last <= to_remove {
to_remove -= *last;
rows.pop();
} else {
*last -= to_remove;
to_remove = 0;
}
}
if rows.is_empty() {
return curated_layouts(new_n)
.into_iter()
.next()
.unwrap_or_else(|| Composition(vec![new_n]));
}
return Composition(rows);
}
let to_add = new_n - old_total;
let max_row = rows.iter().copied().max().unwrap_or(1);
if let Some(last) = rows.last_mut()
&& *last + to_add <= max_row + 1
{
*last += to_add;
return Composition(rows);
}
rows.push(to_add);
Composition(rows)
}
pub const ARM_UP: u8 = 0b0000_0001;
pub const ARM_DOWN: u8 = 0b0000_0010;
pub const ARM_LEFT: u8 = 0b0000_0100;
pub const ARM_RIGHT: u8 = 0b0000_1000;
pub const HEAVY_UP: u8 = 0b0001_0000;
pub const HEAVY_DOWN: u8 = 0b0010_0000;
pub const HEAVY_LEFT: u8 = 0b0100_0000;
pub const HEAVY_RIGHT: u8 = 0b1000_0000;
#[must_use]
#[allow(
clippy::too_many_lines,
reason = "exhaustive match on junction flag combinations"
)]
pub const fn box_char(flags: u8) -> char {
let arms = flags & 0x0F;
let heavy = flags >> 4;
let hu = arms & ARM_UP != 0 && heavy & 0x01 != 0;
let hd = arms & ARM_DOWN != 0 && heavy & 0x02 != 0;
let hl = arms & ARM_LEFT != 0 && heavy & 0x04 != 0;
let hr = arms & ARM_RIGHT != 0 && heavy & 0x08 != 0;
let up = arms & ARM_UP != 0;
let down = arms & ARM_DOWN != 0;
let left = arms & ARM_LEFT != 0;
let right = arms & ARM_RIGHT != 0;
match (up, down, left, right) {
(true, true, false, false) => {
if hu || hd {
'┃'
} else {
'│'
}
}
(false, false, true, true) => {
if hl || hr {
'━'
} else {
'─'
}
}
(false, true, false, true) => match (hd, hr) {
(false, false) => '┌',
(true, true) => '┏',
(true, false) => '┎',
(false, true) => '┍',
},
(false, true, true, false) => match (hd, hl) {
(false, false) => '┐',
(true, true) => '┓',
(true, false) => '┒',
(false, true) => '┑',
},
(true, false, false, true) => match (hu, hr) {
(false, false) => '└',
(true, true) => '┗',
(true, false) => '┖',
(false, true) => '┕',
},
(true, false, true, false) => match (hu, hl) {
(false, false) => '┘',
(true, true) => '┛',
(true, false) => '┚',
(false, true) => '┙',
},
(true, true, false, true) => {
match (hu, hd, hr) {
(false, false, false) => '├',
(true, true, true) => '┣',
(true, true, false) => '┠',
(false, false, true) => '┝',
(true, false, false) => '┞',
(false, true, false) => '┟',
(true, false, true) => '┡',
(false, true, true) => '┢',
}
}
(true, true, true, false) => {
match (hu, hd, hl) {
(false, false, false) => '┤',
(true, true, true) => '┫',
(true, true, false) => '┨',
(false, false, true) => '┥',
(true, false, false) => '┦',
(false, true, false) => '┧',
(true, false, true) => '┩',
(false, true, true) => '┪',
}
}
(false, true, true, true) => {
match (hd, hl, hr) {
(false, false, false) => '┬',
(true, true, true) => '┳',
(true, false, false) => '┰',
(false, true, true) => '┯',
(false, true, false) => '┭',
(false, false, true) => '┮',
(true, true, false) => '┱',
(true, false, true) => '┲',
}
}
(true, false, true, true) => {
match (hu, hl, hr) {
(false, false, false) => '┴',
(true, true, true) => '┻',
(true, false, false) => '┸',
(false, true, true) => '┷',
(false, true, false) => '┵',
(false, false, true) => '┶',
(true, true, false) => '┹',
(true, false, true) => '┺',
}
}
(true, true, true, true) => match (hu, hd, hl, hr) {
(false, false, false, false) => '┼',
(true, true, true, true) => '╋',
(true, true, false, false) => '╂',
(false, false, true, true) => '┿',
(true, false, false, false) => '╀',
(false, true, false, false) => '╁',
(false, false, true, false) => '┽',
(false, false, false, true) => '┾',
(true, false, true, false) => '╃',
(true, false, false, true) => '╄',
(false, true, true, false) => '╅',
(false, true, false, true) => '╆',
(true, false, true, true) => '╇',
(false, true, true, true) => '╈',
(true, true, true, false) => '╉',
(true, true, false, true) => '╊',
},
(true, false, false, false) => {
if hu {
'╹'
} else {
'╵'
}
}
(false, true, false, false) => {
if hd {
'╻'
} else {
'╷'
}
}
(false, false, true, false) => {
if hl {
'╸'
} else {
'╴'
}
}
(false, false, false, true) => {
if hr {
'╺'
} else {
'╶'
}
}
(false, false, false, false) => ' ',
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PanelZone {
Content,
TitleBar,
Scrollbar,
}
#[must_use]
pub fn panel_at(layout: &PanelLayout, x: u16, y: u16) -> Option<usize> {
panel_zone_at(layout, x, y).and_then(|(idx, zone)| {
if zone == PanelZone::Content {
Some(idx)
} else {
None
}
})
}
#[must_use]
pub fn panel_zone_at(layout: &PanelLayout, x: u16, y: u16) -> Option<(usize, PanelZone)> {
for panel in &layout.panels {
let r = &panel.rect;
if x < r.x || x >= r.x + r.width || y < r.y || y >= r.y + r.height {
continue;
}
let right = r.x + r.width.saturating_sub(1);
if y == r.y {
return Some((panel.index, PanelZone::TitleBar));
}
if x == right {
return Some((panel.index, PanelZone::Scrollbar));
}
return Some((panel.index, PanelZone::Content));
}
None
}
#[cfg(test)]
#[allow(
clippy::expect_used,
reason = "tests use expect for readable assertions"
)]
mod tests {
use super::*;
#[test]
fn test_single_panel_fills_area() {
let area = Rect::new(0, 0, 80, 24);
let comp = Composition(vec![1]);
let layout = compute_layout(area, &comp, &HashSet::new(), 1.0);
assert_eq!(layout.panels.len(), 1);
assert_eq!(layout.panels[0].rect, area);
}
#[test]
fn test_two_panels_stacked() {
let area = Rect::new(0, 0, 80, 24);
let comp = Composition(vec![1, 1]);
let layout = compute_layout(area, &comp, &HashSet::new(), 1.0);
assert_eq!(layout.panels.len(), 2);
assert_eq!(layout.panels[0].rect.width, 80);
assert_eq!(layout.panels[1].rect.width, 80);
assert_eq!(
layout.panels[0].rect.height + layout.panels[1].rect.height,
24
);
assert_eq!(layout.panels[0].rect.y, 0);
assert_eq!(
layout.panels[1].rect.y, layout.panels[0].rect.height,
"second panel starts where first ends"
);
}
#[test]
fn test_two_panels_side_by_side() {
let area = Rect::new(0, 0, 80, 24);
let comp = Composition(vec![2]);
let layout = compute_layout(area, &comp, &HashSet::new(), 1.0);
assert_eq!(layout.panels.len(), 2);
assert_eq!(layout.panels[0].rect.height, 24);
assert_eq!(layout.panels[1].rect.height, 24);
assert_eq!(
layout.panels[0].rect.width + layout.panels[1].rect.width,
80
);
assert_eq!(layout.panels[0].rect.x, 0);
assert_eq!(
layout.panels[1].rect.x, layout.panels[0].rect.width,
"second panel starts where first ends"
);
}
#[test]
fn test_three_panels_2_1() {
let area = Rect::new(0, 0, 80, 24);
let comp = Composition(vec![2, 1]);
let layout = compute_layout(area, &comp, &HashSet::new(), 1.0);
assert_eq!(layout.panels.len(), 3);
assert_eq!(
layout.panels[0].rect.width + layout.panels[1].rect.width,
80
);
assert_eq!(layout.panels[2].rect.width, 80);
assert_eq!(layout.panels[2].rect.x, 0);
assert_eq!(
layout.panels[0].rect.height + layout.panels[2].rect.height,
24
);
assert_eq!(layout.panels[2].rect.y, layout.panels[0].rect.height);
}
#[test]
fn test_pin_scaling() {
let area = Rect::new(0, 0, 80, 24);
let comp = Composition(vec![2]);
let mut pinned = HashSet::new();
pinned.insert(0);
let layout = compute_layout(area, &comp, &pinned, 2.0);
assert_eq!(layout.panels.len(), 2);
assert!(
layout.panels[0].rect.width > layout.panels[1].rect.width,
"pinned panel 0 ({}) should be wider than unpinned panel 1 ({})",
layout.panels[0].rect.width,
layout.panels[1].rect.width,
);
assert_eq!(
layout.panels[0].rect.width + layout.panels[1].rect.width,
80
);
}
#[test]
fn test_no_pixel_loss() {
let area = Rect::new(0, 0, 81, 25);
let comp = Composition(vec![3]);
let layout = compute_layout(area, &comp, &HashSet::new(), 1.0);
let total_width: u16 = layout.panels.iter().map(|p| p.rect.width).sum();
assert_eq!(total_width, 81, "widths sum to area width");
for p in &layout.panels {
assert_eq!(p.rect.height, 25, "single-row panels are full height");
}
let comp2 = Composition(vec![1, 1, 1]);
let layout2 = compute_layout(area, &comp2, &HashSet::new(), 1.0);
let total_height: u16 = layout2.panels.iter().map(|p| p.rect.height).sum();
assert_eq!(total_height, 25, "heights sum to area height");
for p in &layout2.panels {
assert_eq!(p.rect.width, 81, "single-column panels are full width");
}
}
#[test]
fn test_curated_layouts_n1() {
let layouts = curated_layouts(1);
assert_eq!(layouts, vec![Composition(vec![1])]);
}
#[test]
fn test_curated_layouts_n4() {
let layouts = curated_layouts(4);
assert!(
layouts.contains(&Composition(vec![2, 2])),
"N=4 curated layouts should contain (2,2)"
);
}
#[test]
fn test_closest_composition_add() {
let old = Composition(vec![2, 1]);
let result = closest_composition(&old, 4);
assert_eq!(result.total(), 4);
assert!(!result.0.is_empty());
}
#[test]
fn test_closest_composition_remove() {
let old = Composition(vec![2, 2]);
let result = closest_composition(&old, 3);
assert_eq!(result.total(), 3);
assert_eq!(result.0, vec![2, 1]);
}
#[test]
fn test_box_char_all_light() {
assert_eq!(box_char(ARM_DOWN | ARM_RIGHT), '┌');
assert_eq!(box_char(ARM_DOWN | ARM_LEFT), '┐');
assert_eq!(box_char(ARM_UP | ARM_RIGHT), '└');
assert_eq!(box_char(ARM_UP | ARM_LEFT), '┘');
assert_eq!(box_char(ARM_DOWN | ARM_LEFT | ARM_RIGHT), '┬');
assert_eq!(box_char(ARM_UP | ARM_LEFT | ARM_RIGHT), '┴');
assert_eq!(box_char(ARM_UP | ARM_DOWN | ARM_RIGHT), '├');
assert_eq!(box_char(ARM_UP | ARM_DOWN | ARM_LEFT), '┤');
assert_eq!(box_char(ARM_UP | ARM_DOWN | ARM_LEFT | ARM_RIGHT), '┼');
}
#[test]
fn test_box_char_focused_arms() {
let flags = ARM_UP | ARM_DOWN | ARM_RIGHT | HEAVY_UP | HEAVY_DOWN;
assert_eq!(box_char(flags), '┠');
let flags = ARM_DOWN | ARM_RIGHT | HEAVY_RIGHT;
assert_eq!(box_char(flags), '┍');
let flags = ARM_UP | ARM_DOWN | ARM_LEFT | ARM_RIGHT | HEAVY_UP | HEAVY_LEFT;
assert_eq!(box_char(flags), '╃');
}
#[test]
fn test_panel_at_inside() {
let area = Rect::new(0, 0, 80, 24);
let comp = Composition(vec![2]);
let layout = compute_layout(area, &comp, &HashSet::new(), 1.0);
assert_eq!(panel_at(&layout, 10, 5), Some(0));
assert_eq!(panel_at(&layout, 50, 5), Some(1));
}
#[test]
fn test_panel_at_border() {
let area = Rect::new(0, 0, 80, 24);
let comp = Composition(vec![2]);
let layout = compute_layout(area, &comp, &HashSet::new(), 1.0);
let p0 = &layout.panels[0].rect;
let p1 = &layout.panels[1].rect;
assert_eq!(panel_at(&layout, 10, p0.y), None, "top title row is chrome");
let scrollbar_x = p0.x + p0.width - 1;
assert_eq!(
panel_at(&layout, scrollbar_x, 5),
None,
"right scrollbar is chrome"
);
assert_eq!(panel_at(&layout, p0.x, 1), Some(0), "left edge is content");
let bottom_y = p0.y + p0.height - 1;
assert_eq!(
panel_at(&layout, 10, bottom_y),
Some(0),
"bottom edge is content"
);
assert_eq!(panel_at(&layout, p1.x + 1, p1.y), None, "panel 1 title row");
let scrollbar_x1 = p1.x + p1.width - 1;
assert_eq!(
panel_at(&layout, scrollbar_x1, 5),
None,
"panel 1 scrollbar"
);
}
}