//! BSP tree layout for tiling panes within a workspace. use ratatui::layout::{Direction, Rect}; #[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, 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, /// Total area of the split node. pub area: Rect, /// Path from root to this split node (false=first, true=second). pub path: Vec, } /// 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, second: Box, }, } /// 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 { 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 { 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 { 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); self.focus = new_id; new_id } /// 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; } } /// Set the ratio of a split node at the given path. pub fn set_ratio_at(&mut self, path: &[bool], ratio: f32) { 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); // Find the split whose border is adjacent to the focused pane in the given direction 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); // Find the closest matching split border let best = splits .iter() .filter(|s| s.direction == target_dir) .filter(|s| match target_dir { Direction::Horizontal => { // Border must be near the focused pane's left or right edge let near_right = (s.pos as i32 - (focused_rect.x + focused_rect.width) as i32) .unsigned_abs() <= 1; let near_left = (s.pos as i32 - focused_rect.x as i32).unsigned_abs() <= 1; near_right || near_left } Direction::Vertical => { let near_bottom = (s.pos as i32 - (focused_rect.y + focused_rect.height) as i32) .unsigned_abs() <= 1; let near_top = (s.pos as i32 - focused_rect.y as i32).unsigned_abs() <= 1; near_bottom || near_top } }) .min_by_key(|s| { // Prefer the border in the direction we're resizing toward match (target_dir, grows) { (Direction::Horizontal, true) => { ((focused_rect.x + focused_rect.width) as i32 - s.pos as i32).unsigned_abs() } (Direction::Horizontal, false) => { (focused_rect.x as i32 - s.pos as i32).unsigned_abs() } (Direction::Vertical, true) => ((focused_rect.y + focused_rect.height) as i32 - s.pos as i32) .unsigned_abs(), (Direction::Vertical, false) => { (focused_rect.y as i32 - s.pos as i32).unsigned_abs() } } }); 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 pane_ids(&self) -> Vec { 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 { let fr = focused.rect; panes .iter() .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(|p| { let r = p.rect; 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), } }) .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 } // --- 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) { 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, 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, result: &mut Vec) { 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, 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) { match node { Node::Pane(id) => ids.push(*id), Node::Split { first, second, .. } => { collect_ids(first, ids); collect_ids(second, ids); } } } fn split_at(node: Node, target: PaneId, direction: Direction, new_id: PaneId) -> Node { match node { Node::Pane(id) if id == target => Node::Split { direction, ratio: 0.5, 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)), second: Box::new(split_at(*second, target, direction, new_id)), }, } } fn remove_pane(node: Node, target: PaneId) -> Option { 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) { if let Node::Split { ratio, first, second, .. } = node { if path.is_empty() { *ratio = new_ratio; } else if path[0] { set_ratio_at(second, &path[1..], new_ratio); } else { set_ratio_at(first, &path[1..], new_ratio); } } } fn get_ratio_at(node: &Node, path: &[bool]) -> Option { 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), ) } } }