253 lines
7.3 KiB
Rust
253 lines
7.3 KiB
Rust
//! Frontend-independent binary pane layout and spatial focus navigation.
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
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)]
|
|
#[path = "../tests/unit/panes.rs"]
|
|
mod tests;
|