#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::{layout::Rect, sanitize_str, widget::PaneModel, Vec2, WIDGET_PANE_MAX_ITEMS};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum PaneSplitAxis {
Horizontal,
Vertical,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum PaneEdge {
Top,
Right,
Bottom,
Left,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum PaneDirection {
Left,
Right,
Up,
Down,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum PaneDropRegion {
Center,
Edge(PaneEdge),
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct PaneSplit {
pub id: u64,
pub axis: PaneSplitAxis,
pub ratio: f32,
}
impl PaneSplit {
pub fn new(id: u64, axis: PaneSplitAxis, ratio: f32) -> Self {
Self {
id,
axis,
ratio: sanitize_ratio(ratio),
}
}
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum PaneTreeNode {
Pane {
id: String,
},
Split {
split: PaneSplit,
first: Box<PaneTreeNode>,
second: Box<PaneTreeNode>,
},
}
impl PaneTreeNode {
pub fn pane(id: impl AsRef<str>) -> Self {
Self::Pane {
id: sanitize_pane_id(id.as_ref()),
}
}
pub fn split(
id: u64,
axis: PaneSplitAxis,
ratio: f32,
first: PaneTreeNode,
second: PaneTreeNode,
) -> Self {
Self::Split {
split: PaneSplit::new(id, axis, ratio),
first: Box::new(first),
second: Box::new(second),
}
}
pub fn contains_pane(&self, id: &str) -> bool {
let id = sanitize_pane_id(id);
self.contains_sanitized_pane(&id)
}
pub fn pane_count(&self) -> usize {
match self {
Self::Pane { .. } => 1,
Self::Split { first, second, .. } => first.pane_count() + second.pane_count(),
}
}
fn contains_sanitized_pane(&self, id: &str) -> bool {
match self {
Self::Pane { id: pane_id } => pane_id == id,
Self::Split { first, second, .. } => {
first.contains_sanitized_pane(id) || second.contains_sanitized_pane(id)
}
}
}
fn first_pane_id(&self) -> Option<&str> {
match self {
Self::Pane { id } => Some(id),
Self::Split { first, .. } => first.first_pane_id(),
}
}
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct PaneLayoutSlot {
pub id: String,
pub rect: Rect,
pub active: bool,
pub maximized: bool,
}
#[derive(Clone, Debug, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct PaneGridModel {
pub panes: Vec<PaneModel>,
pub root: Option<PaneTreeNode>,
pub active_id: Option<String>,
pub maximized_id: Option<String>,
pub rect: Rect,
pub gap_px: f32,
pub next_split_id: u64,
}
impl PaneGridModel {
pub fn new(first: PaneModel) -> Self {
let id = sanitize_pane_id(&first.id);
Self {
panes: vec![first],
root: Some(PaneTreeNode::pane(&id)),
active_id: Some(id),
maximized_id: None,
rect: Rect::ZERO,
gap_px: 8.0,
next_split_id: 1,
}
}
pub fn from_panes(panes: impl IntoIterator<Item = PaneModel>) -> Self {
let mut panes = panes.into_iter();
let Some(first) = panes.next() else {
return Self::default();
};
let mut grid = Self::new(first);
for pane in panes.take(WIDGET_PANE_MAX_ITEMS.saturating_sub(1)) {
let target = grid.active_id.clone();
if let Some(target) = target {
let _ = grid.split(&target, PaneSplitAxis::Vertical, pane, true);
}
}
grid
}
pub fn with_rect(mut self, rect: Rect) -> Self {
self.rect = sanitize_rect(rect);
self
}
pub fn with_gap(mut self, gap_px: f32) -> Self {
self.gap_px = finite_or(gap_px, 0.0).clamp(0.0, 128.0);
self
}
pub fn len(&self) -> usize {
self.panes.len().min(WIDGET_PANE_MAX_ITEMS)
}
pub fn is_empty(&self) -> bool {
self.len() == 0 || self.root.is_none()
}
pub fn active(&self) -> Option<&PaneModel> {
self.active_id
.as_deref()
.and_then(|id| self.pane(id))
.or_else(|| self.panes.first())
}
pub fn pane(&self, id: &str) -> Option<&PaneModel> {
let id = sanitize_pane_id(id);
self.panes.iter().find(|pane| pane.id == id)
}
pub fn pane_mut(&mut self, id: &str) -> Option<&mut PaneModel> {
let id = sanitize_pane_id(id);
self.panes.iter_mut().find(|pane| pane.id == id)
}
pub fn set_active(&mut self, id: &str) -> bool {
let id = sanitize_pane_id(id);
if self.root_contains(&id) && self.pane(&id).is_some() {
self.active_id = Some(id);
true
} else {
false
}
}
pub fn split(
&mut self,
target_id: &str,
axis: PaneSplitAxis,
pane: PaneModel,
after: bool,
) -> Option<(String, u64)> {
if self.len() >= WIDGET_PANE_MAX_ITEMS {
return None;
}
let new_id = sanitize_pane_id(&pane.id);
let target_id = sanitize_pane_id(target_id);
if new_id.is_empty() || self.pane(&new_id).is_some() || !self.root_contains(&target_id) {
return None;
}
let split_id = self.alloc_split_id()?;
if !self.split_existing(&target_id, &new_id, axis, after, split_id) {
return None;
}
self.panes.push(pane);
self.active_id = Some(new_id.clone());
self.maximized_id = None;
Some((new_id, split_id))
}
pub fn split_with(&mut self, target_id: &str, moving_id: &str, region: PaneDropRegion) -> bool {
let target_id = sanitize_pane_id(target_id);
let moving_id = sanitize_pane_id(moving_id);
if target_id == moving_id
|| !self.root_contains(&target_id)
|| !self.root_contains(&moving_id)
{
return false;
}
if matches!(region, PaneDropRegion::Center) {
return self.swap(&target_id, &moving_id);
}
let Some(mut root) = self.root.take() else {
return false;
};
if !remove_leaf(&mut root, &moving_id) {
self.root = Some(root);
return false;
}
self.root = Some(root);
let (axis, after) = axis_after_for_region(region);
let Some(split_id) = self.alloc_split_id() else {
return false;
};
let moved = self.split_existing(&target_id, &moving_id, axis, after, split_id);
if moved {
self.active_id = Some(moving_id);
self.maximized_id = None;
}
moved
}
pub fn drop_pane(&mut self, moving_id: &str, target_id: &str, region: PaneDropRegion) -> bool {
self.split_with(target_id, moving_id, region)
}
pub fn move_to_edge(&mut self, id: &str, edge: PaneEdge) -> bool {
let id = sanitize_pane_id(id);
if !self.root_contains(&id) {
return false;
}
let Some(root) = self.root.take() else {
return false;
};
if matches!(&root, PaneTreeNode::Pane { id: pane_id } if pane_id == &id) {
self.root = Some(root);
return true;
}
let mut root = root;
if !remove_leaf(&mut root, &id) {
self.root = Some(root);
return false;
}
let Some(split_id) = self.alloc_split_id() else {
self.root = Some(root);
return false;
};
let axis = axis_for_edge(edge);
let moving = PaneTreeNode::pane(&id);
self.root = Some(match edge {
PaneEdge::Top | PaneEdge::Left => {
PaneTreeNode::split(split_id, axis, 0.25, moving, root)
}
PaneEdge::Right | PaneEdge::Bottom => {
PaneTreeNode::split(split_id, axis, 0.75, root, moving)
}
});
self.active_id = Some(id);
self.maximized_id = None;
true
}
pub fn resize(&mut self, split_id: u64, ratio: f32) -> bool {
let Some(root) = self.root.as_mut() else {
return false;
};
resize_split(root, split_id, sanitize_ratio(ratio))
}
pub fn swap(&mut self, left: &str, right: &str) -> bool {
let left = sanitize_pane_id(left);
let right = sanitize_pane_id(right);
if left == right || !self.root_contains(&left) || !self.root_contains(&right) {
return false;
}
if let Some(root) = self.root.as_mut() {
swap_leaf_ids(root, &left, &right);
if self.active_id.as_deref() == Some(left.as_str()) {
self.active_id = Some(right);
} else if self.active_id.as_deref() == Some(right.as_str()) {
self.active_id = Some(left);
}
true
} else {
false
}
}
pub fn close(&mut self, id: &str) -> Option<PaneModel> {
let id = sanitize_pane_id(id);
let removed_from_tree = if self.root.as_ref().is_some_and(
|root| matches!(root, PaneTreeNode::Pane { id: pane_id } if pane_id == &id),
) {
self.root = None;
true
} else {
let root = self.root.as_mut()?;
remove_leaf(root, &id)
};
if !removed_from_tree {
return None;
}
let removed_index = self.panes.iter().position(|pane| pane.id == id)?;
let removed = self.panes.remove(removed_index);
if self.maximized_id.as_deref() == Some(id.as_str()) {
self.maximized_id = None;
}
if self.active_id.as_deref() == Some(id.as_str()) {
self.active_id = self
.root
.as_ref()
.and_then(|root| root.first_pane_id().map(str::to_string));
}
Some(removed)
}
pub fn maximize(&mut self, id: &str) -> bool {
let id = sanitize_pane_id(id);
if self.root_contains(&id) {
self.maximized_id = Some(id.clone());
self.active_id = Some(id);
true
} else {
false
}
}
pub fn restore(&mut self) -> bool {
self.maximized_id.take().is_some()
}
pub fn layout_slots(&self) -> Vec<PaneLayoutSlot> {
let rect = sanitize_rect(self.rect);
if rect.is_empty() {
return Vec::new();
}
if let Some(maximized_id) = self.maximized_id.as_deref() {
if self.root_contains(maximized_id) {
return vec![PaneLayoutSlot {
id: maximized_id.to_string(),
rect,
active: self.active_id.as_deref() == Some(maximized_id),
maximized: true,
}];
}
}
let Some(root) = self.root.as_ref() else {
return Vec::new();
};
let mut slots = Vec::with_capacity(self.len());
layout_node(
root,
rect,
finite_or(self.gap_px, 0.0).clamp(0.0, 128.0),
self.active_id.as_deref(),
&mut slots,
);
slots
}
pub fn layout_panes(&self) -> Vec<PaneModel> {
self.layout_slots()
.into_iter()
.filter_map(|slot| {
let mut pane = self.pane(&slot.id)?.clone();
pane.rect = slot.rect;
Some(pane)
})
.collect()
}
pub fn hit_test(&self, point: Vec2) -> Option<&PaneModel> {
let point = Vec2::new(finite_or(point.x, 0.0), finite_or(point.y, 0.0));
self.layout_slots()
.into_iter()
.rev()
.find(|slot| slot.rect.contains(point.x, point.y))
.and_then(|slot| self.pane(&slot.id))
}
pub fn adjacent(&self, id: &str, direction: PaneDirection) -> Option<String> {
let id = sanitize_pane_id(id);
let slots = self.layout_slots();
let current = slots.iter().find(|slot| slot.id == id)?;
let mut candidates = slots
.iter()
.filter(|slot| slot.id != id)
.filter(|slot| overlaps_for_direction(current.rect, slot.rect, direction))
.collect::<Vec<_>>();
candidates.sort_by(|left, right| {
distance_for_direction(current.rect, left.rect, direction)
.total_cmp(&distance_for_direction(current.rect, right.rect, direction))
});
candidates.first().map(|slot| slot.id.clone())
}
fn root_contains(&self, id: &str) -> bool {
self.root
.as_ref()
.is_some_and(|root| root.contains_sanitized_pane(id))
}
fn split_existing(
&mut self,
target_id: &str,
new_id: &str,
axis: PaneSplitAxis,
after: bool,
split_id: u64,
) -> bool {
let Some(root) = self.root.as_mut() else {
self.root = Some(PaneTreeNode::pane(new_id));
return true;
};
split_leaf(
root,
target_id,
new_id,
PaneSplit::new(split_id, axis, 0.5),
after,
)
}
fn alloc_split_id(&mut self) -> Option<u64> {
let id = self.next_split_id;
self.next_split_id = self.next_split_id.checked_add(1)?;
Some(id)
}
}
fn split_leaf(
node: &mut PaneTreeNode,
target_id: &str,
new_id: &str,
split: PaneSplit,
after: bool,
) -> bool {
match node {
PaneTreeNode::Pane { id } if id == target_id => {
let old_id = id.clone();
let old_node = PaneTreeNode::pane(&old_id);
let new_node = PaneTreeNode::pane(new_id);
let (first, second) = if after {
(old_node, new_node)
} else {
(new_node, old_node)
};
*node = PaneTreeNode::Split {
split,
first: Box::new(first),
second: Box::new(second),
};
true
}
PaneTreeNode::Pane { .. } => false,
PaneTreeNode::Split { first, second, .. } => {
split_leaf(first, target_id, new_id, split, after)
|| split_leaf(second, target_id, new_id, split, after)
}
}
}
fn remove_leaf(node: &mut PaneTreeNode, id: &str) -> bool {
match node {
PaneTreeNode::Pane { .. } => false,
PaneTreeNode::Split { first, second, .. } => {
if matches!(&**first, PaneTreeNode::Pane { id: pane_id } if pane_id == id) {
*node = (**second).clone();
return true;
}
if matches!(&**second, PaneTreeNode::Pane { id: pane_id } if pane_id == id) {
*node = (**first).clone();
return true;
}
remove_leaf(first, id) || remove_leaf(second, id)
}
}
}
fn resize_split(node: &mut PaneTreeNode, split_id: u64, ratio: f32) -> bool {
match node {
PaneTreeNode::Pane { .. } => false,
PaneTreeNode::Split {
split,
first,
second,
} => {
if split.id == split_id {
split.ratio = sanitize_ratio(ratio);
true
} else {
resize_split(first, split_id, ratio) || resize_split(second, split_id, ratio)
}
}
}
}
fn swap_leaf_ids(node: &mut PaneTreeNode, left: &str, right: &str) {
match node {
PaneTreeNode::Pane { id } if id == left => *id = right.to_string(),
PaneTreeNode::Pane { id } if id == right => *id = left.to_string(),
PaneTreeNode::Pane { .. } => {}
PaneTreeNode::Split { first, second, .. } => {
swap_leaf_ids(first, left, right);
swap_leaf_ids(second, left, right);
}
}
}
fn layout_node(
node: &PaneTreeNode,
rect: Rect,
gap: f32,
active_id: Option<&str>,
slots: &mut Vec<PaneLayoutSlot>,
) {
match node {
PaneTreeNode::Pane { id } => slots.push(PaneLayoutSlot {
id: id.clone(),
rect,
active: active_id == Some(id.as_str()),
maximized: false,
}),
PaneTreeNode::Split {
split,
first,
second,
} => {
let ratio = sanitize_ratio(split.ratio);
match split.axis {
PaneSplitAxis::Vertical => {
let available = (rect.width - gap).max(0.0);
let first_width = available * ratio;
let second_width = available - first_width;
layout_node(
first,
Rect::new(rect.x, rect.y, first_width, rect.height),
gap,
active_id,
slots,
);
layout_node(
second,
Rect::new(
rect.x + first_width + gap,
rect.y,
second_width,
rect.height,
),
gap,
active_id,
slots,
);
}
PaneSplitAxis::Horizontal => {
let available = (rect.height - gap).max(0.0);
let first_height = available * ratio;
let second_height = available - first_height;
layout_node(
first,
Rect::new(rect.x, rect.y, rect.width, first_height),
gap,
active_id,
slots,
);
layout_node(
second,
Rect::new(
rect.x,
rect.y + first_height + gap,
rect.width,
second_height,
),
gap,
active_id,
slots,
);
}
}
}
}
}
fn axis_after_for_region(region: PaneDropRegion) -> (PaneSplitAxis, bool) {
match region {
PaneDropRegion::Center => (PaneSplitAxis::Vertical, true),
PaneDropRegion::Edge(PaneEdge::Top) => (PaneSplitAxis::Horizontal, false),
PaneDropRegion::Edge(PaneEdge::Bottom) => (PaneSplitAxis::Horizontal, true),
PaneDropRegion::Edge(PaneEdge::Left) => (PaneSplitAxis::Vertical, false),
PaneDropRegion::Edge(PaneEdge::Right) => (PaneSplitAxis::Vertical, true),
}
}
fn axis_for_edge(edge: PaneEdge) -> PaneSplitAxis {
match edge {
PaneEdge::Top | PaneEdge::Bottom => PaneSplitAxis::Horizontal,
PaneEdge::Left | PaneEdge::Right => PaneSplitAxis::Vertical,
}
}
fn overlaps_for_direction(current: Rect, candidate: Rect, direction: PaneDirection) -> bool {
let epsilon = 0.01;
match direction {
PaneDirection::Left => {
candidate.right() <= current.x + epsilon
&& ranges_overlap(candidate.y, candidate.bottom(), current.y, current.bottom())
}
PaneDirection::Right => {
candidate.x >= current.right() - epsilon
&& ranges_overlap(candidate.y, candidate.bottom(), current.y, current.bottom())
}
PaneDirection::Up => {
candidate.bottom() <= current.y + epsilon
&& ranges_overlap(candidate.x, candidate.right(), current.x, current.right())
}
PaneDirection::Down => {
candidate.y >= current.bottom() - epsilon
&& ranges_overlap(candidate.x, candidate.right(), current.x, current.right())
}
}
}
fn distance_for_direction(current: Rect, candidate: Rect, direction: PaneDirection) -> f32 {
match direction {
PaneDirection::Left => (current.x - candidate.right()).abs(),
PaneDirection::Right => (candidate.x - current.right()).abs(),
PaneDirection::Up => (current.y - candidate.bottom()).abs(),
PaneDirection::Down => (candidate.y - current.bottom()).abs(),
}
}
fn ranges_overlap(a_start: f32, a_end: f32, b_start: f32, b_end: f32) -> bool {
a_start < b_end && b_start < a_end
}
fn sanitize_pane_id(input: &str) -> String {
sanitize_str(input, 120)
.trim()
.chars()
.filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | ':' | '.'))
.take(120)
.collect()
}
fn sanitize_rect(rect: Rect) -> Rect {
if rect.is_finite() && rect.width >= 0.0 && rect.height >= 0.0 {
rect
} else {
Rect::ZERO
}
}
fn sanitize_ratio(ratio: f32) -> f32 {
if ratio.is_finite() {
ratio.clamp(0.05, 0.95)
} else {
0.5
}
}
fn finite_or(value: f32, fallback: f32) -> f32 {
if value.is_finite() {
value
} else {
fallback
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::widget::PaneKind;
fn pane(id: &str) -> PaneModel {
PaneModel::new(id, id, PaneKind::Editor)
}
#[test]
fn pane_grid_splits_resizes_and_lays_out_tree() {
let mut grid = PaneGridModel::new(pane("editor"))
.with_rect(Rect::new(0.0, 0.0, 620.0, 300.0))
.with_gap(20.0);
let (_, split_id) = grid
.split("editor", PaneSplitAxis::Vertical, pane("preview"), true)
.unwrap();
assert!(grid.resize(split_id, 0.25));
let slots = grid.layout_slots();
assert_eq!(slots.len(), 2);
assert_eq!(slots[0].rect, Rect::new(0.0, 0.0, 150.0, 300.0));
assert_eq!(slots[1].rect, Rect::new(170.0, 0.0, 450.0, 300.0));
assert_eq!(
grid.adjacent("editor", PaneDirection::Right),
Some("preview".into())
);
}
#[test]
fn pane_grid_closes_swaps_and_preserves_models() {
let mut grid = PaneGridModel::new(pane("one")).with_rect(Rect::new(0.0, 0.0, 300.0, 100.0));
grid.split("one", PaneSplitAxis::Vertical, pane("two"), true)
.unwrap();
assert!(grid.swap("one", "two"));
assert_eq!(grid.layout_slots()[0].id, "two");
assert_eq!(grid.hit_test(Vec2::new(10.0, 10.0)).unwrap().id, "two");
assert_eq!(grid.close("two").unwrap().id, "two");
assert_eq!(grid.len(), 1);
assert_eq!(grid.layout_slots()[0].id, "one");
}
#[test]
fn pane_grid_moves_existing_panes_to_edges_and_maximizes() {
let mut grid = PaneGridModel::from_panes([pane("a"), pane("b"), pane("c")])
.with_rect(Rect::new(0.0, 0.0, 400.0, 300.0))
.with_gap(0.0);
assert!(grid.move_to_edge("c", PaneEdge::Top));
assert_eq!(grid.layout_slots()[0].id, "c");
assert!(grid.maximize("b"));
let slots = grid.layout_slots();
assert_eq!(slots.len(), 1);
assert_eq!(slots[0].id, "b");
assert!(slots[0].maximized);
assert!(grid.restore());
assert!(grid.layout_slots().len() >= 3);
}
#[test]
fn pane_grid_drop_region_splits_around_target() {
let mut grid = PaneGridModel::from_panes([pane("left"), pane("right"), pane("logs")])
.with_rect(Rect::new(0.0, 0.0, 600.0, 300.0))
.with_gap(0.0);
assert!(grid.drop_pane("logs", "left", PaneDropRegion::Edge(PaneEdge::Bottom)));
assert_eq!(
grid.adjacent("left", PaneDirection::Down),
Some("logs".into())
);
assert!(grid.drop_pane("logs", "right", PaneDropRegion::Center));
assert!(grid.root.as_ref().unwrap().contains_pane("logs"));
}
#[test]
fn pane_grid_sanitizes_ids_and_public_geometry() {
let grid = PaneGridModel::new(pane("a\u{200f}\x1b[31m")).with_rect(Rect::new(
f32::NAN,
0.0,
10.0,
10.0,
));
assert_eq!(grid.panes[0].id, "a");
assert!(grid.layout_slots().is_empty());
}
}