use ratatui::layout::{Constraint, Direction, Layout, Rect};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SplitDirection {
Horizontal,
Vertical,
}
impl From<SplitDirection> for Direction {
fn from(d: SplitDirection) -> Self {
match d {
SplitDirection::Horizontal => Direction::Horizontal,
SplitDirection::Vertical => Direction::Vertical,
}
}
}
#[derive(Debug, Clone)]
pub enum LayoutNode {
Pane(usize),
Slot(usize),
Split {
direction: SplitDirection,
children: Vec<(Constraint, LayoutNode)>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SlotCase {
pub trigger_panes: Vec<usize>,
pub then_pane: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SlotRule {
pub slot_id: usize,
pub cases: Vec<SlotCase>,
pub default_pane: usize,
}
impl SlotRule {
#[cfg(test)]
pub fn single(
slot_id: usize,
trigger_panes: Vec<usize>,
then_pane: usize,
default_pane: usize,
) -> Self {
Self {
slot_id,
cases: vec![SlotCase {
trigger_panes,
then_pane,
}],
default_pane,
}
}
pub fn resolve(&self, focused_pane: usize) -> usize {
self.cases
.iter()
.find(|c| c.trigger_panes.contains(&focused_pane))
.map(|c| c.then_pane)
.unwrap_or(self.default_pane)
}
}
pub struct PageLayoutConfig {
pub tree: LayoutNode,
pub tab_panes: Vec<usize>,
pub slot_rules: Vec<SlotRule>,
}
impl PageLayoutConfig {
pub fn resolve_slots(&self, focused_pane: usize) -> Vec<(usize, usize)> {
self.slot_rules
.iter()
.map(|r| (r.slot_id, r.resolve(focused_pane)))
.collect()
}
}
pub struct PageFrame {
pub header: Rect,
pub content: Rect,
pub status_bar: Rect,
}
pub fn split_page_frame(area: Rect) -> PageFrame {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(3),
Constraint::Length(1),
])
.split(area);
PageFrame {
header: chunks[0],
content: chunks[1],
status_bar: chunks[2],
}
}
pub fn resolve_layout(
area: Rect,
tree: &LayoutNode,
slots: &[(usize, usize)],
) -> Vec<(usize, Rect)> {
let mut result = Vec::new();
resolve_node(area, tree, slots, &mut result);
result
}
fn resolve_node(
area: Rect,
node: &LayoutNode,
slots: &[(usize, usize)],
out: &mut Vec<(usize, Rect)>,
) {
match node {
LayoutNode::Pane(id) => {
out.push((*id, area));
}
LayoutNode::Slot(slot_id) => {
let pane_id = slots
.iter()
.find(|(s, _)| s == slot_id)
.map(|(_, p)| *p)
.unwrap_or(*slot_id);
out.push((pane_id, area));
}
LayoutNode::Split {
direction,
children,
} => {
let constraints: Vec<Constraint> = children.iter().map(|(c, _)| *c).collect();
let chunks = Layout::default()
.direction(Direction::from(*direction))
.constraints(constraints)
.split(area);
for (i, (_, child)) in children.iter().enumerate() {
resolve_node(chunks[i], child, slots, out);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::layout::Rect;
#[test]
fn single_pane_fills_area() {
let area = Rect::new(0, 0, 80, 24);
let tree = LayoutNode::Pane(42);
let result = resolve_layout(area, &tree, &[]);
assert_eq!(result, vec![(42, area)]);
}
#[test]
fn horizontal_split() {
let area = Rect::new(0, 0, 100, 24);
let tree = LayoutNode::Split {
direction: SplitDirection::Horizontal,
children: vec![
(Constraint::Percentage(50), LayoutNode::Pane(0)),
(Constraint::Percentage(50), LayoutNode::Pane(1)),
],
};
let result = resolve_layout(area, &tree, &[]);
assert_eq!(result.len(), 2);
assert_eq!(result[0].0, 0);
assert_eq!(result[1].0, 1);
assert_eq!(result[0].1.height, 24);
assert_eq!(result[1].1.height, 24);
assert_eq!(result[0].1.width + result[1].1.width, 100);
}
#[test]
fn nested_split() {
let area = Rect::new(0, 0, 80, 40);
let tree = LayoutNode::Split {
direction: SplitDirection::Vertical,
children: vec![
(
Constraint::Percentage(50),
LayoutNode::Split {
direction: SplitDirection::Horizontal,
children: vec![
(Constraint::Percentage(50), LayoutNode::Pane(0)),
(Constraint::Percentage(50), LayoutNode::Pane(1)),
],
},
),
(Constraint::Percentage(50), LayoutNode::Pane(2)),
],
};
let result = resolve_layout(area, &tree, &[]);
assert_eq!(result.len(), 3);
assert_eq!(result[0].0, 0);
assert_eq!(result[1].0, 1);
assert_eq!(result[2].0, 2);
assert_eq!(result[0].1.y, result[1].1.y);
assert!(result[2].1.y > result[0].1.y);
}
#[test]
fn slot_resolution() {
let area = Rect::new(0, 0, 80, 24);
let tree = LayoutNode::Slot(0);
let result = resolve_layout(area, &tree, &[(0, 99)]);
assert_eq!(result, vec![(99, area)]);
}
#[test]
fn slot_rule_trigger() {
let config = PageLayoutConfig {
tree: LayoutNode::Slot(0),
tab_panes: vec![0, 1],
slot_rules: vec![SlotRule::single(0, vec![1, 3], 2, 4)],
};
assert_eq!(config.resolve_slots(1), vec![(0, 2)]);
assert_eq!(config.resolve_slots(3), vec![(0, 2)]);
assert_eq!(config.resolve_slots(0), vec![(0, 4)]);
assert_eq!(config.resolve_slots(99), vec![(0, 4)]);
}
#[test]
fn multiple_slot_rules() {
let config = PageLayoutConfig {
tree: LayoutNode::Pane(0),
tab_panes: vec![],
slot_rules: vec![
SlotRule::single(0, vec![1], 10, 20),
SlotRule::single(1, vec![2], 30, 40),
],
};
assert_eq!(config.resolve_slots(1), vec![(0, 10), (1, 40)]);
assert_eq!(config.resolve_slots(2), vec![(0, 20), (1, 30)]);
}
#[test]
fn multi_case_slot_rule_takes_the_first_matching_case() {
let rule = SlotRule {
slot_id: 0,
cases: vec![
SlotCase {
trigger_panes: vec![1, 3],
then_pane: 3,
},
SlotCase {
trigger_panes: vec![4, 5, 3],
then_pane: 5,
},
],
default_pane: 2,
};
assert_eq!(rule.resolve(1), 3);
assert_eq!(rule.resolve(3), 3, "earlier case wins");
assert_eq!(rule.resolve(4), 5);
assert_eq!(rule.resolve(5), 5);
assert_eq!(rule.resolve(0), 2);
assert_eq!(rule.resolve(99), 2);
}
#[test]
fn page_frame_splits_correctly() {
let area = Rect::new(0, 0, 80, 24);
let frame = split_page_frame(area);
assert_eq!(frame.header.height, 1);
assert_eq!(frame.status_bar.height, 1);
assert_eq!(frame.content.height, 22);
assert_eq!(frame.header.y, 0);
assert_eq!(frame.content.y, 1);
assert_eq!(frame.status_bar.y, 23);
}
}