mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-14 18:03:41 +08:00
958 lines
31 KiB
Rust
958 lines
31 KiB
Rust
//! BSP tree layout for tiling panes within a workspace.
|
|
|
|
use std::cmp::Reverse;
|
|
|
|
use ratatui::{
|
|
layout::{Direction, Rect},
|
|
widgets::Borders,
|
|
};
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
|
pub struct PaneId(u32);
|
|
|
|
/// Global atomic counter for unique PaneId generation across all workspaces.
|
|
static NEXT_PANE_ID: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(1);
|
|
|
|
impl PaneId {
|
|
/// Allocate a globally unique PaneId.
|
|
pub fn alloc() -> Self {
|
|
Self(NEXT_PANE_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed))
|
|
}
|
|
|
|
pub fn raw(self) -> u32 {
|
|
self.0
|
|
}
|
|
|
|
/// Reconstruct from a saved u32 (persistence only).
|
|
pub fn from_raw(id: u32) -> Self {
|
|
Self(id)
|
|
}
|
|
}
|
|
|
|
/// Snapshot of a pane's position and focus state after layout.
|
|
#[derive(Clone)]
|
|
pub struct PaneInfo {
|
|
pub id: PaneId,
|
|
/// Outer rect (including borders if present).
|
|
pub rect: Rect,
|
|
/// Inner rect (content area, excluding borders). Used for selection.
|
|
pub inner_rect: Rect,
|
|
/// Visible scrollbar lane, when scrollback is present. `inner_rect` may still
|
|
/// exclude a stable hidden gutter when this is `None`.
|
|
pub scrollbar_rect: Option<Rect>,
|
|
/// Borders drawn around this pane after UI chrome is applied.
|
|
pub borders: Borders,
|
|
pub is_focused: bool,
|
|
}
|
|
|
|
/// Info about a split boundary, used for mouse drag resize.
|
|
#[derive(Clone)]
|
|
pub struct SplitBorder {
|
|
/// Position of the divider line (x for horizontal split, y for vertical).
|
|
pub pos: u16,
|
|
/// Direction of the split that created this border.
|
|
pub direction: Direction,
|
|
/// Ratio assigned to the first child of this split.
|
|
pub ratio: f32,
|
|
/// Total area of the split node.
|
|
pub area: Rect,
|
|
/// Path from root to this split node (false=first, true=second).
|
|
pub path: Vec<bool>,
|
|
}
|
|
|
|
/// Cardinal direction for pane navigation.
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub enum NavDirection {
|
|
Left,
|
|
Right,
|
|
Up,
|
|
Down,
|
|
}
|
|
|
|
/// A node in the BSP tree. Public for serialization.
|
|
pub enum Node {
|
|
Pane(PaneId),
|
|
Split {
|
|
direction: Direction,
|
|
ratio: f32,
|
|
first: Box<Node>,
|
|
second: Box<Node>,
|
|
},
|
|
}
|
|
|
|
/// BSP tiling layout. Tracks a tree of splits and a focused pane.
|
|
pub struct TileLayout {
|
|
root: Node,
|
|
focus: PaneId,
|
|
}
|
|
|
|
impl TileLayout {
|
|
/// Create a new layout with a single pane (globally unique ID).
|
|
/// Returns (layout, root_pane_id) so the caller can create the pane.
|
|
pub fn new() -> (Self, PaneId) {
|
|
let root_id = PaneId::alloc();
|
|
(
|
|
Self {
|
|
root: Node::Pane(root_id),
|
|
focus: root_id,
|
|
},
|
|
root_id,
|
|
)
|
|
}
|
|
|
|
pub fn focused(&self) -> PaneId {
|
|
self.focus
|
|
}
|
|
|
|
pub fn pane_count(&self) -> usize {
|
|
count_panes(&self.root)
|
|
}
|
|
|
|
/// Compute rects for all panes given the available area.
|
|
pub fn panes(&self, area: Rect) -> Vec<PaneInfo> {
|
|
let mut result = Vec::new();
|
|
collect_panes(&self.root, area, self.focus, &mut result);
|
|
result
|
|
}
|
|
|
|
/// Collect all split boundaries for mouse drag resize.
|
|
pub fn splits(&self, area: Rect) -> Vec<SplitBorder> {
|
|
let mut result = Vec::new();
|
|
collect_splits(&self.root, area, vec![], &mut result);
|
|
result
|
|
}
|
|
|
|
/// Split the focused pane. Returns the new pane's id.
|
|
pub fn split_focused(&mut self, direction: Direction) -> PaneId {
|
|
self.split_focused_with_ratio(direction, 0.5)
|
|
}
|
|
|
|
/// Split the focused pane with a custom first-child ratio.
|
|
pub fn split_focused_with_ratio(&mut self, direction: Direction, ratio: f32) -> PaneId {
|
|
let new_id = PaneId::alloc();
|
|
let placeholder = PaneId::from_raw(0);
|
|
let old = std::mem::replace(&mut self.root, Node::Pane(placeholder));
|
|
self.root = split_at(old, self.focus, direction, new_id, valid_split_ratio(ratio));
|
|
self.focus = new_id;
|
|
new_id
|
|
}
|
|
|
|
/// Insert an existing pane id next to a target pane without allocating a new
|
|
/// pane or spawning a terminal runtime.
|
|
pub fn insert_pane_near(
|
|
&mut self,
|
|
target: PaneId,
|
|
moved: PaneId,
|
|
direction: Direction,
|
|
ratio: f32,
|
|
) -> bool {
|
|
if target == moved {
|
|
return false;
|
|
}
|
|
let ids = self.pane_ids();
|
|
if !ids.contains(&target) || ids.contains(&moved) {
|
|
return false;
|
|
}
|
|
|
|
let placeholder = PaneId::from_raw(0);
|
|
let old = std::mem::replace(&mut self.root, Node::Pane(placeholder));
|
|
self.root = split_at(old, target, direction, moved, valid_split_ratio(ratio));
|
|
self.focus = moved;
|
|
true
|
|
}
|
|
|
|
/// Close the focused pane. Returns false if it's the last pane.
|
|
pub fn close_focused(&mut self) -> bool {
|
|
if self.pane_count() <= 1 {
|
|
return false;
|
|
}
|
|
let target = self.focus;
|
|
let ids = self.pane_ids();
|
|
let pos = ids.iter().position(|id| *id == target).unwrap();
|
|
let new_focus = if pos + 1 < ids.len() {
|
|
ids[pos + 1]
|
|
} else {
|
|
ids[pos - 1]
|
|
};
|
|
let placeholder = PaneId::from_raw(0);
|
|
let old = std::mem::replace(&mut self.root, Node::Pane(placeholder));
|
|
if let Some(new_root) = remove_pane(old, target) {
|
|
self.root = new_root;
|
|
self.focus = new_focus;
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
pub fn focus_pane(&mut self, id: PaneId) {
|
|
if self.pane_ids().contains(&id) {
|
|
self.focus = id;
|
|
}
|
|
}
|
|
|
|
/// Swap two pane ids in the layout tree while preserving split shape and
|
|
/// ratios. Returns true only when both panes exist and are different.
|
|
pub fn swap_panes(&mut self, first: PaneId, second: PaneId) -> bool {
|
|
if first == second {
|
|
return false;
|
|
}
|
|
let ids = self.pane_ids();
|
|
if !ids.contains(&first) || !ids.contains(&second) {
|
|
return false;
|
|
}
|
|
swap_pane_ids(&mut self.root, first, second);
|
|
true
|
|
}
|
|
|
|
/// Set the ratio of a split node at the given path.
|
|
pub fn set_ratio_at(&mut self, path: &[bool], ratio: f32) -> bool {
|
|
set_ratio_at(&mut self.root, path, ratio.clamp(0.1, 0.9))
|
|
}
|
|
|
|
/// Adjust the nearest split in the given direction for the focused pane.
|
|
/// `delta` is positive to grow, negative to shrink.
|
|
pub fn resize_focused(&mut self, nav: NavDirection, delta: f32, area: Rect) {
|
|
let panes = self.panes(area);
|
|
let Some(focused) = panes.iter().find(|p| p.is_focused) else {
|
|
return;
|
|
};
|
|
let focused_rect = focused.rect;
|
|
let splits = self.splits(area);
|
|
|
|
let target_dir = match nav {
|
|
NavDirection::Left | NavDirection::Right => Direction::Horizontal,
|
|
NavDirection::Up | NavDirection::Down => Direction::Vertical,
|
|
};
|
|
let grows = matches!(nav, NavDirection::Right | NavDirection::Down);
|
|
|
|
let best = nearest_resize_split(&splits, target_dir, focused_rect, nav).or_else(|| {
|
|
nearest_resize_split(&splits, target_dir, focused_rect, opposite_direction(nav))
|
|
});
|
|
|
|
if let Some(split) = best {
|
|
let path = split.path.clone();
|
|
let current_ratio = get_ratio_at(&self.root, &path).unwrap_or(0.5);
|
|
let adj = if grows { delta } else { -delta };
|
|
self.set_ratio_at(&path, current_ratio + adj);
|
|
}
|
|
}
|
|
|
|
pub fn resize_pane(
|
|
&mut self,
|
|
pane_id: PaneId,
|
|
nav: NavDirection,
|
|
delta: f32,
|
|
area: Rect,
|
|
) -> bool {
|
|
if !self.pane_ids().contains(&pane_id) {
|
|
return false;
|
|
}
|
|
let before = split_ratios(&self.root);
|
|
let previous_focus = self.focus;
|
|
self.focus = pane_id;
|
|
self.resize_focused(nav, delta, area);
|
|
self.focus = previous_focus;
|
|
split_ratios(&self.root) != before
|
|
}
|
|
|
|
pub fn pane_ids(&self) -> Vec<PaneId> {
|
|
let mut ids = Vec::new();
|
|
collect_ids(&self.root, &mut ids);
|
|
ids
|
|
}
|
|
|
|
/// Access the tree root for serialization.
|
|
pub fn root(&self) -> &Node {
|
|
&self.root
|
|
}
|
|
|
|
/// Reconstruct a layout from a saved tree.
|
|
/// Reconstruct a layout from a saved tree.
|
|
pub fn from_saved(root: Node, focus: PaneId) -> Self {
|
|
Self { root, focus }
|
|
}
|
|
}
|
|
|
|
// --- Directional pane navigation ---
|
|
|
|
/// Find the nearest pane in the given direction from `focused`.
|
|
pub fn find_in_direction(
|
|
focused: &PaneInfo,
|
|
direction: NavDirection,
|
|
panes: &[PaneInfo],
|
|
) -> Option<PaneId> {
|
|
let fr = focused.rect;
|
|
|
|
panes
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, p)| p.id != focused.id)
|
|
.filter(|(_, p)| {
|
|
let r = p.rect;
|
|
match direction {
|
|
NavDirection::Left => {
|
|
r.x + r.width <= fr.x && ranges_overlap(r.y, r.height, fr.y, fr.height)
|
|
}
|
|
NavDirection::Right => {
|
|
r.x >= fr.x + fr.width && ranges_overlap(r.y, r.height, fr.y, fr.height)
|
|
}
|
|
NavDirection::Up => {
|
|
r.y + r.height <= fr.y && ranges_overlap(r.x, r.width, fr.x, fr.width)
|
|
}
|
|
NavDirection::Down => {
|
|
r.y >= fr.y + fr.height && ranges_overlap(r.x, r.width, fr.x, fr.width)
|
|
}
|
|
}
|
|
})
|
|
.min_by_key(|(index, p)| {
|
|
let r = p.rect;
|
|
let edge_distance = match direction {
|
|
NavDirection::Left => fr.x.saturating_sub(r.x + r.width),
|
|
NavDirection::Right => r.x.saturating_sub(fr.x + fr.width),
|
|
NavDirection::Up => fr.y.saturating_sub(r.y + r.height),
|
|
NavDirection::Down => r.y.saturating_sub(fr.y + fr.height),
|
|
};
|
|
let overlap = match direction {
|
|
NavDirection::Left | NavDirection::Right => {
|
|
range_overlap_amount(r.y, r.height, fr.y, fr.height)
|
|
}
|
|
NavDirection::Up | NavDirection::Down => {
|
|
range_overlap_amount(r.x, r.width, fr.x, fr.width)
|
|
}
|
|
};
|
|
let center_distance = match direction {
|
|
NavDirection::Left | NavDirection::Right => {
|
|
range_center_distance(r.y, r.height, fr.y, fr.height)
|
|
}
|
|
NavDirection::Up | NavDirection::Down => {
|
|
range_center_distance(r.x, r.width, fr.x, fr.width)
|
|
}
|
|
};
|
|
(edge_distance, Reverse(overlap), center_distance, *index)
|
|
})
|
|
.map(|(_, p)| p.id)
|
|
}
|
|
|
|
fn ranges_overlap(a_start: u16, a_len: u16, b_start: u16, b_len: u16) -> bool {
|
|
a_start < b_start + b_len && a_start + a_len > b_start
|
|
}
|
|
|
|
fn split_on_requested_edge(split: &SplitBorder, focused: Rect, nav: NavDirection) -> bool {
|
|
split_edge_distance(split, focused, nav) <= 1
|
|
}
|
|
|
|
fn split_area_overlaps_focused_pane(split: &SplitBorder, focused: Rect, nav: NavDirection) -> bool {
|
|
match nav {
|
|
NavDirection::Left | NavDirection::Right => {
|
|
ranges_overlap(split.area.y, split.area.height, focused.y, focused.height)
|
|
}
|
|
NavDirection::Up | NavDirection::Down => {
|
|
ranges_overlap(split.area.x, split.area.width, focused.x, focused.width)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn nearest_resize_split(
|
|
splits: &[SplitBorder],
|
|
target_dir: Direction,
|
|
focused: Rect,
|
|
nav: NavDirection,
|
|
) -> Option<&SplitBorder> {
|
|
splits
|
|
.iter()
|
|
.filter(|s| s.direction == target_dir)
|
|
.filter(|s| split_area_overlaps_focused_pane(s, focused, nav))
|
|
.filter(|s| split_on_requested_edge(s, focused, nav))
|
|
.min_by_key(|s| split_edge_distance(s, focused, nav))
|
|
}
|
|
|
|
fn opposite_direction(nav: NavDirection) -> NavDirection {
|
|
match nav {
|
|
NavDirection::Left => NavDirection::Right,
|
|
NavDirection::Right => NavDirection::Left,
|
|
NavDirection::Up => NavDirection::Down,
|
|
NavDirection::Down => NavDirection::Up,
|
|
}
|
|
}
|
|
|
|
fn split_edge_distance(split: &SplitBorder, focused: Rect, nav: NavDirection) -> u32 {
|
|
match nav {
|
|
NavDirection::Left => (split.pos as i32 - focused.x as i32).unsigned_abs(),
|
|
NavDirection::Right => {
|
|
(split.pos as i32 - (focused.x + focused.width) as i32).unsigned_abs()
|
|
}
|
|
NavDirection::Up => (split.pos as i32 - focused.y as i32).unsigned_abs(),
|
|
NavDirection::Down => {
|
|
(split.pos as i32 - (focused.y + focused.height) as i32).unsigned_abs()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn range_overlap_amount(a_start: u16, a_len: u16, b_start: u16, b_len: u16) -> u16 {
|
|
let a_end = a_start.saturating_add(a_len);
|
|
let b_end = b_start.saturating_add(b_len);
|
|
a_end.min(b_end).saturating_sub(a_start.max(b_start))
|
|
}
|
|
|
|
fn range_center_distance(a_start: u16, a_len: u16, b_start: u16, b_len: u16) -> u16 {
|
|
let a_center = a_start.saturating_mul(2).saturating_add(a_len);
|
|
let b_center = b_start.saturating_mul(2).saturating_add(b_len);
|
|
a_center.abs_diff(b_center)
|
|
}
|
|
|
|
// --- Tree operations ---
|
|
|
|
fn count_panes(node: &Node) -> usize {
|
|
match node {
|
|
Node::Pane(_) => 1,
|
|
Node::Split { first, second, .. } => count_panes(first) + count_panes(second),
|
|
}
|
|
}
|
|
|
|
fn collect_panes(node: &Node, area: Rect, focus: PaneId, result: &mut Vec<PaneInfo>) {
|
|
match node {
|
|
Node::Pane(id) => {
|
|
result.push(PaneInfo {
|
|
id: *id,
|
|
rect: area,
|
|
// inner_rect is set during render when we know if borders are shown
|
|
inner_rect: area,
|
|
scrollbar_rect: None,
|
|
borders: Borders::NONE,
|
|
is_focused: *id == focus,
|
|
});
|
|
}
|
|
Node::Split {
|
|
direction,
|
|
ratio,
|
|
first,
|
|
second,
|
|
} => {
|
|
let (a, b) = split_rect(area, *direction, *ratio);
|
|
collect_panes(first, a, focus, result);
|
|
collect_panes(second, b, focus, result);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn collect_splits(node: &Node, area: Rect, path: Vec<bool>, result: &mut Vec<SplitBorder>) {
|
|
if let Node::Split {
|
|
direction,
|
|
ratio,
|
|
first,
|
|
second,
|
|
} = node
|
|
{
|
|
let (a, b) = split_rect(area, *direction, *ratio);
|
|
let pos = match direction {
|
|
Direction::Horizontal => a.x + a.width,
|
|
Direction::Vertical => a.y + a.height,
|
|
};
|
|
result.push(SplitBorder {
|
|
pos,
|
|
direction: *direction,
|
|
ratio: *ratio,
|
|
area,
|
|
path: path.clone(),
|
|
});
|
|
let mut lp = path.clone();
|
|
lp.push(false);
|
|
collect_splits(first, a, lp, result);
|
|
let mut rp = path;
|
|
rp.push(true);
|
|
collect_splits(second, b, rp, result);
|
|
}
|
|
}
|
|
|
|
fn collect_ids(node: &Node, ids: &mut Vec<PaneId>) {
|
|
match node {
|
|
Node::Pane(id) => ids.push(*id),
|
|
Node::Split { first, second, .. } => {
|
|
collect_ids(first, ids);
|
|
collect_ids(second, ids);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn split_ratios(node: &Node) -> Vec<(Vec<bool>, f32)> {
|
|
fn collect(node: &Node, path: &mut Vec<bool>, out: &mut Vec<(Vec<bool>, f32)>) {
|
|
match node {
|
|
Node::Pane(_) => {}
|
|
Node::Split {
|
|
ratio,
|
|
first,
|
|
second,
|
|
..
|
|
} => {
|
|
out.push((path.clone(), *ratio));
|
|
path.push(false);
|
|
collect(first, path, out);
|
|
path.pop();
|
|
path.push(true);
|
|
collect(second, path, out);
|
|
path.pop();
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut out = Vec::new();
|
|
collect(node, &mut Vec::new(), &mut out);
|
|
out
|
|
}
|
|
|
|
fn swap_pane_ids(node: &mut Node, first: PaneId, second: PaneId) {
|
|
match node {
|
|
Node::Pane(id) if *id == first => *id = second,
|
|
Node::Pane(id) if *id == second => *id = first,
|
|
Node::Pane(_) => {}
|
|
Node::Split {
|
|
first: first_child,
|
|
second: second_child,
|
|
..
|
|
} => {
|
|
swap_pane_ids(first_child, first, second);
|
|
swap_pane_ids(second_child, first, second);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn split_at(
|
|
node: Node,
|
|
target: PaneId,
|
|
direction: Direction,
|
|
new_id: PaneId,
|
|
split_ratio: f32,
|
|
) -> Node {
|
|
match node {
|
|
Node::Pane(id) if id == target => Node::Split {
|
|
direction,
|
|
ratio: split_ratio,
|
|
first: Box::new(Node::Pane(id)),
|
|
second: Box::new(Node::Pane(new_id)),
|
|
},
|
|
Node::Pane(_) => node,
|
|
Node::Split {
|
|
direction: d,
|
|
ratio,
|
|
first,
|
|
second,
|
|
} => Node::Split {
|
|
direction: d,
|
|
ratio,
|
|
first: Box::new(split_at(*first, target, direction, new_id, split_ratio)),
|
|
second: Box::new(split_at(*second, target, direction, new_id, split_ratio)),
|
|
},
|
|
}
|
|
}
|
|
|
|
fn valid_split_ratio(ratio: f32) -> f32 {
|
|
if ratio.is_finite() {
|
|
ratio.clamp(0.1, 0.9)
|
|
} else {
|
|
0.5
|
|
}
|
|
}
|
|
|
|
fn remove_pane(node: Node, target: PaneId) -> Option<Node> {
|
|
match node {
|
|
Node::Pane(id) if id == target => None,
|
|
Node::Pane(_) => Some(node),
|
|
Node::Split {
|
|
direction,
|
|
ratio,
|
|
first,
|
|
second,
|
|
} => match (remove_pane(*first, target), remove_pane(*second, target)) {
|
|
(None, Some(s)) => Some(s),
|
|
(Some(f), None) => Some(f),
|
|
(Some(f), Some(s)) => Some(Node::Split {
|
|
direction,
|
|
ratio,
|
|
first: Box::new(f),
|
|
second: Box::new(s),
|
|
}),
|
|
(None, None) => None,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn set_ratio_at(node: &mut Node, path: &[bool], new_ratio: f32) -> bool {
|
|
if let Node::Split {
|
|
ratio,
|
|
first,
|
|
second,
|
|
..
|
|
} = node
|
|
{
|
|
if path.is_empty() {
|
|
*ratio = new_ratio;
|
|
true
|
|
} else if path[0] {
|
|
set_ratio_at(second, &path[1..], new_ratio)
|
|
} else {
|
|
set_ratio_at(first, &path[1..], new_ratio)
|
|
}
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
fn get_ratio_at(node: &Node, path: &[bool]) -> Option<f32> {
|
|
if let Node::Split {
|
|
ratio,
|
|
first,
|
|
second,
|
|
..
|
|
} = node
|
|
{
|
|
if path.is_empty() {
|
|
Some(*ratio)
|
|
} else if path[0] {
|
|
get_ratio_at(second, &path[1..])
|
|
} else {
|
|
get_ratio_at(first, &path[1..])
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn split_rect(area: Rect, direction: Direction, ratio: f32) -> (Rect, Rect) {
|
|
match direction {
|
|
Direction::Horizontal => {
|
|
let first_w = ((area.width as f32) * ratio).round() as u16;
|
|
let second_w = area.width.saturating_sub(first_w);
|
|
(
|
|
Rect::new(area.x, area.y, first_w, area.height),
|
|
Rect::new(area.x + first_w, area.y, second_w, area.height),
|
|
)
|
|
}
|
|
Direction::Vertical => {
|
|
let first_h = ((area.height as f32) * ratio).round() as u16;
|
|
let second_h = area.height.saturating_sub(first_h);
|
|
(
|
|
Rect::new(area.x, area.y, area.width, first_h),
|
|
Rect::new(area.x, area.y + first_h, area.width, second_h),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn pane(id: u32) -> PaneId {
|
|
PaneId::from_raw(id)
|
|
}
|
|
|
|
fn sample_layout() -> TileLayout {
|
|
TileLayout::from_saved(
|
|
Node::Split {
|
|
direction: Direction::Horizontal,
|
|
ratio: 0.3,
|
|
first: Box::new(Node::Pane(pane(1))),
|
|
second: Box::new(Node::Split {
|
|
direction: Direction::Vertical,
|
|
ratio: 0.6,
|
|
first: Box::new(Node::Pane(pane(2))),
|
|
second: Box::new(Node::Split {
|
|
direction: Direction::Horizontal,
|
|
ratio: 0.4,
|
|
first: Box::new(Node::Pane(pane(3))),
|
|
second: Box::new(Node::Pane(pane(4))),
|
|
}),
|
|
}),
|
|
},
|
|
pane(2),
|
|
)
|
|
}
|
|
|
|
fn pane_rects(layout: &TileLayout) -> Vec<(PaneId, Rect)> {
|
|
layout
|
|
.panes(Rect::new(0, 0, 100, 40))
|
|
.into_iter()
|
|
.map(|info| (info.id, info.rect))
|
|
.collect()
|
|
}
|
|
|
|
fn pane_rect(layout: &TileLayout, pane_id: PaneId) -> Rect {
|
|
pane_rects(layout)
|
|
.into_iter()
|
|
.find_map(|(id, rect)| (id == pane_id).then_some(rect))
|
|
.expect("pane should exist")
|
|
}
|
|
|
|
fn split_snapshot(layout: &TileLayout) -> Vec<(Direction, f32)> {
|
|
fn collect(node: &Node, out: &mut Vec<(Direction, f32)>) {
|
|
match node {
|
|
Node::Pane(_) => {}
|
|
Node::Split {
|
|
direction,
|
|
ratio,
|
|
first,
|
|
second,
|
|
} => {
|
|
out.push((*direction, *ratio));
|
|
collect(first, out);
|
|
collect(second, out);
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut out = Vec::new();
|
|
collect(layout.root(), &mut out);
|
|
out
|
|
}
|
|
|
|
#[test]
|
|
fn swap_panes_exchanges_leaf_ids_without_changing_cells() {
|
|
let mut layout = sample_layout();
|
|
let before_rects = pane_rects(&layout);
|
|
let before_splits = split_snapshot(&layout);
|
|
|
|
assert!(layout.swap_panes(pane(2), pane(4)));
|
|
|
|
assert_eq!(layout.pane_count(), 4);
|
|
assert_eq!(split_snapshot(&layout), before_splits);
|
|
assert_eq!(layout.focused(), pane(2));
|
|
|
|
let after_rects = pane_rects(&layout);
|
|
assert_eq!(after_rects[0], before_rects[0]);
|
|
assert_eq!(after_rects[1], (pane(4), before_rects[1].1));
|
|
assert_eq!(after_rects[2], before_rects[2]);
|
|
assert_eq!(after_rects[3], (pane(2), before_rects[3].1));
|
|
}
|
|
|
|
#[test]
|
|
fn swap_panes_is_noop_for_same_or_missing_pane() {
|
|
let mut layout = sample_layout();
|
|
let before_rects = pane_rects(&layout);
|
|
let before_splits = split_snapshot(&layout);
|
|
let before_focus = layout.focused();
|
|
|
|
assert!(!layout.swap_panes(pane(2), pane(2)));
|
|
assert!(!layout.swap_panes(pane(2), pane(99)));
|
|
assert!(!layout.swap_panes(pane(99), pane(2)));
|
|
|
|
assert_eq!(pane_rects(&layout), before_rects);
|
|
assert_eq!(split_snapshot(&layout), before_splits);
|
|
assert_eq!(layout.focused(), before_focus);
|
|
}
|
|
|
|
#[test]
|
|
fn insert_existing_pane_near_target_preserves_existing_ids_and_focuses_moved_pane() {
|
|
let (mut layout, root) = TileLayout::new();
|
|
let moved = pane(99);
|
|
|
|
assert!(layout.insert_pane_near(root, moved, Direction::Horizontal, 0.25));
|
|
|
|
assert_eq!(layout.pane_count(), 2);
|
|
assert_eq!(layout.pane_ids(), vec![root, moved]);
|
|
assert_eq!(layout.focused(), moved);
|
|
let splits = split_snapshot(&layout);
|
|
assert_eq!(splits, vec![(Direction::Horizontal, 0.25)]);
|
|
assert_eq!(pane_rect(&layout, root), Rect::new(0, 0, 25, 40));
|
|
assert_eq!(pane_rect(&layout, moved), Rect::new(25, 0, 75, 40));
|
|
}
|
|
|
|
#[test]
|
|
fn split_focused_with_ratio_sets_new_split_ratio() {
|
|
let (mut layout, root) = TileLayout::new();
|
|
layout.focus_pane(root);
|
|
|
|
layout.split_focused_with_ratio(Direction::Horizontal, 0.333);
|
|
|
|
let splits = split_snapshot(&layout);
|
|
assert_eq!(splits.len(), 1);
|
|
assert_eq!(splits[0].0, Direction::Horizontal);
|
|
assert!((splits[0].1 - 0.333).abs() < f32::EPSILON);
|
|
}
|
|
|
|
#[test]
|
|
fn resize_pane_preserves_focus_and_reports_change() {
|
|
let mut layout = sample_layout();
|
|
let original_focus = layout.focused();
|
|
|
|
assert!(layout.resize_pane(pane(1), NavDirection::Right, 0.05, Rect::new(0, 0, 100, 40),));
|
|
|
|
assert_eq!(layout.focused(), original_focus);
|
|
let split = split_snapshot(&layout)[0];
|
|
assert_eq!(split.0, Direction::Horizontal);
|
|
assert!((split.1 - 0.35).abs() < f32::EPSILON);
|
|
}
|
|
|
|
#[test]
|
|
fn resize_second_child_toward_split_decreases_ratio() {
|
|
let (mut layout, root) = TileLayout::new();
|
|
let right = layout.split_focused(Direction::Horizontal);
|
|
layout.focus_pane(root);
|
|
|
|
assert!(layout.resize_pane(right, NavDirection::Left, 0.05, Rect::new(0, 0, 100, 40),));
|
|
|
|
let split = split_snapshot(&layout)[0];
|
|
assert_eq!(split.0, Direction::Horizontal);
|
|
assert!((split.1 - 0.45).abs() < f32::EPSILON);
|
|
assert_eq!(layout.focused(), root);
|
|
}
|
|
|
|
#[test]
|
|
fn resize_outer_edges_shrink_focused_pane() {
|
|
let (mut horizontal, left) = TileLayout::new();
|
|
horizontal.split_focused(Direction::Horizontal);
|
|
|
|
assert!(horizontal.resize_pane(left, NavDirection::Left, 0.05, Rect::new(0, 0, 100, 40),));
|
|
let split = split_snapshot(&horizontal)[0];
|
|
assert_eq!(split.0, Direction::Horizontal);
|
|
assert!((split.1 - 0.45).abs() < f32::EPSILON);
|
|
|
|
let (mut horizontal, _left) = TileLayout::new();
|
|
let right = horizontal.split_focused(Direction::Horizontal);
|
|
|
|
assert!(horizontal.resize_pane(right, NavDirection::Right, 0.05, Rect::new(0, 0, 100, 40),));
|
|
let split = split_snapshot(&horizontal)[0];
|
|
assert_eq!(split.0, Direction::Horizontal);
|
|
assert!((split.1 - 0.55).abs() < f32::EPSILON);
|
|
|
|
let (mut vertical, top) = TileLayout::new();
|
|
vertical.split_focused(Direction::Vertical);
|
|
|
|
assert!(vertical.resize_pane(top, NavDirection::Up, 0.05, Rect::new(0, 0, 100, 40),));
|
|
let split = split_snapshot(&vertical)[0];
|
|
assert_eq!(split.0, Direction::Vertical);
|
|
assert!((split.1 - 0.45).abs() < f32::EPSILON);
|
|
|
|
let (mut vertical, _top) = TileLayout::new();
|
|
let bottom = vertical.split_focused(Direction::Vertical);
|
|
|
|
assert!(vertical.resize_pane(bottom, NavDirection::Down, 0.05, Rect::new(0, 0, 100, 40),));
|
|
let split = split_snapshot(&vertical)[0];
|
|
assert_eq!(split.0, Direction::Vertical);
|
|
assert!((split.1 - 0.55).abs() < f32::EPSILON);
|
|
}
|
|
|
|
#[test]
|
|
fn resize_outer_edge_falls_back_to_horizontal_ancestor_split() {
|
|
let mut layout = TileLayout::from_saved(
|
|
Node::Split {
|
|
direction: Direction::Horizontal,
|
|
ratio: 0.6,
|
|
first: Box::new(Node::Split {
|
|
direction: Direction::Vertical,
|
|
ratio: 0.5,
|
|
first: Box::new(Node::Pane(pane(1))),
|
|
second: Box::new(Node::Pane(pane(2))),
|
|
}),
|
|
second: Box::new(Node::Pane(pane(3))),
|
|
},
|
|
pane(1),
|
|
);
|
|
let before = pane_rect(&layout, pane(1));
|
|
|
|
assert!(layout.resize_pane(pane(1), NavDirection::Left, 0.05, Rect::new(0, 0, 100, 40),));
|
|
|
|
let after = pane_rect(&layout, pane(1));
|
|
assert_eq!(after.height, before.height);
|
|
assert!(after.width < before.width);
|
|
let splits = split_snapshot(&layout);
|
|
assert_eq!(splits[0].0, Direction::Horizontal);
|
|
assert!((splits[0].1 - 0.55).abs() < f32::EPSILON);
|
|
assert_eq!(splits[1], (Direction::Vertical, 0.5));
|
|
}
|
|
|
|
#[test]
|
|
fn resize_outer_edge_falls_back_to_vertical_ancestor_split() {
|
|
let mut layout = TileLayout::from_saved(
|
|
Node::Split {
|
|
direction: Direction::Vertical,
|
|
ratio: 0.6,
|
|
first: Box::new(Node::Split {
|
|
direction: Direction::Horizontal,
|
|
ratio: 0.5,
|
|
first: Box::new(Node::Pane(pane(1))),
|
|
second: Box::new(Node::Pane(pane(2))),
|
|
}),
|
|
second: Box::new(Node::Pane(pane(3))),
|
|
},
|
|
pane(1),
|
|
);
|
|
let before = pane_rect(&layout, pane(1));
|
|
|
|
assert!(layout.resize_pane(pane(1), NavDirection::Up, 0.05, Rect::new(0, 0, 100, 40),));
|
|
|
|
let after = pane_rect(&layout, pane(1));
|
|
assert_eq!(after.width, before.width);
|
|
assert!(after.height < before.height);
|
|
let splits = split_snapshot(&layout);
|
|
assert_eq!(splits[0].0, Direction::Vertical);
|
|
assert!((splits[0].1 - 0.55).abs() < f32::EPSILON);
|
|
assert_eq!(splits[1], (Direction::Horizontal, 0.5));
|
|
}
|
|
|
|
#[test]
|
|
fn resize_uses_split_in_same_branch_when_borders_share_coordinate() {
|
|
let mut layout = TileLayout::from_saved(
|
|
Node::Split {
|
|
direction: Direction::Vertical,
|
|
ratio: 0.5,
|
|
first: Box::new(Node::Split {
|
|
direction: Direction::Horizontal,
|
|
ratio: 0.5,
|
|
first: Box::new(Node::Pane(pane(1))),
|
|
second: Box::new(Node::Pane(pane(2))),
|
|
}),
|
|
second: Box::new(Node::Split {
|
|
direction: Direction::Horizontal,
|
|
ratio: 0.5,
|
|
first: Box::new(Node::Pane(pane(3))),
|
|
second: Box::new(Node::Pane(pane(4))),
|
|
}),
|
|
},
|
|
pane(3),
|
|
);
|
|
|
|
assert!(layout.resize_pane(pane(3), NavDirection::Right, 0.05, Rect::new(0, 0, 100, 40),));
|
|
|
|
let splits = split_snapshot(&layout);
|
|
assert_eq!(splits[0], (Direction::Vertical, 0.5));
|
|
assert_eq!(splits[1], (Direction::Horizontal, 0.5));
|
|
assert_eq!(splits[2].0, Direction::Horizontal);
|
|
assert!((splits[2].1 - 0.55).abs() < f32::EPSILON);
|
|
}
|
|
|
|
#[test]
|
|
fn find_in_direction_tiebreaks_by_larger_overlap_before_layout_order() {
|
|
let focused = PaneInfo {
|
|
id: pane(1),
|
|
rect: Rect::new(10, 10, 10, 10),
|
|
inner_rect: Rect::new(10, 10, 10, 10),
|
|
scrollbar_rect: None,
|
|
borders: Borders::NONE,
|
|
is_focused: true,
|
|
};
|
|
let small_overlap_first = PaneInfo {
|
|
id: pane(2),
|
|
rect: Rect::new(0, 10, 10, 2),
|
|
inner_rect: Rect::new(0, 10, 10, 2),
|
|
scrollbar_rect: None,
|
|
borders: Borders::NONE,
|
|
is_focused: false,
|
|
};
|
|
let larger_overlap_second = PaneInfo {
|
|
id: pane(3),
|
|
rect: Rect::new(0, 10, 10, 8),
|
|
inner_rect: Rect::new(0, 10, 10, 8),
|
|
scrollbar_rect: None,
|
|
borders: Borders::NONE,
|
|
is_focused: false,
|
|
};
|
|
let panes = vec![focused.clone(), small_overlap_first, larger_overlap_second];
|
|
|
|
assert_eq!(
|
|
find_in_direction(&focused, NavDirection::Left, &panes),
|
|
Some(pane(3))
|
|
);
|
|
}
|
|
}
|