use ratatui::layout::{Constraint, Direction, Layout, Rect};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SplitDirection {
Vertical,
Horizontal,
}
impl SplitDirection {
fn to_ratatui(self) -> Direction {
match self {
SplitDirection::Vertical => Direction::Horizontal,
SplitDirection::Horizontal => Direction::Vertical,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum Pane {
Leaf(usize),
Split {
direction: SplitDirection,
ratio: f32,
first: Box<Pane>,
second: Box<Pane>,
},
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PlacedPane {
pub session: usize,
pub area: Rect,
pub focused: bool,
}
#[derive(Clone, Debug, PartialEq)]
pub struct PaneTree {
root: Pane,
focus: usize,
}
impl PaneTree {
pub fn new(session: usize) -> Self {
Self {
root: Pane::Leaf(session),
focus: session,
}
}
pub fn focus(&self) -> usize {
self.focus
}
pub fn set_focus(&mut self, session: usize) {
if self.contains(session) {
self.focus = session;
}
}
pub fn contains(&self, session: usize) -> bool {
self.sessions().contains(&session)
}
pub fn sessions(&self) -> Vec<usize> {
fn walk(p: &Pane, out: &mut Vec<usize>) {
match p {
Pane::Leaf(s) => out.push(*s),
Pane::Split { first, second, .. } => {
walk(first, out);
walk(second, out);
}
}
}
let mut out = Vec::new();
walk(&self.root, &mut out);
out
}
pub fn len(&self) -> usize {
self.sessions().len()
}
pub fn is_single(&self) -> bool {
matches!(self.root, Pane::Leaf(_))
}
pub fn split(&mut self, direction: SplitDirection, new_session: usize) {
let focus = self.focus;
Self::split_at(&mut self.root, focus, direction, new_session);
self.focus = new_session;
}
fn split_at(pane: &mut Pane, target: usize, direction: SplitDirection, new_session: usize) {
match pane {
Pane::Leaf(s) if *s == target => {
let existing = Pane::Leaf(*s);
*pane = Pane::Split {
direction,
ratio: 0.5,
first: Box::new(existing),
second: Box::new(Pane::Leaf(new_session)),
};
}
Pane::Leaf(_) => {}
Pane::Split { first, second, .. } => {
Self::split_at(first, target, direction, new_session);
Self::split_at(second, target, direction, new_session);
}
}
}
pub fn close(&mut self, session: usize) -> bool {
if self.is_single() {
return false;
}
Self::close_in(&mut self.root, session);
if !self.contains(self.focus) {
self.focus = self.sessions().first().copied().unwrap_or(0);
}
true
}
fn close_in(pane: &mut Pane, target: usize) {
if let Pane::Split { first, second, .. } = pane {
if matches!(**first, Pane::Leaf(s) if s == target) {
*pane = (**second).clone();
return;
}
if matches!(**second, Pane::Leaf(s) if s == target) {
*pane = (**first).clone();
return;
}
Self::close_in(first, target);
Self::close_in(second, target);
}
}
pub fn reindex_after_removal(&mut self, removed: usize) {
fn walk(p: &mut Pane, removed: usize) {
match p {
Pane::Leaf(s) => {
if *s > removed {
*s -= 1;
}
}
Pane::Split { first, second, .. } => {
walk(first, removed);
walk(second, removed);
}
}
}
walk(&mut self.root, removed);
if self.focus > removed {
self.focus -= 1;
}
}
pub fn focus_next(&mut self) {
let sessions = self.sessions();
if sessions.is_empty() {
return;
}
let pos = sessions.iter().position(|s| *s == self.focus).unwrap_or(0);
self.focus = sessions[(pos + 1) % sessions.len()];
}
pub fn focus_prev(&mut self) {
let sessions = self.sessions();
if sessions.is_empty() {
return;
}
let pos = sessions.iter().position(|s| *s == self.focus).unwrap_or(0);
self.focus = sessions[(pos + sessions.len() - 1) % sessions.len()];
}
pub fn resize_focused(&mut self, delta: f32) {
let focus = self.focus;
Self::resize_in(&mut self.root, focus, delta);
}
fn resize_in(pane: &mut Pane, target: usize, delta: f32) -> bool {
match pane {
Pane::Leaf(s) => *s == target,
Pane::Split {
ratio,
first,
second,
..
} => {
if Self::resize_in(first, target, delta) {
*ratio = (*ratio + delta).clamp(0.1, 0.9);
return true;
}
if Self::resize_in(second, target, delta) {
*ratio = (*ratio - delta).clamp(0.1, 0.9);
return true;
}
false
}
}
}
pub fn layout(&self, area: Rect) -> Vec<PlacedPane> {
let mut out = Vec::new();
self.place(&self.root, area, &mut out);
out
}
fn place(&self, pane: &Pane, area: Rect, out: &mut Vec<PlacedPane>) {
match pane {
Pane::Leaf(s) => out.push(PlacedPane {
session: *s,
area,
focused: *s == self.focus,
}),
Pane::Split {
direction,
ratio,
first,
second,
} => {
let pct = (ratio * 100.0).round().clamp(10.0, 90.0) as u16;
let chunks = Layout::default()
.direction(direction.to_ratatui())
.constraints([
Constraint::Percentage(pct),
Constraint::Percentage(100 - pct),
])
.split(area);
self.place(first, chunks[0], out);
self.place(second, chunks[1], out);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn area() -> Rect {
Rect::new(0, 0, 100, 40)
}
#[test]
fn a_new_tree_is_one_pane_holding_one_session() {
let t = PaneTree::new(0);
assert!(t.is_single());
assert_eq!(t.len(), 1);
assert_eq!(t.focus(), 0);
let placed = t.layout(area());
assert_eq!(placed.len(), 1);
assert_eq!(placed[0].area, area());
assert!(placed[0].focused);
}
#[test]
fn splitting_produces_the_layout_from_the_spec() {
let mut t = PaneTree::new(0);
t.split(SplitDirection::Vertical, 1); t.set_focus(0);
t.split(SplitDirection::Horizontal, 2); t.set_focus(1);
t.split(SplitDirection::Horizontal, 3);
assert_eq!(t.len(), 4);
let placed = t.layout(area());
assert_eq!(placed.len(), 4);
let total: u32 = placed
.iter()
.map(|p| p.area.width as u32 * p.area.height as u32)
.sum();
assert_eq!(total, 100 * 40, "panes must tile the area exactly");
for p in &placed {
assert!(p.area.width > 0 && p.area.height > 0);
}
}
#[test]
fn a_vertical_split_puts_panes_side_by_side() {
let mut t = PaneTree::new(0);
t.split(SplitDirection::Vertical, 1);
let placed = t.layout(area());
assert_eq!(placed.len(), 2);
assert_eq!(placed[0].area.y, placed[1].area.y);
assert_ne!(placed[0].area.x, placed[1].area.x);
}
#[test]
fn a_horizontal_split_stacks_panes() {
let mut t = PaneTree::new(0);
t.split(SplitDirection::Horizontal, 1);
let placed = t.layout(area());
assert_eq!(placed[0].area.x, placed[1].area.x);
assert_ne!(placed[0].area.y, placed[1].area.y);
}
#[test]
fn splitting_focuses_the_new_pane() {
let mut t = PaneTree::new(0);
t.split(SplitDirection::Vertical, 7);
assert_eq!(t.focus(), 7);
let placed = t.layout(area());
assert!(placed.iter().find(|p| p.session == 7).unwrap().focused);
assert!(!placed.iter().find(|p| p.session == 0).unwrap().focused);
}
#[test]
fn closing_a_pane_promotes_its_sibling_rather_than_leaving_a_gap() {
let mut t = PaneTree::new(0);
t.split(SplitDirection::Vertical, 1);
assert!(t.close(1));
assert!(t.is_single(), "a one-child split must collapse");
assert_eq!(t.sessions(), vec![0]);
assert_eq!(t.layout(area())[0].area, area());
}
#[test]
fn closing_the_focused_pane_moves_focus_to_a_real_one() {
let mut t = PaneTree::new(0);
t.split(SplitDirection::Vertical, 1);
assert_eq!(t.focus(), 1);
t.close(1);
assert_eq!(t.focus(), 0, "focus must not dangle");
assert!(t.contains(t.focus()));
}
#[test]
fn closing_the_last_pane_is_refused_so_the_caller_closes_the_tab() {
let mut t = PaneTree::new(0);
assert!(!t.close(0));
assert_eq!(t.len(), 1, "the tree must never become empty");
}
#[test]
fn closing_a_deeply_nested_pane_collapses_only_its_own_split() {
let mut t = PaneTree::new(0);
t.split(SplitDirection::Vertical, 1);
t.split(SplitDirection::Horizontal, 2);
assert_eq!(t.len(), 3);
t.close(2);
assert_eq!(t.sessions(), vec![0, 1]);
assert_eq!(t.layout(area()).len(), 2);
}
#[test]
fn focus_cycles_through_every_pane_and_wraps() {
let mut t = PaneTree::new(0);
t.split(SplitDirection::Vertical, 1);
t.split(SplitDirection::Horizontal, 2);
let order = t.sessions();
t.set_focus(order[0]);
for expected in order.iter().skip(1) {
t.focus_next();
assert_eq!(t.focus(), *expected);
}
t.focus_next();
assert_eq!(t.focus(), order[0], "focus wraps");
t.focus_prev();
assert_eq!(t.focus(), *order.last().unwrap());
}
#[test]
fn resizing_moves_the_boundary_and_stays_within_bounds() {
let mut t = PaneTree::new(0);
t.split(SplitDirection::Vertical, 1);
let before = t.layout(area())[0].area.width;
t.set_focus(0);
t.resize_focused(0.1);
let after = t.layout(area())[0].area.width;
assert!(after > before, "{} !> {}", after, before);
for _ in 0..50 {
t.resize_focused(0.1);
}
let placed = t.layout(area());
assert!(placed.iter().all(|p| p.area.width >= 5));
}
#[test]
fn indices_are_renumbered_when_a_session_is_removed() {
let mut t = PaneTree::new(0);
t.split(SplitDirection::Vertical, 1);
t.split(SplitDirection::Horizontal, 2);
t.set_focus(0);
t.split(SplitDirection::Horizontal, 3);
assert_eq!(t.sessions().len(), 4);
t.close(1);
t.reindex_after_removal(1);
let mut remaining = t.sessions();
remaining.sort();
assert_eq!(remaining, vec![0, 1, 2], "2 and 3 shift down to 1 and 2");
}
#[test]
fn reindexing_moves_focus_with_it() {
let mut t = PaneTree::new(0);
t.split(SplitDirection::Vertical, 3);
assert_eq!(t.focus(), 3);
t.reindex_after_removal(1);
assert_eq!(t.focus(), 2);
assert!(t.contains(2));
}
#[test]
fn set_focus_ignores_a_session_not_in_this_tree() {
let mut t = PaneTree::new(0);
t.set_focus(42);
assert_eq!(t.focus(), 0, "focus must stay on a real pane");
}
#[test]
fn panes_tile_without_gaps_at_awkward_sizes() {
let mut t = PaneTree::new(0);
t.split(SplitDirection::Vertical, 1);
t.split(SplitDirection::Horizontal, 2);
for (w, h) in [(81u16, 23u16), (37, 11), (13, 5)] {
let a = Rect::new(0, 0, w, h);
let placed = t.layout(a);
let total: u32 = placed
.iter()
.map(|p| p.area.width as u32 * p.area.height as u32)
.sum();
assert_eq!(total, w as u32 * h as u32, "gap at {}x{}", w, h);
}
}
}