use serde::{Deserialize, Serialize};
use crate::{
direction::{Direction, SplitOrientation},
geometry::Rect,
id::PaneId,
};
pub const MIN_RATIO: f32 = 0.05;
#[derive(Copy, Clone, Debug, PartialEq, Serialize)]
#[serde(transparent)]
pub struct SplitRatio(f32);
impl SplitRatio {
pub const BALANCED: Self = Self(0.5);
#[must_use]
pub fn new(v: f32) -> Self {
if v.is_finite() {
Self(v.clamp(MIN_RATIO, 1.0 - MIN_RATIO))
} else {
Self::BALANCED
}
}
#[must_use]
pub const fn get(self) -> f32 {
self.0
}
}
impl Default for SplitRatio {
fn default() -> Self {
Self::BALANCED
}
}
impl From<f32> for SplitRatio {
fn from(v: f32) -> Self {
Self::new(v)
}
}
impl<'de> Deserialize<'de> for SplitRatio {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
Ok(Self::new(f32::deserialize(d)?))
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum LeafRemoval {
Removed,
WasRoot,
NotFound,
}
#[derive(Clone, Debug, PartialEq)]
pub enum LayoutError {
NullLeaf,
DuplicatePane(PaneId),
BadRatio(f32),
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum LayoutNode {
Leaf {
pane: PaneId,
},
Split {
orientation: SplitOrientation,
ratio: SplitRatio,
a: Box<LayoutNode>,
b: Box<LayoutNode>,
},
}
impl LayoutNode {
#[must_use]
pub fn leaf(pane: PaneId) -> Self {
Self::Leaf { pane }
}
#[must_use]
pub fn split(orientation: SplitOrientation, a: LayoutNode, b: LayoutNode) -> Self {
Self::Split {
orientation,
ratio: SplitRatio::BALANCED,
a: Box::new(a),
b: Box::new(b),
}
}
#[must_use]
pub fn panes(&self) -> Vec<PaneId> {
let mut out = Vec::new();
self.collect(&mut out);
out
}
fn collect(&self, out: &mut Vec<PaneId>) {
match self {
Self::Leaf { pane } => out.push(*pane),
Self::Split { a, b, .. } => {
a.collect(out);
b.collect(out);
}
}
}
#[must_use]
pub fn pane_count(&self) -> usize {
match self {
Self::Leaf { .. } => 1,
Self::Split { a, b, .. } => a.pane_count() + b.pane_count(),
}
}
#[must_use]
fn is_leaf_of(&self, pane: PaneId) -> bool {
matches!(self, Self::Leaf { pane: p } if *p == pane)
}
#[must_use]
pub fn contains_pane(&self, pane: PaneId) -> bool {
match self {
Self::Leaf { pane: p } => *p == pane,
Self::Split { a, b, .. } => a.contains_pane(pane) || b.contains_pane(pane),
}
}
#[must_use]
pub fn from_kind(kind: LayoutKind, panes: &[PaneId]) -> Option<Self> {
match panes {
[] => None,
[only] => Some(Self::leaf(*only)),
[main, rest @ ..] => match kind {
LayoutKind::EvenHorizontal => {
even_chain(SplitOrientation::Vertical, &leaves(panes))
}
LayoutKind::EvenVertical => {
even_chain(SplitOrientation::Horizontal, &leaves(panes))
}
LayoutKind::MainHorizontal => {
let bottom = even_chain(SplitOrientation::Vertical, &leaves(rest))?;
Some(Self::Split {
orientation: SplitOrientation::Horizontal,
ratio: SplitRatio::BALANCED,
a: Box::new(Self::leaf(*main)),
b: Box::new(bottom),
})
}
LayoutKind::MainVertical => {
let right = even_chain(SplitOrientation::Horizontal, &leaves(rest))?;
Some(Self::Split {
orientation: SplitOrientation::Vertical,
ratio: SplitRatio::BALANCED,
a: Box::new(Self::leaf(*main)),
b: Box::new(right),
})
}
LayoutKind::Tiled => tiled(panes),
LayoutKind::Custom => None,
},
}
}
pub fn split_leaf(
&mut self,
target: PaneId,
new_pane: PaneId,
direction: Direction,
origin_ratio: f32,
) -> bool {
match self {
Self::Leaf { pane } if *pane == target => {
let origin = Self::leaf(*pane);
let fresh = Self::leaf(new_pane);
let keep = SplitRatio::new(origin_ratio).get();
let orientation = direction.orientation();
let (a, b, ratio) = match direction {
Direction::Right | Direction::Below => (origin, fresh, keep),
Direction::Left | Direction::Above => (fresh, origin, 1.0 - keep),
};
*self = Self::Split {
orientation,
ratio: SplitRatio::new(ratio),
a: Box::new(a),
b: Box::new(b),
};
true
}
Self::Leaf { .. } => false,
Self::Split { a, b, .. } => {
a.split_leaf(target, new_pane, direction, origin_ratio)
|| b.split_leaf(target, new_pane, direction, origin_ratio)
}
}
}
pub fn remove_leaf(&mut self, target: PaneId) -> LeafRemoval {
match self {
Self::Leaf { pane } if *pane == target => LeafRemoval::WasRoot,
Self::Leaf { .. } => LeafRemoval::NotFound,
Self::Split { a, b, .. } => {
if a.is_leaf_of(target) {
*self = std::mem::replace(b.as_mut(), Self::leaf(PaneId::NULL));
return LeafRemoval::Removed;
}
if b.is_leaf_of(target) {
*self = std::mem::replace(a.as_mut(), Self::leaf(PaneId::NULL));
return LeafRemoval::Removed;
}
match a.remove_leaf(target) {
LeafRemoval::NotFound => b.remove_leaf(target),
other => other,
}
}
}
}
pub fn resize_leaf(&mut self, target: PaneId, direction: Direction, delta_frac: f32) -> bool {
let want = direction.orientation();
let toward_b = matches!(direction, Direction::Right | Direction::Below);
let Some(path) = self.governing_split_path(target, want, toward_b) else {
return false;
};
let Some(Self::Split { ratio, .. }) = self.split_at_path(&path) else {
return false;
};
let sign = if toward_b { 1.0 } else { -1.0 };
let next = ratio.get() + sign * delta_frac;
if next.is_finite() {
*ratio = SplitRatio::new(next);
}
true
}
fn governing_split_path(
&self,
target: PaneId,
want: SplitOrientation,
need_side_a: bool,
) -> Option<Vec<bool>> {
let mut best: Option<Vec<bool>> = None;
let mut path: Vec<bool> = Vec::new();
self.walk_governing(target, want, need_side_a, &mut path, &mut best);
best
}
fn walk_governing(
&self,
target: PaneId,
want: SplitOrientation,
need_side_a: bool,
path: &mut Vec<bool>,
best: &mut Option<Vec<bool>>,
) {
if let Self::Split {
orientation, a, b, ..
} = self
{
let in_a = a.contains_pane(target);
let in_b = b.contains_pane(target);
if *orientation == want && ((in_a && need_side_a) || (in_b && !need_side_a)) {
*best = Some(path.clone());
}
if in_a {
path.push(true);
a.walk_governing(target, want, need_side_a, path, best);
path.pop();
} else if in_b {
path.push(false);
b.walk_governing(target, want, need_side_a, path, best);
path.pop();
}
}
}
fn split_at_path(&mut self, path: &[bool]) -> Option<&mut Self> {
let mut node = self;
for &into_a in path {
match node {
Self::Split { a, b, .. } => {
node = if into_a { a.as_mut() } else { b.as_mut() };
}
Self::Leaf { .. } => return None,
}
}
Some(node)
}
#[must_use]
pub fn compute_rects(&self, bounds: Rect) -> Vec<(PaneId, Rect)> {
let mut out = Vec::with_capacity(self.pane_count());
self.lay_out(bounds, &mut out);
out
}
fn lay_out(&self, bounds: Rect, out: &mut Vec<(PaneId, Rect)>) {
match self {
Self::Leaf { pane } => out.push((*pane, bounds)),
Self::Split {
orientation,
ratio,
a,
b,
} => {
let (ra, rb) = split_rect(bounds, *orientation, ratio.get());
a.lay_out(ra, out);
b.lay_out(rb, out);
}
}
}
#[must_use]
pub fn neighbor(&self, target: PaneId, direction: Direction, bounds: Rect) -> Option<PaneId> {
let rects = self.compute_rects(bounds);
let me = rects.iter().find(|(p, _)| *p == target).map(|(_, r)| *r)?;
let mut best: Option<(PaneId, u32, u32)> = None;
for (pane, r) in &rects {
if *pane == target {
continue;
}
let (on_side, gap, overlap) = match direction {
Direction::Left => (
r.right() <= u32::from(me.x),
u32::from(me.x).saturating_sub(r.right()),
Rect::span_overlap(u32::from(r.y), r.bottom(), u32::from(me.y), me.bottom()),
),
Direction::Right => (
u32::from(r.x) >= me.right(),
u32::from(r.x).saturating_sub(me.right()),
Rect::span_overlap(u32::from(r.y), r.bottom(), u32::from(me.y), me.bottom()),
),
Direction::Above => (
r.bottom() <= u32::from(me.y),
u32::from(me.y).saturating_sub(r.bottom()),
Rect::span_overlap(u32::from(r.x), r.right(), u32::from(me.x), me.right()),
),
Direction::Below => (
u32::from(r.y) >= me.bottom(),
u32::from(r.y).saturating_sub(me.bottom()),
Rect::span_overlap(u32::from(r.x), r.right(), u32::from(me.x), me.right()),
),
};
if on_side && overlap > 0 {
let better = match best {
None => true,
Some((_, best_gap, best_overlap)) => {
gap < best_gap || (gap == best_gap && overlap > best_overlap)
}
};
if better {
best = Some((*pane, gap, overlap));
}
}
}
best.map(|(p, _, _)| p)
}
pub fn validate(&self) -> Result<(), LayoutError> {
let mut seen = Vec::new();
self.validate_into(&mut seen)
}
fn validate_into(&self, seen: &mut Vec<PaneId>) -> Result<(), LayoutError> {
match self {
Self::Leaf { pane } => {
if *pane == PaneId::NULL {
return Err(LayoutError::NullLeaf);
}
if seen.contains(pane) {
return Err(LayoutError::DuplicatePane(*pane));
}
seen.push(*pane);
Ok(())
}
Self::Split { ratio, a, b, .. } => {
let r = ratio.get();
if !(r > 0.0 && r < 1.0) {
return Err(LayoutError::BadRatio(r));
}
a.validate_into(seen)?;
b.validate_into(seen)
}
}
}
}
fn split_rect(bounds: Rect, orientation: SplitOrientation, ratio: f32) -> (Rect, Rect) {
match orientation {
SplitOrientation::Horizontal => {
let a_h = split_extent(bounds.h, ratio);
let b_h = bounds.h - a_h;
(
Rect::new(bounds.x, bounds.y, bounds.w, a_h),
Rect::new(bounds.x, bounds.y + a_h, bounds.w, b_h),
)
}
SplitOrientation::Vertical => {
let a_w = split_extent(bounds.w, ratio);
let b_w = bounds.w - a_w;
(
Rect::new(bounds.x, bounds.y, a_w, bounds.h),
Rect::new(bounds.x + a_w, bounds.y, b_w, bounds.h),
)
}
}
}
fn split_extent(total: u16, ratio: f32) -> u16 {
if total <= 1 {
return total;
}
let raw = (f32::from(total) * ratio).round();
let a = raw.clamp(1.0, f32::from(total) - 1.0);
a as u16
}
fn leaves(panes: &[PaneId]) -> Vec<LayoutNode> {
panes.iter().map(|p| LayoutNode::leaf(*p)).collect()
}
fn even_chain(orientation: SplitOrientation, nodes: &[LayoutNode]) -> Option<LayoutNode> {
match nodes {
[] => None,
[single] => Some(single.clone()),
[first, rest @ ..] => {
let n = nodes.len() as f32;
let rest_tree = even_chain(orientation, rest)?;
Some(LayoutNode::Split {
orientation,
ratio: SplitRatio::new(1.0 / n),
a: Box::new(first.clone()),
b: Box::new(rest_tree),
})
}
}
}
fn tiled(panes: &[PaneId]) -> Option<LayoutNode> {
let n = panes.len();
if n == 0 {
return None;
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let rows = (n as f64).sqrt().ceil() as usize;
let per = n / rows;
let extra = n % rows;
let mut row_trees = Vec::with_capacity(rows);
let mut i = 0;
for r in 0..rows {
let cnt = per + usize::from(r < extra);
let row = even_chain(SplitOrientation::Vertical, &leaves(&panes[i..i + cnt]))?;
row_trees.push(row);
i += cnt;
}
even_chain(SplitOrientation::Horizontal, &row_trees)
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum LayoutKind {
EvenHorizontal,
EvenVertical,
MainHorizontal,
MainVertical,
Tiled,
Custom,
}
#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Size {
Cells(u16),
Fraction(f32),
Auto,
}
impl Default for Size {
fn default() -> Self {
Self::Auto
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::direction::SplitOrientation;
#[test]
fn leaf_has_one_pane() {
let n = LayoutNode::leaf(PaneId(7));
assert_eq!(n.pane_count(), 1);
assert_eq!(n.panes(), vec![PaneId(7)]);
}
#[test]
fn split_aggregates_panes_left_then_right() {
let n = LayoutNode::split(
SplitOrientation::Vertical,
LayoutNode::leaf(PaneId(1)),
LayoutNode::leaf(PaneId(2)),
);
assert_eq!(n.pane_count(), 2);
assert_eq!(n.panes(), vec![PaneId(1), PaneId(2)]);
}
#[test]
fn nested_split_traversal_is_predictable() {
let n = LayoutNode::split(
SplitOrientation::Horizontal,
LayoutNode::leaf(PaneId(1)),
LayoutNode::split(
SplitOrientation::Vertical,
LayoutNode::leaf(PaneId(2)),
LayoutNode::leaf(PaneId(3)),
),
);
assert_eq!(n.panes(), vec![PaneId(1), PaneId(2), PaneId(3)]);
assert_eq!(n.pane_count(), 3);
}
#[test]
fn split_leaf_targets_the_matched_leaf_only() {
let mut n = LayoutNode::split(
SplitOrientation::Vertical,
LayoutNode::leaf(PaneId(1)),
LayoutNode::split(
SplitOrientation::Horizontal,
LayoutNode::leaf(PaneId(2)),
LayoutNode::leaf(PaneId(3)),
),
);
assert!(n.split_leaf(PaneId(2), PaneId(9), Direction::Right, 0.5));
assert_eq!(n.panes(), vec![PaneId(1), PaneId(2), PaneId(9), PaneId(3)]);
assert_eq!(n.pane_count(), 4);
n.validate().unwrap();
}
#[test]
fn split_leaf_orders_new_pane_by_direction() {
let mut right = LayoutNode::leaf(PaneId(1));
right.split_leaf(PaneId(1), PaneId(2), Direction::Right, 0.5);
assert_eq!(right.panes(), vec![PaneId(1), PaneId(2)]);
let mut left = LayoutNode::leaf(PaneId(1));
left.split_leaf(PaneId(1), PaneId(2), Direction::Left, 0.5);
assert_eq!(left.panes(), vec![PaneId(2), PaneId(1)]); }
#[test]
fn split_leaf_unknown_target_is_noop() {
let mut n = LayoutNode::leaf(PaneId(1));
assert!(!n.split_leaf(PaneId(99), PaneId(2), Direction::Right, 0.5));
assert_eq!(n.panes(), vec![PaneId(1)]);
}
#[test]
fn split_leaf_clamps_extreme_ratio() {
let mut n = LayoutNode::leaf(PaneId(1));
n.split_leaf(PaneId(1), PaneId(2), Direction::Right, 0.0);
n.validate().unwrap();
if let LayoutNode::Split { ratio, .. } = n {
assert!(ratio.get() >= MIN_RATIO && ratio.get() <= 1.0 - MIN_RATIO);
} else {
panic!("expected a split");
}
}
#[test]
fn remove_leaf_collapses_parent_into_sibling() {
let mut n = LayoutNode::split(
SplitOrientation::Vertical,
LayoutNode::leaf(PaneId(1)),
LayoutNode::leaf(PaneId(2)),
);
assert_eq!(n.remove_leaf(PaneId(1)), LeafRemoval::Removed);
assert_eq!(n, LayoutNode::leaf(PaneId(2)));
n.validate().unwrap();
}
#[test]
fn remove_leaf_collapses_deep_node() {
let mut n = LayoutNode::split(
SplitOrientation::Vertical,
LayoutNode::leaf(PaneId(1)),
LayoutNode::split(
SplitOrientation::Horizontal,
LayoutNode::leaf(PaneId(2)),
LayoutNode::leaf(PaneId(3)),
),
);
assert_eq!(n.remove_leaf(PaneId(3)), LeafRemoval::Removed);
assert_eq!(n.panes(), vec![PaneId(1), PaneId(2)]);
assert!(!n.contains_pane(PaneId(3)));
n.validate().unwrap();
}
#[test]
fn remove_leaf_root_reports_was_root() {
let mut n = LayoutNode::leaf(PaneId(1));
assert_eq!(n.remove_leaf(PaneId(1)), LeafRemoval::WasRoot);
assert_eq!(n, LayoutNode::leaf(PaneId(1)));
}
#[test]
fn remove_leaf_unknown_is_not_found() {
let mut n = LayoutNode::split(
SplitOrientation::Vertical,
LayoutNode::leaf(PaneId(1)),
LayoutNode::leaf(PaneId(2)),
);
assert_eq!(n.remove_leaf(PaneId(99)), LeafRemoval::NotFound);
assert_eq!(n.pane_count(), 2);
}
#[test]
fn compute_rects_single_pane_fills_bounds() {
let n = LayoutNode::leaf(PaneId(1));
let r = n.compute_rects(Rect::sized(80, 24));
assert_eq!(r, vec![(PaneId(1), Rect::new(0, 0, 80, 24))]);
}
#[test]
fn compute_rects_vertical_split_is_side_by_side_gapless() {
let n = LayoutNode::split(
SplitOrientation::Vertical,
LayoutNode::leaf(PaneId(1)),
LayoutNode::leaf(PaneId(2)),
);
let r = n.compute_rects(Rect::sized(80, 24));
let a = r.iter().find(|(p, _)| *p == PaneId(1)).unwrap().1;
let b = r.iter().find(|(p, _)| *p == PaneId(2)).unwrap().1;
assert_eq!(a, Rect::new(0, 0, 40, 24));
assert_eq!(b, Rect::new(40, 0, 40, 24));
assert_eq!(a.right(), u32::from(b.x)); }
#[test]
fn compute_rects_horizontal_split_stacks_rows() {
let n = LayoutNode::split(
SplitOrientation::Horizontal,
LayoutNode::leaf(PaneId(1)),
LayoutNode::leaf(PaneId(2)),
);
let r = n.compute_rects(Rect::sized(80, 24));
let a = r.iter().find(|(p, _)| *p == PaneId(1)).unwrap().1;
let b = r.iter().find(|(p, _)| *p == PaneId(2)).unwrap().1;
assert_eq!(a, Rect::new(0, 0, 80, 12));
assert_eq!(b, Rect::new(0, 12, 80, 12));
assert_eq!(a.bottom(), u32::from(b.y));
}
#[test]
fn compute_rects_tiles_bounds_exactly() {
let n = LayoutNode::split(
SplitOrientation::Vertical,
LayoutNode::leaf(PaneId(1)),
LayoutNode::split(
SplitOrientation::Horizontal,
LayoutNode::leaf(PaneId(2)),
LayoutNode::leaf(PaneId(3)),
),
);
let bounds = Rect::sized(81, 25);
let rects = n.compute_rects(bounds);
let total: u32 = rects.iter().map(|(_, r)| r.area()).sum();
assert_eq!(total, bounds.area());
for y in 0..bounds.h {
for x in 0..bounds.w {
let owners = rects.iter().filter(|(_, r)| r.contains(x, y)).count();
assert_eq!(owners, 1, "cell ({x},{y}) owned by {owners} panes");
}
}
}
#[test]
fn compute_rects_tiny_window_never_panics() {
let n = LayoutNode::split(
SplitOrientation::Vertical,
LayoutNode::leaf(PaneId(1)),
LayoutNode::leaf(PaneId(2)),
);
let r = n.compute_rects(Rect::sized(1, 5));
let total: u32 = r.iter().map(|(_, rr)| rr.area()).sum();
assert_eq!(total, 5);
}
#[test]
fn neighbor_walks_left_and_right() {
let n = LayoutNode::split(
SplitOrientation::Vertical,
LayoutNode::leaf(PaneId(1)),
LayoutNode::split(
SplitOrientation::Vertical,
LayoutNode::leaf(PaneId(2)),
LayoutNode::leaf(PaneId(3)),
),
);
let b = Rect::sized(90, 24);
assert_eq!(n.neighbor(PaneId(2), Direction::Left, b), Some(PaneId(1)));
assert_eq!(n.neighbor(PaneId(2), Direction::Right, b), Some(PaneId(3)));
assert_eq!(n.neighbor(PaneId(1), Direction::Left, b), None); assert_eq!(n.neighbor(PaneId(3), Direction::Right, b), None);
}
#[test]
fn neighbor_crosses_split_boundary_by_edge_overlap() {
let n = LayoutNode::split(
SplitOrientation::Vertical,
LayoutNode::leaf(PaneId(1)),
LayoutNode::split(
SplitOrientation::Horizontal,
LayoutNode::leaf(PaneId(2)),
LayoutNode::leaf(PaneId(3)),
),
);
let b = Rect::sized(80, 24);
let right = n.neighbor(PaneId(1), Direction::Right, b);
assert!(matches!(right, Some(PaneId(2)) | Some(PaneId(3))));
assert_eq!(n.neighbor(PaneId(2), Direction::Left, b), Some(PaneId(1)));
}
#[test]
fn neighbor_unknown_target_is_none() {
let n = LayoutNode::leaf(PaneId(1));
assert_eq!(n.neighbor(PaneId(99), Direction::Left, Rect::sized(80, 24)), None);
}
#[test]
fn neighbor_prefers_nearest_not_just_max_overlap() {
let right_leaning = LayoutNode::split(
SplitOrientation::Vertical,
LayoutNode::leaf(PaneId(1)),
LayoutNode::split(
SplitOrientation::Vertical,
LayoutNode::leaf(PaneId(2)),
LayoutNode::leaf(PaneId(3)),
),
);
let left_leaning = LayoutNode::split(
SplitOrientation::Vertical,
LayoutNode::split(
SplitOrientation::Vertical,
LayoutNode::leaf(PaneId(1)),
LayoutNode::leaf(PaneId(2)),
),
LayoutNode::leaf(PaneId(3)),
);
let b = Rect::sized(90, 24);
assert_eq!(right_leaning.neighbor(PaneId(1), Direction::Right, b), Some(PaneId(2)));
assert_eq!(left_leaning.neighbor(PaneId(1), Direction::Right, b), Some(PaneId(2)));
}
#[test]
fn from_kind_empty_and_custom_are_none() {
assert_eq!(LayoutNode::from_kind(LayoutKind::Tiled, &[]), None);
assert_eq!(
LayoutNode::from_kind(LayoutKind::Custom, &[PaneId(1), PaneId(2)]),
None
);
}
#[test]
fn from_kind_single_pane_is_a_leaf_for_every_kind() {
for kind in [
LayoutKind::EvenHorizontal,
LayoutKind::EvenVertical,
LayoutKind::MainHorizontal,
LayoutKind::MainVertical,
LayoutKind::Tiled,
] {
assert_eq!(
LayoutNode::from_kind(kind, &[PaneId(1)]),
Some(LayoutNode::leaf(PaneId(1)))
);
}
}
#[test]
fn from_kind_even_horizontal_gives_equal_thirds() {
let panes = [PaneId(1), PaneId(2), PaneId(3)];
let tree = LayoutNode::from_kind(LayoutKind::EvenHorizontal, &panes).unwrap();
tree.validate().unwrap();
let rects = tree.compute_rects(Rect::sized(90, 24));
let mut widths: Vec<u16> = rects.iter().map(|(_, r)| r.w).collect();
widths.sort_unstable();
assert_eq!(widths, vec![30, 30, 30]);
}
#[test]
fn from_kind_every_preset_validates_and_tiles_exactly() {
let kinds = [
LayoutKind::EvenHorizontal,
LayoutKind::EvenVertical,
LayoutKind::MainHorizontal,
LayoutKind::MainVertical,
LayoutKind::Tiled,
];
for kind in kinds {
for n in 1..=7usize {
let panes: Vec<PaneId> = (1..=n as u64).map(PaneId).collect();
let tree = LayoutNode::from_kind(kind, &panes)
.unwrap_or_else(|| panic!("{kind:?} n={n} produced None"));
tree.validate()
.unwrap_or_else(|e| panic!("{kind:?} n={n} invalid: {e:?}"));
assert_eq!(tree.pane_count(), n, "{kind:?} n={n} pane count");
assert_eq!(tree.panes().len(), n);
for &(w, h) in &[(80u16, 24u16), (81, 25), (97, 31)] {
let bounds = Rect::sized(w, h);
let rects = tree.compute_rects(bounds);
let total: u32 = rects.iter().map(|(_, r)| r.area()).sum();
assert_eq!(total, bounds.area(), "{kind:?} n={n} at {w}x{h}");
}
}
}
}
#[test]
fn from_kind_main_vertical_keeps_main_on_the_left() {
let panes = [PaneId(1), PaneId(2), PaneId(3)];
let tree = LayoutNode::from_kind(LayoutKind::MainVertical, &panes).unwrap();
match &tree {
LayoutNode::Split { orientation, a, .. } => {
assert_eq!(*orientation, SplitOrientation::Vertical);
assert_eq!(a.as_ref(), &LayoutNode::leaf(PaneId(1)));
}
LayoutNode::Leaf { .. } => panic!("expected a split"),
}
let rects = tree.compute_rects(Rect::sized(80, 24));
let main = rects.iter().find(|(p, _)| *p == PaneId(1)).unwrap().1;
assert_eq!(main.x, 0);
}
#[test]
fn compute_rects_tiles_exactly_across_shapes_and_sizes() {
let shapes = [
LayoutNode::leaf(PaneId(1)),
LayoutNode::split(
SplitOrientation::Vertical,
LayoutNode::leaf(PaneId(1)),
LayoutNode::leaf(PaneId(2)),
),
LayoutNode::split(
SplitOrientation::Horizontal,
LayoutNode::split(
SplitOrientation::Vertical,
LayoutNode::leaf(PaneId(1)),
LayoutNode::leaf(PaneId(2)),
),
LayoutNode::split(
SplitOrientation::Vertical,
LayoutNode::leaf(PaneId(3)),
LayoutNode::split(
SplitOrientation::Horizontal,
LayoutNode::leaf(PaneId(4)),
LayoutNode::leaf(PaneId(5)),
),
),
),
];
for shape in &shapes {
for &(w, h) in &[(80u16, 24u16), (81, 25), (1, 1), (3, 200), (200, 3), (2, 2)] {
let bounds = Rect::sized(w, h);
let rects = shape.compute_rects(bounds);
let total: u32 = rects.iter().map(|(_, r)| r.area()).sum();
assert_eq!(total, bounds.area(), "area mismatch at {w}x{h}");
assert_eq!(rects.len(), shape.pane_count());
if bounds.area() <= 8192 {
for y in 0..h {
for x in 0..w {
let owners = rects.iter().filter(|(_, r)| r.contains(x, y)).count();
assert!(owners <= 1, "cell ({x},{y}) owned by {owners} at {w}x{h}");
}
}
}
}
}
}
#[test]
fn resize_leaf_grows_focused_pane_rightward() {
let mut n = LayoutNode::split(
SplitOrientation::Vertical,
LayoutNode::leaf(PaneId(1)),
LayoutNode::leaf(PaneId(2)),
);
let before = n.compute_rects(Rect::sized(80, 24));
let w1_before = before.iter().find(|(p, _)| *p == PaneId(1)).unwrap().1.w;
assert!(n.resize_leaf(PaneId(1), Direction::Right, 0.2));
let after = n.compute_rects(Rect::sized(80, 24));
let w1_after = after.iter().find(|(p, _)| *p == PaneId(1)).unwrap().1.w;
assert!(w1_after > w1_before, "{w1_after} !> {w1_before}");
}
#[test]
fn resize_leaf_grows_pane_on_side_b_too() {
let mut n = LayoutNode::split(
SplitOrientation::Vertical,
LayoutNode::leaf(PaneId(1)),
LayoutNode::leaf(PaneId(2)),
);
let before = n.compute_rects(Rect::sized(80, 24));
let w2_before = before.iter().find(|(p, _)| *p == PaneId(2)).unwrap().1.w;
assert!(n.resize_leaf(PaneId(2), Direction::Left, 0.2));
let after = n.compute_rects(Rect::sized(80, 24));
let w2_after = after.iter().find(|(p, _)| *p == PaneId(2)).unwrap().1.w;
assert!(w2_after > w2_before, "{w2_after} !> {w2_before}");
}
#[test]
fn resize_leaf_grows_toward_outer_neighbour_across_a_deeper_split() {
let mut n = LayoutNode::split(
SplitOrientation::Vertical,
LayoutNode::leaf(PaneId(1)),
LayoutNode::split(
SplitOrientation::Vertical,
LayoutNode::leaf(PaneId(2)),
LayoutNode::leaf(PaneId(3)),
),
);
let b = Rect::sized(90, 24);
let w2_before = n.compute_rects(b).iter().find(|(p, _)| *p == PaneId(2)).unwrap().1.w;
assert!(n.resize_leaf(PaneId(2), Direction::Left, 0.2));
let w2_after = n.compute_rects(b).iter().find(|(p, _)| *p == PaneId(2)).unwrap().1.w;
assert!(w2_after > w2_before, "grow-Left should enlarge pane 2: {w2_before} -> {w2_after}");
}
#[test]
fn resize_leaf_side_b_of_deeper_split_grows_right_toward_outer_neighbour() {
let mut n = LayoutNode::split(
SplitOrientation::Vertical,
LayoutNode::split(
SplitOrientation::Vertical,
LayoutNode::leaf(PaneId(1)),
LayoutNode::leaf(PaneId(2)),
),
LayoutNode::leaf(PaneId(3)),
);
let b = Rect::sized(90, 24);
let w2_before = n.compute_rects(b).iter().find(|(p, _)| *p == PaneId(2)).unwrap().1.w;
assert!(n.resize_leaf(PaneId(2), Direction::Right, 0.2));
let w2_after = n.compute_rects(b).iter().find(|(p, _)| *p == PaneId(2)).unwrap().1.w;
assert!(w2_after > w2_before, "grow-Right should enlarge pane 2: {w2_before} -> {w2_after}");
}
#[test]
fn resize_leaf_no_neighbour_that_way_is_noop() {
let mut n = LayoutNode::split(
SplitOrientation::Vertical,
LayoutNode::leaf(PaneId(1)),
LayoutNode::leaf(PaneId(2)),
);
assert!(!n.resize_leaf(PaneId(1), Direction::Left, 0.2));
}
#[test]
fn resize_leaf_grows_focused_pane_in_all_four_directions() {
let cases: &[(SplitOrientation, Direction)] = &[
(SplitOrientation::Vertical, Direction::Right),
(SplitOrientation::Vertical, Direction::Left),
(SplitOrientation::Horizontal, Direction::Below),
(SplitOrientation::Horizontal, Direction::Above),
];
for &(orient, dir) in cases {
let (focus, other) = match dir {
Direction::Right | Direction::Below => (PaneId(1), PaneId(2)),
Direction::Left | Direction::Above => (PaneId(2), PaneId(1)),
};
let mut n = LayoutNode::split(
orient,
LayoutNode::leaf(PaneId(1)),
LayoutNode::leaf(PaneId(2)),
);
let b = Rect::sized(80, 24);
let axis = |r: Rect| if orient == SplitOrientation::Vertical { r.w } else { r.h };
let before = axis(n.compute_rects(b).iter().find(|(p, _)| *p == focus).unwrap().1);
assert!(n.resize_leaf(focus, dir, 0.2), "{dir:?} should find a divider");
let after = axis(n.compute_rects(b).iter().find(|(p, _)| *p == focus).unwrap().1);
assert!(after > before, "focus pane should grow {dir:?}: {before} -> {after}");
let _ = other;
}
}
#[test]
fn split_leaf_nan_ratio_coerces_to_valid_split() {
let mut n = LayoutNode::leaf(PaneId(1));
assert!(n.split_leaf(PaneId(1), PaneId(2), Direction::Right, f32::NAN));
n.validate().unwrap();
}
#[test]
fn resize_leaf_nan_delta_leaves_tree_valid() {
let mut n = LayoutNode::split(
SplitOrientation::Vertical,
LayoutNode::leaf(PaneId(1)),
LayoutNode::leaf(PaneId(2)),
);
n.resize_leaf(PaneId(1), Direction::Right, f32::NAN);
n.validate().unwrap();
}
#[test]
fn resize_leaf_ignores_wrong_axis() {
let mut n = LayoutNode::split(
SplitOrientation::Vertical,
LayoutNode::leaf(PaneId(1)),
LayoutNode::leaf(PaneId(2)),
);
assert!(!n.resize_leaf(PaneId(1), Direction::Below, 0.2));
}
#[test]
fn resize_leaf_picks_deepest_governing_split() {
let mut n = LayoutNode::split(
SplitOrientation::Horizontal,
LayoutNode::split(
SplitOrientation::Vertical,
LayoutNode::leaf(PaneId(1)),
LayoutNode::leaf(PaneId(2)),
),
LayoutNode::leaf(PaneId(3)),
);
let bounds = Rect::sized(80, 24);
let w1_before = n.compute_rects(bounds).iter().find(|(p, _)| *p == PaneId(1)).unwrap().1.w;
let h3_before = n.compute_rects(bounds).iter().find(|(p, _)| *p == PaneId(3)).unwrap().1.h;
assert!(n.resize_leaf(PaneId(1), Direction::Right, 0.2));
let after = n.compute_rects(bounds);
let w1_after = after.iter().find(|(p, _)| *p == PaneId(1)).unwrap().1.w;
let h3_after = after.iter().find(|(p, _)| *p == PaneId(3)).unwrap().1.h;
assert!(w1_after > w1_before); assert_eq!(h3_after, h3_before); }
#[test]
fn validate_rejects_null_leaf() {
let n = LayoutNode::leaf(PaneId::NULL);
assert_eq!(n.validate(), Err(LayoutError::NullLeaf));
}
#[test]
fn validate_rejects_duplicate_pane() {
let n = LayoutNode::split(
SplitOrientation::Vertical,
LayoutNode::leaf(PaneId(5)),
LayoutNode::leaf(PaneId(5)),
);
assert_eq!(n.validate(), Err(LayoutError::DuplicatePane(PaneId(5))));
}
#[test]
fn a_degenerate_ratio_has_no_representation() {
for bad in [0.0, 1.0, -3.0, 42.0] {
let r = SplitRatio::new(bad);
assert!(
r.get() >= MIN_RATIO && r.get() <= 1.0 - MIN_RATIO,
"{bad} must refine into range, got {}",
r.get()
);
}
let n = LayoutNode::Split {
orientation: SplitOrientation::Vertical,
ratio: SplitRatio::new(0.0),
a: Box::new(LayoutNode::leaf(PaneId(1))),
b: Box::new(LayoutNode::leaf(PaneId(2))),
};
n.validate().expect("a refined ratio always validates");
}
#[test]
fn a_nan_ratio_cannot_reach_the_geometry() {
assert_eq!(SplitRatio::new(f32::NAN).get(), SplitRatio::BALANCED.get());
assert_eq!(SplitRatio::new(f32::INFINITY).get(), SplitRatio::BALANCED.get());
assert_eq!(
SplitRatio::new(f32::NEG_INFINITY).get(),
SplitRatio::BALANCED.get()
);
let n = LayoutNode::Split {
orientation: SplitOrientation::Vertical,
ratio: SplitRatio::new(f32::NAN),
a: Box::new(LayoutNode::leaf(PaneId(1))),
b: Box::new(LayoutNode::leaf(PaneId(2))),
};
let rects = n.compute_rects(Rect::sized(80, 24));
assert_eq!(rects.len(), 2);
for (pane, r) in rects {
assert!(r.w > 0 && r.h > 0, "pane {pane:?} vanished: {r:?}");
}
}
#[test]
fn deserialisation_refines_a_hostile_ratio() {
let zero: SplitRatio = serde_json::from_str("0.0").expect("deserialises");
assert!(zero.get() >= MIN_RATIO, "wire value must be refined");
let huge: SplitRatio = serde_json::from_str("42.0").expect("deserialises");
assert!(huge.get() <= 1.0 - MIN_RATIO, "wire value must be refined");
let neg: SplitRatio = serde_json::from_str("-3.0").expect("deserialises");
assert!(neg.get() >= MIN_RATIO, "wire value must be refined");
}
#[test]
fn split_ratio_is_wire_identical_to_the_bare_f32_it_replaced() {
let as_ratio = serde_json::to_string(&SplitRatio::new(0.25)).unwrap();
let as_f32 = serde_json::to_string(&0.25_f32).unwrap();
assert_eq!(
as_ratio, as_f32,
"SplitRatio must serialise exactly like the f32 it replaced, or \
a running daemon cannot talk to a new client"
);
let tree = LayoutNode::Split {
orientation: SplitOrientation::Vertical,
ratio: SplitRatio::new(0.25),
a: Box::new(LayoutNode::leaf(PaneId(1))),
b: Box::new(LayoutNode::leaf(PaneId(2))),
};
let json = serde_json::to_string(&tree).unwrap();
let back: LayoutNode = serde_json::from_str(&json).unwrap();
assert_eq!(back, tree);
}
#[test]
fn validate_accepts_well_formed_tree() {
let n = LayoutNode::split(
SplitOrientation::Vertical,
LayoutNode::leaf(PaneId(1)),
LayoutNode::split(
SplitOrientation::Horizontal,
LayoutNode::leaf(PaneId(2)),
LayoutNode::leaf(PaneId(3)),
),
);
n.validate().unwrap();
}
#[test]
fn split_then_remove_is_identity() {
let original = LayoutNode::leaf(PaneId(1));
let mut n = original.clone();
n.split_leaf(PaneId(1), PaneId(2), Direction::Right, 0.5);
assert_eq!(n.pane_count(), 2);
assert_eq!(n.remove_leaf(PaneId(2)), LeafRemoval::Removed);
assert_eq!(n, original);
}
}