mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 16:01:07 +00:00
Closing a focused pane handed focus to the next pane in tree order. For a pane opened beside another one -- a plugin split, a file viewer, any transient tool pane -- that is rarely where the user was: it lands on some unrelated neighbour rather than the pane that opened it. Track the pane focus came from in TileLayout and prefer it when the focused pane closes, falling back to tree order when there is no history, when it points at the pane being closed, or when it points at a pane that has since gone away. The history lives in the layout, so it can only ever name a pane in the same tab. A one-slot history is only sound if internal focus excursions never write it, so the tree edits that used to bounce focus around now go through target-taking primitives instead. close_pane removes a background pane directly, so detach_pane and take_pane_for_move stop focus-close-refocusing. split_pane splits a target without moving focus: the runtime split path only focuses the new pane once the spawn succeeds, which makes a failed split a pure rollback, and the targeted and unfocused workspace split paths stop fabricating history. insert_pane_near now takes the focus intent, so an unfocused pane move leaves the target tab's history alone. The layout-level focused-split helpers become test-only; production splits all flow through the target-taking path. Co-authored-by: Can Celik <ogulcancelik@gmail.com>
1168 lines
37 KiB
Rust
1168 lines
37 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,
|
|
/// Pane focused before `focus`, used by `close_focused`. Only a real focus
|
|
/// move writes it; tree edits go through the target-taking primitives
|
|
/// (`split_pane`, `close_pane`, unfocused `insert_pane_near`) so internal
|
|
/// focus excursions never corrupt it.
|
|
prev_focus: Option<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,
|
|
prev_focus: None,
|
|
},
|
|
root_id,
|
|
)
|
|
}
|
|
|
|
/// Move focus, recording the pane being left. No-op when focus is unchanged.
|
|
fn set_focus(&mut self, id: PaneId) {
|
|
if id != self.focus {
|
|
self.prev_focus = Some(self.focus);
|
|
self.focus = 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. Production splits
|
|
/// flow through `Tab` so a failed runtime spawn can roll back; this remains
|
|
/// as the user-split shape for tests.
|
|
#[cfg(test)]
|
|
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.
|
|
#[cfg(test)]
|
|
pub fn split_focused_with_ratio(&mut self, direction: Direction, ratio: f32) -> PaneId {
|
|
let new_id = self
|
|
.split_pane(self.focus, direction, ratio)
|
|
.expect("focused pane is in the layout");
|
|
self.set_focus(new_id);
|
|
new_id
|
|
}
|
|
|
|
/// Split `target` without moving focus. Returns the new pane's id, or None
|
|
/// when `target` is not in the layout.
|
|
pub fn split_pane(
|
|
&mut self,
|
|
target: PaneId,
|
|
direction: Direction,
|
|
ratio: f32,
|
|
) -> Option<PaneId> {
|
|
if !self.pane_ids().contains(&target) {
|
|
return None;
|
|
}
|
|
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, target, direction, new_id, valid_split_ratio(ratio));
|
|
Some(new_id)
|
|
}
|
|
|
|
/// Insert an existing pane id next to a target pane without allocating a new
|
|
/// pane or spawning a terminal runtime. When `focus` is false, focus and its
|
|
/// history are left untouched.
|
|
pub fn insert_pane_near(
|
|
&mut self,
|
|
target: PaneId,
|
|
moved: PaneId,
|
|
direction: Direction,
|
|
ratio: f32,
|
|
focus: bool,
|
|
) -> 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));
|
|
if focus {
|
|
self.set_focus(moved);
|
|
}
|
|
true
|
|
}
|
|
|
|
/// Close the focused pane, returning focus to the pane it came from when
|
|
/// that pane is still open. 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 ordered = if pos + 1 < ids.len() {
|
|
ids[pos + 1]
|
|
} else {
|
|
ids[pos - 1]
|
|
};
|
|
let new_focus = match self.prev_focus {
|
|
Some(prev) if prev != target && ids.contains(&prev) => prev,
|
|
_ => ordered,
|
|
};
|
|
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;
|
|
self.prev_focus = None;
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
/// Close any pane. Focus and its history are left alone unless the closed
|
|
/// pane is the focused one.
|
|
pub fn close_pane(&mut self, id: PaneId) -> bool {
|
|
if self.focus == id {
|
|
return self.close_focused();
|
|
}
|
|
if self.pane_count() <= 1 || !self.pane_ids().contains(&id) {
|
|
return false;
|
|
}
|
|
let placeholder = PaneId::from_raw(0);
|
|
let old = std::mem::replace(&mut self.root, Node::Pane(placeholder));
|
|
let Some(new_root) = remove_pane(old, id) else {
|
|
return false;
|
|
};
|
|
self.root = new_root;
|
|
if self.prev_focus == Some(id) {
|
|
self.prev_focus = None;
|
|
}
|
|
true
|
|
}
|
|
|
|
pub fn focus_pane(&mut self, id: PaneId) {
|
|
if self.pane_ids().contains(&id) {
|
|
self.set_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,
|
|
prev_focus: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- 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, true));
|
|
|
|
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))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn close_focused_returns_to_the_pane_focus_came_from() {
|
|
let mut layout = sample_layout();
|
|
layout.focus_pane(pane(4));
|
|
|
|
assert!(layout.close_focused());
|
|
|
|
assert_eq!(layout.focused(), pane(2));
|
|
}
|
|
|
|
#[test]
|
|
fn close_focused_returns_to_the_pane_that_opened_a_split() {
|
|
// Allocated ids only: sample_layout() uses from_raw and shares the id
|
|
// space with the allocator.
|
|
let (mut layout, first) = TileLayout::new();
|
|
let second = layout.split_focused(Direction::Horizontal);
|
|
let third = layout.split_focused(Direction::Vertical);
|
|
assert_eq!(layout.pane_ids().len(), 3);
|
|
|
|
layout.focus_pane(first);
|
|
let opened = layout.split_focused(Direction::Horizontal);
|
|
assert_eq!(layout.focused(), opened);
|
|
|
|
assert!(layout.close_focused());
|
|
|
|
assert_eq!(layout.focused(), first);
|
|
assert!(layout.pane_ids().contains(&second));
|
|
assert!(layout.pane_ids().contains(&third));
|
|
}
|
|
|
|
#[test]
|
|
fn closing_a_background_pane_keeps_the_focused_pane_history() {
|
|
let mut layout = sample_layout();
|
|
layout.focus_pane(pane(4));
|
|
|
|
assert!(layout.close_pane(pane(1)));
|
|
assert_eq!(layout.focused(), pane(4));
|
|
|
|
assert!(layout.close_focused());
|
|
assert_eq!(layout.focused(), pane(2));
|
|
}
|
|
|
|
#[test]
|
|
fn closing_the_remembered_pane_drops_the_focus_history() {
|
|
let mut layout = sample_layout();
|
|
layout.focus_pane(pane(4));
|
|
|
|
assert!(layout.close_pane(pane(2)));
|
|
|
|
assert!(layout.close_focused());
|
|
assert_eq!(layout.focused(), pane(3));
|
|
}
|
|
|
|
#[test]
|
|
fn close_focused_uses_tree_order_without_focus_history() {
|
|
let mut layout = sample_layout();
|
|
|
|
assert!(layout.close_focused());
|
|
|
|
assert_eq!(layout.focused(), pane(3));
|
|
}
|
|
|
|
#[test]
|
|
fn close_focused_does_not_reuse_history_after_it_is_consumed() {
|
|
let mut layout = sample_layout();
|
|
layout.focus_pane(pane(4));
|
|
|
|
assert!(layout.close_focused());
|
|
assert_eq!(layout.focused(), pane(2));
|
|
|
|
assert!(layout.close_focused());
|
|
assert_eq!(layout.focused(), pane(3));
|
|
}
|
|
|
|
#[test]
|
|
fn resize_does_not_disturb_the_close_focus_target() {
|
|
let mut layout = sample_layout();
|
|
layout.focus_pane(pane(4));
|
|
layout.resize_pane(pane(1), NavDirection::Right, 0.05, Rect::new(0, 0, 100, 40));
|
|
|
|
assert!(layout.close_focused());
|
|
|
|
assert_eq!(layout.focused(), pane(2));
|
|
}
|
|
|
|
#[test]
|
|
fn split_pane_leaves_focus_and_history_untouched() {
|
|
let mut layout = sample_layout();
|
|
layout.focus_pane(pane(4));
|
|
|
|
let new_id = layout
|
|
.split_pane(pane(1), Direction::Horizontal, 0.5)
|
|
.expect("target exists");
|
|
|
|
assert!(layout.pane_ids().contains(&new_id));
|
|
assert_eq!(layout.focused(), pane(4));
|
|
assert!(layout.close_focused());
|
|
assert_eq!(layout.focused(), pane(2));
|
|
}
|
|
|
|
#[test]
|
|
fn split_pane_missing_target_changes_nothing() {
|
|
let mut layout = sample_layout();
|
|
let ids = layout.pane_ids();
|
|
|
|
assert_eq!(
|
|
layout.split_pane(pane(99), Direction::Horizontal, 0.5),
|
|
None
|
|
);
|
|
|
|
assert_eq!(layout.pane_ids(), ids);
|
|
}
|
|
|
|
#[test]
|
|
fn insert_pane_near_unfocused_keeps_focus_and_history() {
|
|
let mut layout = sample_layout();
|
|
layout.focus_pane(pane(4));
|
|
|
|
assert!(layout.insert_pane_near(pane(1), pane(9), Direction::Horizontal, 0.5, false));
|
|
|
|
assert_eq!(layout.focused(), pane(4));
|
|
assert!(layout.close_focused());
|
|
assert_eq!(layout.focused(), pane(2));
|
|
}
|
|
|
|
#[test]
|
|
fn failed_split_rollback_preserves_focus_history() {
|
|
let mut layout = sample_layout();
|
|
layout.focus_pane(pane(4));
|
|
|
|
let new_id = layout
|
|
.split_pane(layout.focused(), Direction::Horizontal, 0.5)
|
|
.expect("target exists");
|
|
assert!(layout.close_pane(new_id));
|
|
|
|
assert_eq!(layout.focused(), pane(4));
|
|
assert!(layout.close_focused());
|
|
assert_eq!(layout.focused(), pane(2));
|
|
}
|
|
}
|