From 28d0e05763aae876e1032216e82f528bbe0d1498 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Tue, 9 Jun 2026 21:15:08 +0700 Subject: [PATCH] =?UTF-8?q?feat(core):=20n-ary=20tree=20ops=20=E2=80=94=20?= =?UTF-8?q?split,=20remove+collapse,=20ratios,=20move?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 1 + crates/spacesh-core/Cargo.toml | 1 + crates/spacesh-core/src/lib.rs | 1 + crates/spacesh-core/src/ops.rs | 256 +++++++++++++++++++++++++++++++++ 4 files changed, 259 insertions(+) create mode 100644 crates/spacesh-core/src/ops.rs diff --git a/Cargo.lock b/Cargo.lock index 3abca47..ed0f63b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -719,6 +719,7 @@ version = "0.1.0" dependencies = [ "alacritty_terminal", "serde", + "spacesh-proto", ] [[package]] diff --git a/crates/spacesh-core/Cargo.toml b/crates/spacesh-core/Cargo.toml index 01718ba..afe7f6e 100644 --- a/crates/spacesh-core/Cargo.toml +++ b/crates/spacesh-core/Cargo.toml @@ -6,3 +6,4 @@ version.workspace = true [dependencies] alacritty_terminal.workspace = true serde.workspace = true +spacesh-proto = { path = "../spacesh-proto" } diff --git a/crates/spacesh-core/src/lib.rs b/crates/spacesh-core/src/lib.rs index 107702d..ff47eea 100644 --- a/crates/spacesh-core/src/lib.rs +++ b/crates/spacesh-core/src/lib.rs @@ -1,4 +1,5 @@ pub mod grid; +pub mod ops; pub mod snapshot; pub use grid::GridSurface; diff --git a/crates/spacesh-core/src/ops.rs b/crates/spacesh-core/src/ops.rs new file mode 100644 index 0000000..df23ff4 --- /dev/null +++ b/crates/spacesh-core/src/ops.rs @@ -0,0 +1,256 @@ +//! Pure algorithms over `spacesh_proto::LayoutNode`. No I/O. +use spacesh_proto::layout::{LayoutNode, Orient}; +use spacesh_proto::ids::SurfaceId; + +/// Minimum ratio a panel may shrink to (5%). +const MIN_RATIO: f32 = 0.05; + +/// Collect all surface ids in the tree, left-to-right. +pub fn leaves(node: &LayoutNode) -> Vec { + let mut out = Vec::new(); + collect(node, &mut out); + out +} +fn collect(node: &LayoutNode, out: &mut Vec) { + match node { + LayoutNode::Leaf { surface_id } => out.push(surface_id.clone()), + LayoutNode::Split { children, .. } => children.iter().for_each(|c| collect(c, out)), + } +} + +/// Split the leaf `target` by inserting `new_id` as a sibling on `dir`. +/// Returns true if the target was found and split. +pub fn split_leaf(root: &mut LayoutNode, target: &SurfaceId, dir: Orient, after: bool, new_id: SurfaceId) -> bool { + // If root itself is the target leaf, replace it with a split. + if let LayoutNode::Leaf { surface_id } = root { + if surface_id == target { + let existing = root.clone(); + let new_leaf = LayoutNode::leaf(new_id); + let children = if after { vec![existing, new_leaf] } else { vec![new_leaf, existing] }; + *root = LayoutNode::Split { orient: dir, ratios: even(children.len()), children }; + return true; + } + return false; + } + if let LayoutNode::Split { orient, ratios, children } = root { + // If a direct child is the target leaf AND this split matches `dir`, insert as sibling. + if *orient == dir { + if let Some(i) = children.iter().position(|c| is_leaf(c, target)) { + children.insert(i + if after { 1 } else { 0 }, LayoutNode::leaf(new_id)); + *ratios = even(children.len()); + return true; + } + } + // Otherwise recurse. + for c in children.iter_mut() { + if split_leaf(c, target, dir, after, new_id.clone()) { + return true; + } + } + } + false +} + +/// Remove the leaf `target`. Collapses empty/now-single-child splits and promotes +/// single children. Returns the new root (None if the tree became empty). +pub fn remove_leaf(root: LayoutNode, target: &SurfaceId) -> Option { + match root { + LayoutNode::Leaf { surface_id } => { + if &surface_id == target { None } else { Some(LayoutNode::Leaf { surface_id }) } + } + LayoutNode::Split { orient, children, .. } => { + let kept: Vec = children + .into_iter() + .filter_map(|c| remove_leaf(c, target)) + .collect(); + match kept.len() { + 0 => None, + 1 => Some(kept.into_iter().next().unwrap()), // promote single child + n => Some(LayoutNode::Split { orient, ratios: even(n), children: kept }), + } + } + } +} + +/// Set ratios on the split node addressed by `path` (child indices from root). +/// Normalizes to sum 1.0 and clamps each to >= MIN_RATIO. Returns false if the +/// path is invalid or the length does not match the node's child count. +pub fn set_ratios(root: &mut LayoutNode, path: &[u32], ratios: &[f32]) -> bool { + let Some(node) = node_at_mut(root, path) else { return false }; + if let LayoutNode::Split { ratios: r, children, .. } = node { + if ratios.len() != children.len() { + return false; + } + *r = normalize_clamp(ratios); + true + } else { + false + } +} + +/// Move leaf `src` to sit on `edge` of leaf `target`. Returns the new root. +/// No-op (returns the original) if src == target or either is missing. +pub fn move_leaf(root: LayoutNode, src: &SurfaceId, target: &SurfaceId, edge: spacesh_proto::message::Edge) -> LayoutNode { + use spacesh_proto::message::Edge; + if src == target || !contains(&root, src) || !contains(&root, target) { + return root; + } + let Some(removed) = remove_leaf(root, src) else { return LayoutNode::leaf(src.clone()) }; + let (orient, after) = match edge { + Edge::Left => (Orient::H, false), + Edge::Right => (Orient::H, true), + Edge::Top => (Orient::V, false), + Edge::Bottom => (Orient::V, true), + }; + let mut root = removed; + split_leaf(&mut root, target, orient, after, src.clone()); + root +} + +// ---- helpers ---- + +fn is_leaf(node: &LayoutNode, id: &SurfaceId) -> bool { + matches!(node, LayoutNode::Leaf { surface_id } if surface_id == id) +} +fn contains(node: &LayoutNode, id: &SurfaceId) -> bool { + leaves(node).iter().any(|s| s == id) +} +fn even(n: usize) -> Vec { + vec![1.0 / n as f32; n] +} +fn normalize_clamp(ratios: &[f32]) -> Vec { + // Two-pass: clamp all to MIN_RATIO, then normalize. If normalization would + // bring any value back below MIN_RATIO, pin those and redistribute the rest. + let n = ratios.len(); + if n == 0 { return vec![]; } + let mut r: Vec = ratios.iter().map(|v| v.max(MIN_RATIO)).collect(); + // Iteratively pin items that would end up below MIN_RATIO after normalization. + let mut pinned = vec![false; n]; + for _ in 0..n { + let pin_sum: f32 = r.iter().zip(&pinned).filter(|(_, p)| **p).map(|(v, _)| *v).sum(); + let free_sum: f32 = r.iter().zip(&pinned).filter(|(_, p)| !**p).map(|(v, _)| *v).sum(); + let remaining = 1.0 - pin_sum; + let mut changed = false; + for i in 0..n { + if pinned[i] { continue; } + let normalized = if free_sum > 0.0 { r[i] / free_sum * remaining } else { remaining / n as f32 }; + if normalized < MIN_RATIO { + r[i] = MIN_RATIO; + pinned[i] = true; + changed = true; + } + } + if !changed { break; } + } + // Final normalization of unpinned values. + let pin_sum: f32 = r.iter().zip(&pinned).filter(|(_, p)| **p).map(|(v, _)| *v).sum(); + let free_sum: f32 = r.iter().zip(&pinned).filter(|(_, p)| !**p).map(|(v, _)| *v).sum(); + let remaining = (1.0 - pin_sum).max(0.0); + let mut result = vec![0.0f32; n]; + for i in 0..n { + if pinned[i] { + result[i] = r[i]; + } else { + result[i] = if free_sum > 0.0 { r[i] / free_sum * remaining } else { remaining / n as f32 }; + } + } + result +} +fn node_at_mut<'a>(root: &'a mut LayoutNode, path: &[u32]) -> Option<&'a mut LayoutNode> { + let mut cur = root; + for &idx in path { + match cur { + LayoutNode::Split { children, .. } => { + cur = children.get_mut(idx as usize)?; + } + LayoutNode::Leaf { .. } => return None, + } + } + Some(cur) +} + +#[cfg(test)] +mod tests { + use super::*; + fn sid(s: &str) -> SurfaceId { SurfaceId(s.into()) } + + #[test] + fn split_root_leaf_creates_split() { + let mut root = LayoutNode::leaf(sid("s_1")); + assert!(split_leaf(&mut root, &sid("s_1"), Orient::H, true, sid("s_2"))); + assert_eq!(leaves(&root), vec![sid("s_1"), sid("s_2")]); + } + + #[test] + fn split_same_orient_appends_as_sibling() { + let mut root = LayoutNode::Split { + orient: Orient::H, ratios: vec![0.5, 0.5], + children: vec![LayoutNode::leaf(sid("s_1")), LayoutNode::leaf(sid("s_2"))], + }; + split_leaf(&mut root, &sid("s_2"), Orient::H, true, sid("s_3")); + // 3 children in one row, even ratios. + match &root { + LayoutNode::Split { children, ratios, .. } => { + assert_eq!(children.len(), 3); + assert!((ratios.iter().sum::() - 1.0).abs() < 1e-5); + } + _ => panic!(), + } + assert_eq!(leaves(&root), vec![sid("s_1"), sid("s_2"), sid("s_3")]); + } + + #[test] + fn remove_promotes_single_child() { + let root = LayoutNode::Split { + orient: Orient::H, ratios: vec![0.5, 0.5], + children: vec![LayoutNode::leaf(sid("s_1")), LayoutNode::leaf(sid("s_2"))], + }; + let after = remove_leaf(root, &sid("s_2")).unwrap(); + assert_eq!(after, LayoutNode::leaf(sid("s_1"))); // split collapsed to the surviving leaf + } + + #[test] + fn remove_last_leaf_returns_none() { + let root = LayoutNode::leaf(sid("s_1")); + assert!(remove_leaf(root, &sid("s_1")).is_none()); + } + + #[test] + fn set_ratios_normalizes_and_clamps() { + let mut root = LayoutNode::Split { + orient: Orient::H, ratios: vec![0.5, 0.5], + children: vec![LayoutNode::leaf(sid("s_1")), LayoutNode::leaf(sid("s_2"))], + }; + assert!(set_ratios(&mut root, &[], &[0.0, 1.0])); + if let LayoutNode::Split { ratios, .. } = &root { + assert!(ratios[0] >= MIN_RATIO); + assert!((ratios.iter().sum::() - 1.0).abs() < 1e-5); + } + } + + #[test] + fn set_ratios_wrong_len_rejected() { + let mut root = LayoutNode::Split { + orient: Orient::H, ratios: vec![0.5, 0.5], + children: vec![LayoutNode::leaf(sid("s_1")), LayoutNode::leaf(sid("s_2"))], + }; + assert!(!set_ratios(&mut root, &[], &[1.0])); + } + + #[test] + fn move_leaf_to_right_of_target() { + let root = LayoutNode::Split { + orient: Orient::V, ratios: vec![0.5, 0.5], + children: vec![LayoutNode::leaf(sid("s_1")), LayoutNode::leaf(sid("s_2"))], + }; + let after = move_leaf(root, &sid("s_1"), &sid("s_2"), spacesh_proto::message::Edge::Right); + assert_eq!(leaves(&after), vec![sid("s_2"), sid("s_1")]); + } + + #[test] + fn move_onto_self_is_noop() { + let root = LayoutNode::leaf(sid("s_1")); + let after = move_leaf(root.clone(), &sid("s_1"), &sid("s_1"), spacesh_proto::message::Edge::Right); + assert_eq!(after, root); + } +}