aboutsummaryrefslogtreecommitdiff
path: root/src/layout.rs
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-03-23 00:36:52 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-03-27 21:37:05 +0300
commita57b97285c3308fbdcc167ab3da9ae1dd78767b6 (patch)
tree25a0eb4df100ea9b09c7551fa7cc25f8d86027ef /src/layout.rs
initial release
terminal workspace manager for AI coding agents. single rust binary, runs inside your existing terminal. workspaces, tiled panes, intelligent agent state detection. https://herdr.dev
Diffstat (limited to 'src/layout.rs')
-rw-r--r--src/layout.rs470
1 files changed, 470 insertions, 0 deletions
diff --git a/src/layout.rs b/src/layout.rs
new file mode 100644
index 00000000..23bdbb7d
--- /dev/null
+++ b/src/layout.rs
@@ -0,0 +1,470 @@
+//! 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,
+ 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<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 {
+ 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_next(&mut self) {
+ let ids = self.pane_ids();
+ if let Some(pos) = ids.iter().position(|id| *id == self.focus) {
+ self.focus = ids[(pos + 1) % ids.len()];
+ }
+ }
+
+ pub fn focus_prev(&mut self) {
+ let ids = self.pane_ids();
+ if let Some(pos) = ids.iter().position(|id| *id == self.focus) {
+ self.focus = ids[(pos + ids.len() - 1) % ids.len()];
+ }
+ }
+
+ 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<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()
+ .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<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,
+ 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,
+ 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_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<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) {
+ 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<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),
+ )
+ }
+ }
+}