use ratatui::layout::Rect;
use crate::navigation;
use crate::types::{AreaId, CloseResult, SplitNode, SplitResult};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SplitArea {
pub id: AreaId,
pub rect: Rect,
}
#[derive(Debug, Clone)]
pub struct SplitManager {
root: SplitNode,
next_id: u64,
cached_areas: Vec<SplitArea>,
total_area: Rect,
dirty: bool,
}
impl Default for SplitManager {
fn default() -> Self {
Self::new()
}
}
impl SplitManager {
#[must_use]
pub fn new() -> Self {
let initial_id = AreaId(1);
Self {
root: SplitNode::Leaf { id: initial_id },
next_id: 2,
cached_areas: Vec::new(),
total_area: Rect::default(),
dirty: true,
}
}
pub fn areas(&mut self) -> &[SplitArea] {
self.recalculate_if_dirty();
&self.cached_areas
}
pub fn area_for_id(&mut self, id: AreaId) -> Option<Rect> {
self.recalculate_if_dirty();
self.cached_areas
.iter()
.find(|a| a.id == id)
.map(|a| a.rect)
}
pub fn set_viewport(&mut self, rect: Rect) -> &[SplitArea] {
if rect != self.total_area {
self.total_area = rect;
self.dirty = true;
}
self.areas()
}
pub fn navigate(
&mut self,
current_id: AreaId,
direction: crate::navigation::Direction,
) -> Option<AreaId> {
self.recalculate_if_dirty();
navigation::navigate(&self.cached_areas, current_id, direction)
}
pub fn split_horizontal(&mut self, id: AreaId) -> Option<SplitResult> {
let new_id = AreaId(self.next_id);
self.next_id += 1;
let new_leaf = SplitNode::Leaf { id: new_id };
let found = self.replace_leaf(id, |old| SplitNode::Horizontal {
top: Box::new(old),
bottom: Box::new(new_leaf.clone()),
ratio: 0.5,
});
if found {
self.dirty = true;
Some(SplitResult {
original: id,
new: new_id,
})
} else {
None
}
}
pub fn split_vertical(&mut self, id: AreaId) -> Option<SplitResult> {
let new_id = AreaId(self.next_id);
self.next_id += 1;
let new_leaf = SplitNode::Leaf { id: new_id };
let found = self.replace_leaf(id, |old| SplitNode::Vertical {
left: Box::new(old),
right: Box::new(new_leaf.clone()),
ratio: 0.5,
});
if found {
self.dirty = true;
Some(SplitResult {
original: id,
new: new_id,
})
} else {
None
}
}
pub fn close(&mut self, id: AreaId) -> Option<CloseResult> {
if self.is_single_leaf() {
return None;
}
let sibling_id = self.find_sibling_id(id)?;
let found = self.remove_leaf(id);
if found {
self.dirty = true;
Some(CloseResult {
removed: id,
surviving: sibling_id,
})
} else {
None
}
}
pub fn resize(
&mut self,
id: AreaId,
direction: crate::navigation::Direction,
amount: i16,
) -> bool {
let total_size = match direction {
crate::navigation::Direction::Left | crate::navigation::Direction::Right => {
self.total_area.width
}
crate::navigation::Direction::Up | crate::navigation::Direction::Down => {
self.total_area.height
}
};
if total_size == 0 {
return false;
}
let adjusted = self.adjust_parent_ratio(id, direction, amount, total_size);
if adjusted {
self.dirty = true;
}
adjusted
}
pub fn leaves(&self) -> Vec<AreaId> {
let mut result = Vec::new();
Self::collect_leaves(&self.root, &mut result);
result
}
pub fn contains(&self, id: AreaId) -> bool {
Self::contains_recursive(&self.root, id)
}
pub fn is_single_leaf(&self) -> bool {
self.root.is_leaf()
}
pub fn root_id(&self) -> AreaId {
self.root
.leaf_id()
.expect("root should always be a leaf when called on single-leaf tree")
}
fn recalculate_if_dirty(&mut self) {
if !self.dirty {
return;
}
self.cached_areas.clear();
Self::compute_areas(&self.root, self.total_area, &mut self.cached_areas);
self.dirty = false;
}
fn compute_areas(node: &SplitNode, rect: Rect, out: &mut Vec<SplitArea>) {
match node {
SplitNode::Leaf { id } => {
out.push(SplitArea { id: *id, rect });
}
SplitNode::Horizontal { top, bottom, ratio } => {
let total_height = f64::from(rect.height);
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let top_height = (total_height * ratio).round() as u16;
let bottom_height = rect.height.saturating_sub(top_height);
let top_rect = Rect::new(rect.x, rect.y, rect.width, top_height);
let bottom_rect = Rect::new(
rect.x,
rect.y.saturating_add(top_height),
rect.width,
bottom_height,
);
Self::compute_areas(top, top_rect, out);
Self::compute_areas(bottom, bottom_rect, out);
}
SplitNode::Vertical { left, right, ratio } => {
let total_width = f64::from(rect.width);
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let left_width = (total_width * ratio).round() as u16;
let right_width = rect.width.saturating_sub(left_width);
let left_rect = Rect::new(rect.x, rect.y, left_width, rect.height);
let right_rect = Rect::new(
rect.x.saturating_add(left_width),
rect.y,
right_width,
rect.height,
);
Self::compute_areas(left, left_rect, out);
Self::compute_areas(right, right_rect, out);
}
}
}
fn collect_leaves(node: &SplitNode, out: &mut Vec<AreaId>) {
match node {
SplitNode::Leaf { id } => out.push(*id),
SplitNode::Horizontal { top, bottom, .. } => {
Self::collect_leaves(top, out);
Self::collect_leaves(bottom, out);
}
SplitNode::Vertical { left, right, .. } => {
Self::collect_leaves(left, out);
Self::collect_leaves(right, out);
}
}
}
fn contains_recursive(node: &SplitNode, id: AreaId) -> bool {
match node {
SplitNode::Leaf { id: leaf_id } => *leaf_id == id,
SplitNode::Horizontal { top, bottom, .. } => {
Self::contains_recursive(top, id) || Self::contains_recursive(bottom, id)
}
SplitNode::Vertical { left, right, .. } => {
Self::contains_recursive(left, id) || Self::contains_recursive(right, id)
}
}
}
fn replace_leaf(&mut self, id: AreaId, mut f: impl FnMut(SplitNode) -> SplitNode) -> bool {
Self::replace_leaf_recursive(&mut self.root, id, &mut f)
}
fn replace_leaf_recursive(
node: &mut SplitNode,
id: AreaId,
f: &mut dyn FnMut(SplitNode) -> SplitNode,
) -> bool {
match node {
SplitNode::Leaf { id: leaf_id } if *leaf_id == id => {
let old = std::mem::replace(node, SplitNode::Leaf { id: AreaId(0) });
*node = f(old);
true
}
SplitNode::Horizontal { top, bottom, .. } => {
Self::replace_leaf_recursive(top, id, f)
|| Self::replace_leaf_recursive(bottom, id, f)
}
SplitNode::Vertical { left, right, .. } => {
Self::replace_leaf_recursive(left, id, f)
|| Self::replace_leaf_recursive(right, id, f)
}
_ => false,
}
}
fn find_sibling_id(&self, id: AreaId) -> Option<AreaId> {
Self::find_sibling_recursive(&self.root, id)
}
fn find_sibling_recursive(node: &SplitNode, id: AreaId) -> Option<AreaId> {
match node {
SplitNode::Leaf { .. } => None,
SplitNode::Horizontal { top, bottom, .. } => {
if Self::contains_recursive(top, id) {
if top.is_leaf() && top.leaf_id() == Some(id) {
Self::first_leaf(bottom)
} else {
Self::find_sibling_recursive(top, id)
}
} else if bottom.is_leaf() && bottom.leaf_id() == Some(id) {
Self::first_leaf(top)
} else {
Self::find_sibling_recursive(bottom, id)
}
}
SplitNode::Vertical { left, right, .. } => {
if Self::contains_recursive(left, id) {
if left.is_leaf() && left.leaf_id() == Some(id) {
Self::first_leaf(right)
} else {
Self::find_sibling_recursive(left, id)
}
} else if right.is_leaf() && right.leaf_id() == Some(id) {
Self::first_leaf(left)
} else {
Self::find_sibling_recursive(right, id)
}
}
}
}
fn first_leaf(node: &SplitNode) -> Option<AreaId> {
match node {
SplitNode::Leaf { id } => Some(*id),
SplitNode::Horizontal { top, .. } => Self::first_leaf(top),
SplitNode::Vertical { left, .. } => Self::first_leaf(left),
}
}
fn remove_leaf(&mut self, id: AreaId) -> bool {
Self::remove_leaf_recursive(&mut self.root, id)
}
fn remove_leaf_recursive(node: &mut SplitNode, id: AreaId) -> bool {
match node {
SplitNode::Leaf { .. } => false,
SplitNode::Horizontal { top, bottom, .. } => {
if top.is_leaf() && top.leaf_id() == Some(id) {
let sibling =
std::mem::replace(bottom.as_mut(), SplitNode::Leaf { id: AreaId(0) });
*node = sibling;
true
} else if bottom.is_leaf() && bottom.leaf_id() == Some(id) {
let sibling =
std::mem::replace(top.as_mut(), SplitNode::Leaf { id: AreaId(0) });
*node = sibling;
true
} else {
Self::remove_leaf_recursive(top.as_mut(), id)
|| Self::remove_leaf_recursive(bottom.as_mut(), id)
}
}
SplitNode::Vertical { left, right, .. } => {
if left.is_leaf() && left.leaf_id() == Some(id) {
let sibling =
std::mem::replace(right.as_mut(), SplitNode::Leaf { id: AreaId(0) });
*node = sibling;
true
} else if right.is_leaf() && right.leaf_id() == Some(id) {
let sibling =
std::mem::replace(left.as_mut(), SplitNode::Leaf { id: AreaId(0) });
*node = sibling;
true
} else {
Self::remove_leaf_recursive(left.as_mut(), id)
|| Self::remove_leaf_recursive(right.as_mut(), id)
}
}
}
}
fn adjust_parent_ratio(
&mut self,
id: AreaId,
direction: crate::navigation::Direction,
amount: i16,
total_size: u16,
) -> bool {
Self::adjust_ratio_recursive(&mut self.root, id, direction, amount, total_size)
}
#[allow(clippy::too_many_lines)]
fn adjust_ratio_recursive(
node: &mut SplitNode,
id: AreaId,
direction: crate::navigation::Direction,
amount: i16,
total_size: u16,
) -> bool {
match node {
SplitNode::Leaf { .. } => false,
SplitNode::Horizontal { top, bottom, ratio } => {
if top.is_leaf() && top.leaf_id() == Some(id) {
match direction {
crate::navigation::Direction::Down => {
let delta = f64::from(amount) / f64::from(total_size);
*ratio = (*ratio + delta).clamp(0.1, 0.9);
true
}
crate::navigation::Direction::Up => {
let delta = f64::from(amount) / f64::from(total_size);
*ratio = (*ratio - delta).clamp(0.1, 0.9);
true
}
_ => Self::adjust_ratio_recursive(top, id, direction, amount, total_size),
}
} else if bottom.is_leaf() && bottom.leaf_id() == Some(id) {
match direction {
crate::navigation::Direction::Up => {
let delta = f64::from(amount) / f64::from(total_size);
*ratio = (*ratio + delta).clamp(0.1, 0.9);
true
}
crate::navigation::Direction::Down => {
let delta = f64::from(amount) / f64::from(total_size);
*ratio = (*ratio - delta).clamp(0.1, 0.9);
true
}
_ => {
Self::adjust_ratio_recursive(bottom, id, direction, amount, total_size)
}
}
} else if Self::contains_recursive(top, id) {
Self::adjust_ratio_recursive(top, id, direction, amount, total_size)
} else {
Self::adjust_ratio_recursive(bottom, id, direction, amount, total_size)
}
}
SplitNode::Vertical { left, right, ratio } => {
if left.is_leaf() && left.leaf_id() == Some(id) {
match direction {
crate::navigation::Direction::Right => {
let delta = f64::from(amount) / f64::from(total_size);
*ratio = (*ratio + delta).clamp(0.1, 0.9);
true
}
crate::navigation::Direction::Left => {
let delta = f64::from(amount) / f64::from(total_size);
*ratio = (*ratio - delta).clamp(0.1, 0.9);
true
}
_ => Self::adjust_ratio_recursive(left, id, direction, amount, total_size),
}
} else if right.is_leaf() && right.leaf_id() == Some(id) {
match direction {
crate::navigation::Direction::Left => {
let delta = f64::from(amount) / f64::from(total_size);
*ratio = (*ratio + delta).clamp(0.1, 0.9);
true
}
crate::navigation::Direction::Right => {
let delta = f64::from(amount) / f64::from(total_size);
*ratio = (*ratio - delta).clamp(0.1, 0.9);
true
}
_ => Self::adjust_ratio_recursive(right, id, direction, amount, total_size),
}
} else if Self::contains_recursive(left, id) {
Self::adjust_ratio_recursive(left, id, direction, amount, total_size)
} else {
Self::adjust_ratio_recursive(right, id, direction, amount, total_size)
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::navigation::Direction;
#[test]
fn new_manager_has_single_leaf_with_full_viewport() {
let mut mgr = SplitManager::new();
mgr.set_viewport(Rect::new(0, 0, 100, 100));
let areas = mgr.areas();
assert_eq!(areas.len(), 1);
assert_eq!(areas[0].id, AreaId(1));
assert_eq!(areas[0].rect, Rect::new(0, 0, 100, 100));
}
#[test]
fn split_vertical_divides_area_into_left_and_right() {
let mut mgr = SplitManager::new();
mgr.set_viewport(Rect::new(0, 0, 100, 100));
let result = mgr.split_vertical(AreaId(1)).unwrap();
assert_eq!(result.original, AreaId(1));
assert_eq!(result.new, AreaId(2));
let areas = mgr.areas();
assert_eq!(areas.len(), 2);
assert_eq!(areas[0].id, AreaId(1));
assert_eq!(areas[0].rect, Rect::new(0, 0, 50, 100));
assert_eq!(areas[1].id, AreaId(2));
assert_eq!(areas[1].rect, Rect::new(50, 0, 50, 100));
}
#[test]
fn split_horizontal_divides_area_into_top_and_bottom() {
let mut mgr = SplitManager::new();
mgr.set_viewport(Rect::new(0, 0, 100, 100));
let result = mgr.split_horizontal(AreaId(1)).unwrap();
assert_eq!(result.new, AreaId(2));
let areas = mgr.areas();
assert_eq!(areas.len(), 2);
assert_eq!(areas[0].rect, Rect::new(0, 0, 100, 50));
assert_eq!(areas[1].rect, Rect::new(0, 50, 100, 50));
}
#[test]
fn nested_splits_produce_correct_areas() {
let mut mgr = SplitManager::new();
mgr.set_viewport(Rect::new(0, 0, 100, 100));
mgr.split_vertical(AreaId(1)).unwrap();
mgr.split_horizontal(AreaId(2)).unwrap();
let areas = mgr.areas();
assert_eq!(areas.len(), 3);
assert_eq!(areas[0].id, AreaId(1));
assert_eq!(areas[0].rect, Rect::new(0, 0, 50, 100));
assert_eq!(areas[1].id, AreaId(2));
assert_eq!(areas[1].rect, Rect::new(50, 0, 50, 50));
assert_eq!(areas[2].id, AreaId(3));
assert_eq!(areas[2].rect, Rect::new(50, 50, 50, 50));
}
#[test]
fn split_returns_none_for_nonexistent_leaf() {
let mut mgr = SplitManager::new();
let result = mgr.split_vertical(AreaId(99));
assert!(result.is_none());
}
#[test]
fn close_promotes_sibling_to_replace_parent() {
let mut mgr = SplitManager::new();
mgr.set_viewport(Rect::new(0, 0, 100, 100));
mgr.split_vertical(AreaId(1)).unwrap();
let result = mgr.close(AreaId(2)).unwrap();
assert_eq!(result.removed, AreaId(2));
assert_eq!(result.surviving, AreaId(1));
let areas = mgr.areas();
assert_eq!(areas.len(), 1);
assert_eq!(areas[0].rect, Rect::new(0, 0, 100, 100));
}
#[test]
fn close_returns_none_when_single_leaf_remains() {
let mut mgr = SplitManager::new();
let result = mgr.close(AreaId(1));
assert!(result.is_none());
}
#[test]
fn resize_from_left_child_grows_leftward() {
let mut mgr = SplitManager::new();
mgr.set_viewport(Rect::new(0, 0, 100, 100));
mgr.split_vertical(AreaId(1)).unwrap();
let resized = mgr.resize(AreaId(1), Direction::Right, 10);
assert!(resized);
let areas = mgr.areas();
assert!(areas[0].rect.width > 50);
assert!(areas[1].rect.width < 50);
}
#[test]
fn resize_from_right_child_grows_rightward() {
let mut mgr = SplitManager::new();
mgr.set_viewport(Rect::new(0, 0, 100, 100));
mgr.split_vertical(AreaId(1)).unwrap();
let resized = mgr.resize(AreaId(2), Direction::Right, 10);
assert!(resized);
let areas = mgr.areas();
assert!(areas[0].rect.width < 50);
assert!(areas[1].rect.width > 50);
}
#[test]
fn resize_from_bottom_child_grows_downward() {
let mut mgr = SplitManager::new();
mgr.set_viewport(Rect::new(0, 0, 100, 100));
mgr.split_horizontal(AreaId(1)).unwrap();
let resized = mgr.resize(AreaId(2), Direction::Down, 10);
assert!(resized);
let areas = mgr.areas();
assert!(areas[0].rect.height < 50);
assert!(areas[1].rect.height > 50);
}
#[test]
fn mutation_invalidates_area_cache() {
let mut mgr = SplitManager::new();
mgr.set_viewport(Rect::new(0, 0, 100, 100));
let areas_before = mgr.areas();
assert_eq!(areas_before.len(), 1);
mgr.split_vertical(AreaId(1)).unwrap();
let areas_after = mgr.areas();
assert_eq!(areas_after.len(), 2);
}
#[test]
fn leaves_returns_all_leaf_ids() {
let mut mgr = SplitManager::new();
mgr.split_vertical(AreaId(1)).unwrap();
mgr.split_horizontal(AreaId(2)).unwrap();
let mut leaves = mgr.leaves();
leaves.sort();
assert_eq!(leaves, vec![AreaId(1), AreaId(2), AreaId(3)]);
}
#[test]
fn contains_returns_true_for_existing_leaf() {
let mgr = SplitManager::new();
let found = mgr.contains(AreaId(1));
assert!(found);
}
#[test]
fn contains_returns_false_for_missing_leaf() {
let mgr = SplitManager::new();
let found = mgr.contains(AreaId(99));
assert!(!found);
}
#[test]
fn is_single_leaf_returns_true_before_split() {
let mgr = SplitManager::new();
let result = mgr.is_single_leaf();
assert!(result);
}
#[test]
fn is_single_leaf_returns_false_after_split() {
let mut mgr = SplitManager::new();
mgr.split_vertical(AreaId(1)).unwrap();
let result = mgr.is_single_leaf();
assert!(!result);
}
#[test]
fn navigate_returns_right_neighbor_across_split() {
let mut mgr = SplitManager::new();
mgr.set_viewport(Rect::new(0, 0, 100, 100));
mgr.split_vertical(AreaId(1)).unwrap();
let result = mgr.navigate(AreaId(1), Direction::Right);
assert_eq!(result, Some(AreaId(2)));
}
#[test]
fn navigate_returns_left_neighbor_across_split() {
let mut mgr = SplitManager::new();
mgr.set_viewport(Rect::new(0, 0, 100, 100));
mgr.split_vertical(AreaId(1)).unwrap();
let result = mgr.navigate(AreaId(2), Direction::Left);
assert_eq!(result, Some(AreaId(1)));
}
#[test]
fn navigate_returns_none_at_edge() {
let mut mgr = SplitManager::new();
mgr.set_viewport(Rect::new(0, 0, 100, 100));
mgr.split_vertical(AreaId(1)).unwrap();
let result = mgr.navigate(AreaId(1), Direction::Left);
assert_eq!(result, None);
}
#[test]
fn navigate_finds_right_neighbor_in_nested_layout() {
let mut mgr = SplitManager::new();
mgr.set_viewport(Rect::new(0, 0, 100, 100));
mgr.split_vertical(AreaId(1)).unwrap();
mgr.split_horizontal(AreaId(2)).unwrap();
let result = mgr.navigate(AreaId(1), Direction::Right);
assert_eq!(result, Some(AreaId(2)));
}
#[test]
fn navigate_finds_left_neighbor_in_nested_layout() {
let mut mgr = SplitManager::new();
mgr.set_viewport(Rect::new(0, 0, 100, 100));
mgr.split_vertical(AreaId(1)).unwrap();
mgr.split_horizontal(AreaId(2)).unwrap();
let result = mgr.navigate(AreaId(2), Direction::Left);
assert_eq!(result, Some(AreaId(1)));
}
#[test]
fn navigate_finds_up_neighbor_in_nested_layout() {
let mut mgr = SplitManager::new();
mgr.set_viewport(Rect::new(0, 0, 100, 100));
mgr.split_vertical(AreaId(1)).unwrap();
mgr.split_horizontal(AreaId(2)).unwrap();
let result = mgr.navigate(AreaId(3), Direction::Up);
assert_eq!(result, Some(AreaId(2)));
}
#[test]
fn navigate_finds_down_neighbor_in_nested_layout() {
let mut mgr = SplitManager::new();
mgr.set_viewport(Rect::new(0, 0, 100, 100));
mgr.split_vertical(AreaId(1)).unwrap();
mgr.split_horizontal(AreaId(2)).unwrap();
let result = mgr.navigate(AreaId(2), Direction::Down);
assert_eq!(result, Some(AreaId(3)));
}
#[test]
fn area_for_id_returns_correct_rects_after_split() {
let mut mgr = SplitManager::new();
mgr.set_viewport(Rect::new(0, 0, 100, 100));
mgr.split_vertical(AreaId(1)).unwrap();
let left = mgr.area_for_id(AreaId(1));
let right = mgr.area_for_id(AreaId(2));
assert_eq!(left, Some(Rect::new(0, 0, 50, 100)));
assert_eq!(right, Some(Rect::new(50, 0, 50, 100)));
}
#[test]
fn area_for_id_returns_none_for_missing_id() {
let mut mgr = SplitManager::new();
mgr.set_viewport(Rect::new(0, 0, 100, 100));
let result = mgr.area_for_id(AreaId(99));
assert_eq!(result, None);
}
#[test]
fn four_way_split_produces_nonzero_areas() {
let mut mgr = SplitManager::new();
mgr.set_viewport(Rect::new(0, 0, 100, 100));
mgr.split_vertical(AreaId(1)).unwrap();
mgr.split_horizontal(AreaId(1)).unwrap();
mgr.split_horizontal(AreaId(2)).unwrap();
let areas = mgr.areas();
assert_eq!(areas.len(), 4);
for area in areas {
assert!(area.rect.width > 0);
assert!(area.rect.height > 0);
}
}
}