feat: add panes and shared-buffer splits

This commit is contained in:
2026-10-11 16:49:31 +02:00
parent 0b7d4d2c1f
commit ad9b214b8f
9 changed files with 2997 additions and 778 deletions
+374
View File
@@ -0,0 +1,374 @@
//! Frontend-independent binary pane layout and spatial focus navigation.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SplitAxis {
/// Above/below.
Horizontal,
/// Left/right.
Vertical,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PaneDirection {
Left,
Down,
Up,
Right,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct PaneRect {
pub x: u16,
pub y: u16,
pub width: u16,
pub height: u16,
}
impl PaneRect {
fn is_empty(self) -> bool {
self.width == 0 || self.height == 0
}
}
/// Partition a rectangle into balanced halves, with the odd cell in the second.
pub fn split_rect(bounds: PaneRect, axis: SplitAxis) -> [PaneRect; 2] {
let mut first = bounds;
let mut second = bounds;
match axis {
SplitAxis::Horizontal => {
first.height = bounds.height / 2;
second.y = bounds.y.saturating_add(first.height);
second.height -= first.height;
}
SplitAxis::Vertical => {
first.width = bounds.width / 2;
second.x = bounds.x.saturating_add(first.width);
second.width -= first.width;
}
}
[first, second]
}
#[derive(Clone, Debug)]
enum Node {
Leaf(usize),
Split {
axis: SplitAxis,
children: Box<[Node; 2]>,
},
}
impl Node {
fn contains(&self, id: usize) -> bool {
match self {
Self::Leaf(leaf) => *leaf == id,
Self::Split { children, .. } => children.iter().any(|child| child.contains(id)),
}
}
fn first_id(&self) -> usize {
match self {
Self::Leaf(id) => *id,
Self::Split { children, .. } => children[0].first_id(),
}
}
fn split(&mut self, id: usize, new_id: usize, axis: SplitAxis) -> bool {
match self {
Self::Leaf(leaf) if *leaf == id => {
*self = Self::Split {
axis,
children: Box::new([Self::Leaf(id), Self::Leaf(new_id)]),
};
true
}
Self::Leaf(_) => false,
Self::Split { children, .. } => children
.iter_mut()
.any(|child| child.split(id, new_id, axis)),
}
}
fn close(&mut self, id: usize) -> Option<usize> {
let Self::Split { children, .. } = self else {
return None;
};
for index in 0..2 {
if matches!(children[index], Self::Leaf(leaf) if leaf == id) {
let sibling = 1 - index;
let focus = children[sibling].first_id();
*self = std::mem::replace(&mut children[sibling], Self::Leaf(id));
return Some(focus);
}
}
children.iter_mut().find_map(|child| child.close(id))
}
fn visit_rects(&self, bounds: PaneRect, visitor: &mut impl FnMut(usize, PaneRect)) {
if bounds.is_empty() {
return;
}
match self {
Self::Leaf(id) => visitor(*id, bounds),
Self::Split { axis, children } => {
let halves = split_rect(bounds, *axis);
children[0].visit_rects(halves[0], visitor);
children[1].visit_rects(halves[1], visitor);
}
}
}
}
#[derive(Clone, Debug)]
pub struct PaneTree {
root: Node,
len: usize,
}
impl PaneTree {
pub fn new(id: usize) -> Self {
Self {
root: Node::Leaf(id),
len: 1,
}
}
/// Keep `id` first and insert `new_id` second. IDs must be unique.
pub fn split(&mut self, id: usize, new_id: usize, axis: SplitAxis) -> bool {
if self.contains(new_id) || !self.root.split(id, new_id, axis) {
return false;
}
self.len += 1;
true
}
/// Close a leaf and expand its sibling; return the sibling's first leaf.
/// Unknown IDs and closing the last leaf leave the tree unchanged.
pub fn close(&mut self, id: usize) -> Option<usize> {
let focus = self.root.close(id)?;
self.len -= 1;
Some(focus)
}
pub fn len(&self) -> usize {
self.len
}
pub fn is_empty(&self) -> bool {
false
}
pub fn contains(&self, id: usize) -> bool {
self.root.contains(id)
}
/// Visit nonempty leaves in tree order, without allocating.
pub fn visit_rects(&self, bounds: PaneRect, mut visitor: impl FnMut(usize, PaneRect)) {
self.root.visit_rects(bounds, &mut visitor);
}
pub fn neighbor(&self, id: usize, direction: PaneDirection, bounds: PaneRect) -> Option<usize> {
let mut current = None;
self.visit_rects(bounds, |leaf, rect| {
if leaf == id {
current = Some(rect);
}
});
let current = current?;
let mut best = None;
self.visit_rects(bounds, |leaf, rect| {
if leaf != id {
consider_neighbor(current, direction, leaf, rect, &mut best);
}
});
best.map(|(_, leaf)| leaf)
}
}
// Score: prefer perpendicular overlap, then the nearest forward edge,
// perpendicular gap, center alignment, and finally the stable ID.
type NeighborScore = (bool, u32, u32, u32, usize);
fn neighbor_score(
current: PaneRect,
direction: PaneDirection,
id: usize,
candidate: PaneRect,
) -> Option<NeighborScore> {
if current.is_empty() || candidate.is_empty() {
return None;
}
let edges = |rect: PaneRect| {
let x = u32::from(rect.x);
let y = u32::from(rect.y);
(x, y, x + u32::from(rect.width), y + u32::from(rect.height))
};
let (x, y, right, bottom) = edges(current);
let (cx, cy, cright, cbottom) = edges(candidate);
let (distance, start, end, cstart, cend) = match direction {
PaneDirection::Left if cright <= x => (x - cright, y, bottom, cy, cbottom),
PaneDirection::Right if cx >= right => (cx - right, y, bottom, cy, cbottom),
PaneDirection::Up if cbottom <= y => (y - cbottom, x, right, cx, cright),
PaneDirection::Down if cy >= bottom => (cy - bottom, x, right, cx, cright),
_ => return None,
};
let overlap = start.max(cstart) < end.min(cend);
let gap = start.max(cstart).saturating_sub(end.min(cend));
let alignment = (start + end).abs_diff(cstart + cend);
Some((!overlap, distance, gap, alignment, id))
}
fn consider_neighbor(
current: PaneRect,
direction: PaneDirection,
id: usize,
candidate: PaneRect,
best: &mut Option<(NeighborScore, usize)>,
) {
if let Some(score) = neighbor_score(current, direction, id, candidate)
&& best.as_ref().is_none_or(|(previous, _)| score < *previous)
{
*best = Some((score, id));
}
}
/// Find a forward rectangle, favoring overlap and the closest edge.
/// Empty rectangles are ignored; ties are independent of iterator order.
pub fn directional_neighbor(
current: PaneRect,
direction: PaneDirection,
candidates: impl Iterator<Item = (usize, PaneRect)>,
) -> Option<usize> {
let mut best = None;
for (id, candidate) in candidates {
consider_neighbor(current, direction, id, candidate, &mut best);
}
best.map(|(_, id)| id)
}
#[cfg(test)]
mod tests {
use super::*;
fn rect(x: u16, y: u16, width: u16, height: u16) -> PaneRect {
PaneRect {
x,
y,
width,
height,
}
}
fn leaves(tree: &PaneTree, bounds: PaneRect) -> Vec<(usize, PaneRect)> {
let mut leaves = Vec::new();
tree.visit_rects(bounds, |id, rect| leaves.push((id, rect)));
leaves
}
#[test]
fn nested_layout_partitions_bounds_and_preserves_ids() {
let bounds = rect(3, 5, 11, 9);
let mut tree = PaneTree::new(10);
assert!(tree.split(10, 20, SplitAxis::Vertical));
assert!(tree.split(20, 30, SplitAxis::Horizontal));
assert!(!tree.split(99, 40, SplitAxis::Horizontal));
assert!(!tree.split(10, 30, SplitAxis::Horizontal));
assert!(!tree.split(10, 10, SplitAxis::Horizontal));
assert_eq!(tree.len(), 3);
let panes = leaves(&tree, bounds);
assert_eq!(
panes,
vec![
(10, rect(3, 5, 5, 9)),
(20, rect(8, 5, 6, 4)),
(30, rect(8, 9, 6, 5))
]
);
let mut cells = std::collections::HashSet::new();
for (_, pane) in panes {
for y in pane.y..pane.y + pane.height {
for x in pane.x..pane.x + pane.width {
assert!(cells.insert((x, y)), "overlapping pane cell");
}
}
}
assert_eq!(
cells.len(),
usize::from(bounds.width) * usize::from(bounds.height)
);
assert!(leaves(&tree, rect(0, 0, 0, 10)).is_empty());
assert!(leaves(&tree, rect(0, 0, 10, 0)).is_empty());
}
#[test]
fn closing_expands_nested_sibling_and_refuses_last() {
let bounds = rect(0, 0, 20, 12);
let mut tree = PaneTree::new(1);
tree.split(1, 2, SplitAxis::Vertical);
tree.split(2, 3, SplitAxis::Horizontal);
assert_eq!(tree.close(1), Some(2));
assert_eq!(
leaves(&tree, bounds),
vec![(2, rect(0, 0, 20, 6)), (3, rect(0, 6, 20, 6))]
);
assert_eq!(tree.close(99), None);
assert_eq!(tree.close(2), Some(3));
assert_eq!(leaves(&tree, bounds), vec![(3, bounds)]);
assert_eq!(tree.close(3), None);
assert_eq!(tree.len(), 1);
assert!(!tree.contains(1));
assert!(tree.contains(3));
}
#[test]
fn directional_focus_handles_unequal_nested_regions() {
let bounds = rect(0, 0, 20, 12);
let mut tree = PaneTree::new(1);
tree.split(1, 2, SplitAxis::Vertical);
tree.split(2, 3, SplitAxis::Horizontal);
tree.split(3, 4, SplitAxis::Horizontal);
assert_eq!(tree.neighbor(1, PaneDirection::Right, bounds), Some(3));
assert_eq!(tree.neighbor(2, PaneDirection::Down, bounds), Some(3));
assert_eq!(tree.neighbor(3, PaneDirection::Down, bounds), Some(4));
assert_eq!(tree.neighbor(4, PaneDirection::Up, bounds), Some(3));
assert_eq!(tree.neighbor(4, PaneDirection::Left, bounds), Some(1));
assert_eq!(tree.neighbor(1, PaneDirection::Left, bounds), None);
assert_eq!(tree.neighbor(99, PaneDirection::Right, bounds), None);
assert_eq!(
tree.neighbor(1, PaneDirection::Right, rect(0, 0, 0, 0)),
None
);
}
#[test]
fn navigation_prefers_overlap_then_edge_and_breaks_ties_by_id() {
let current = rect(10, 10, 10, 10);
let candidates = [
(1, rect(20, 0, 5, 5)),
(2, rect(25, 10, 5, 10)),
(4, rect(22, 10, 5, 10)),
(3, rect(22, 10, 5, 10)),
(0, rect(20, 10, 0, 10)),
];
assert_eq!(
directional_neighbor(current, PaneDirection::Right, candidates.into_iter()),
Some(3)
);
assert_eq!(
directional_neighbor(current, PaneDirection::Right, candidates.into_iter().rev()),
Some(3)
);
assert_eq!(
directional_neighbor(
current,
PaneDirection::Up,
[(7, rect(10, 0, 10, 5))].into_iter()
),
Some(7)
);
assert_eq!(
directional_neighbor(current, PaneDirection::Left, candidates.into_iter()),
None
);
}
}