feat(split): shared nebula-split crate - split-pane rules as pure data
Extracts the split-pane rules from the legacy shell's window_context/split.rs into a zero-dependency workspace crate: the layout tree (split/remove/collapse, path addressing), the geometry pass (floor + per-side cell clamping, divider rects with tree paths), divider drag rules (unclamped pointer ratio, preview pinning in the close zone, drag-to-close margins, whole-cell commit quantization) and directional focus (4x perpendicular-drift penalty). Math ported verbatim and locked by 12 unit tests. The legacy implementation stays frozen in-tree per the P4 ruling; the GPUI layer inside nebula_app is the intended consumer.
This commit is contained in:
1 parent
64c7c0ff1a
commit
6d3a076b80
2 files changed
+613
No files matched your search
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "nebula-split"
|
||||
version = "1.0.0"
|
||||
license = "GPL-3.0-or-later"
|
||||
description = "Shared split-pane rules: layout tree, geometry, divider drag and directional focus"
|
||||
publish = false
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
# 刻意零依赖:任何壳与任何渲染后端都能免费引用(与 nebula-settings 同则)。
|
||||
[dependencies]
|
||||
@@ -0,0 +1,602 @@
|
||||
//! Nebula 分屏规则的共享权威实现:布局树、几何切割、分隔条拖拽与方向聚焦。
|
||||
//!
|
||||
//! 从旧壳 `nebula_app/src/window_context/split.rs` 逐字对照移植成纯数据 +
|
||||
//! 纯函数——切割数学(floor/钳制次序)、拖拽比例曲线(关闭边距/预览钉边/
|
||||
//! 提交量化)与最近邻导航(垂直漂移 4 倍惩罚)以旧壳行为为权威,任何 UI
|
||||
//! 框架类型都不出现在这里。
|
||||
//!
|
||||
//! 同步事实(记录在案):旧壳 `split.rs` 是体内既有实现,按 P4 裁定冻结
|
||||
//! 保留;新 UI 一律读本 crate。改规则时两处同步,直到 P3 接入完成。
|
||||
//!
|
||||
//! 坐标系:屏幕像素、左上原点。`Rect` 是 pane 的真实分配矩形;旧壳把它
|
||||
//! 换算成整窗 `SizeInfo` + 四边 padding 属于壳内投影细节,不进合同。
|
||||
|
||||
use std::mem;
|
||||
|
||||
/// 可见分隔条厚度(逻辑像素;使用处乘以 scale 后取整,至少 1 物理像素)。
|
||||
pub const DIVIDER_GAP: f32 = 2.0;
|
||||
/// 分隔条命中扩边(逻辑像素):视觉线很细,抓取目标刻意加宽。
|
||||
pub const HIT_SLOP: f32 = 8.0;
|
||||
/// 松手时原始比例越过该边距即关闭被挤压的一侧
|
||||
/// (`< margin` 关第一个孩子,`> 1 - margin` 关第二个)。
|
||||
pub const CLOSE_MARGIN: f32 = 0.06;
|
||||
/// 提交时比例的硬钳制带:任何一侧都不小于总宽的 10%。
|
||||
pub const RATIO_CLAMP: (f32, f32) = (0.10, 0.90);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SplitDirection {
|
||||
LeftRight,
|
||||
TopBottom,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SplitNav {
|
||||
Left,
|
||||
Right,
|
||||
Up,
|
||||
Down,
|
||||
}
|
||||
|
||||
/// 屏幕矩形,左上原点。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Rect {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub w: f32,
|
||||
pub h: f32,
|
||||
}
|
||||
|
||||
impl Rect {
|
||||
pub fn new(x: f32, y: f32, w: f32, h: f32) -> Self {
|
||||
Self { x, y, w, h }
|
||||
}
|
||||
|
||||
pub fn contains(&self, x: f32, y: f32) -> bool {
|
||||
x >= self.x && x < self.x + self.w && y >= self.y && y < self.y + self.h
|
||||
}
|
||||
|
||||
pub fn center(&self) -> (f32, f32) {
|
||||
(self.x + self.w * 0.5, self.y + self.h * 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条分隔条的身份:屏幕矩形 + 所属 Split 节点的树路径
|
||||
/// (`false` = 第一个孩子,`true` = 第二个),以及该节点铺陈孩子的内容矩形。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Divider {
|
||||
pub rect: Rect,
|
||||
pub direction: SplitDirection,
|
||||
pub path: Vec<bool>,
|
||||
pub viewport: Rect,
|
||||
}
|
||||
|
||||
/// 从树上摘除叶子的结果。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RemoveOutcome<T> {
|
||||
/// 目标不在这棵树上。
|
||||
NotFound,
|
||||
/// 目标是唯一叶子;调用方应关闭整个 tab。
|
||||
WasRoot,
|
||||
/// 已摘除且父节点塌缩;焦点应移交给幸存子树的首叶。
|
||||
Collapsed(T),
|
||||
}
|
||||
|
||||
/// 分屏布局树:叶子是 pane(泛型 id),内部节点是带比例的二分。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum SplitTree<T> {
|
||||
Leaf(T),
|
||||
Split {
|
||||
direction: SplitDirection,
|
||||
/// 第一个孩子的占比(提交值)。
|
||||
ratio: f32,
|
||||
/// 拖拽中的预览比例;PTY 尺寸跟随提交值直到松手。
|
||||
preview_ratio: Option<f32>,
|
||||
dragging: bool,
|
||||
first: Box<SplitTree<T>>,
|
||||
second: Box<SplitTree<T>>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 一次几何铺陈的输出:每个 pane 的矩形 + 所有分隔条。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SplitLayout<T> {
|
||||
pub panes: Vec<(T, Rect)>,
|
||||
pub dividers: Vec<Divider>,
|
||||
}
|
||||
|
||||
impl<T: Copy + Eq> SplitTree<T> {
|
||||
pub fn leaf(id: T) -> Self {
|
||||
SplitTree::Leaf(id)
|
||||
}
|
||||
|
||||
pub fn is_leaf(&self) -> bool {
|
||||
matches!(self, SplitTree::Leaf(_))
|
||||
}
|
||||
|
||||
/// 深度优先收集全部叶子。
|
||||
pub fn leaves(&self) -> Vec<T> {
|
||||
let mut out = Vec::new();
|
||||
self.collect_leaves(&mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn collect_leaves(&self, out: &mut Vec<T>) {
|
||||
match self {
|
||||
SplitTree::Leaf(id) => out.push(*id),
|
||||
SplitTree::Split { first, second, .. } => {
|
||||
first.collect_leaves(out);
|
||||
second.collect_leaves(out);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 最左/最上的叶子。
|
||||
pub fn first_leaf(&self) -> T {
|
||||
match self {
|
||||
SplitTree::Leaf(id) => *id,
|
||||
SplitTree::Split { first, .. } => first.first_leaf(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn contains(&self, id: T) -> bool {
|
||||
match self {
|
||||
SplitTree::Leaf(leaf) => *leaf == id,
|
||||
SplitTree::Split { first, second, .. } => first.contains(id) || second.contains(id),
|
||||
}
|
||||
}
|
||||
|
||||
/// 把 `target` 叶子替换成 `[target, new]` 的 Split。找到返回 `true`。
|
||||
pub fn split_leaf(
|
||||
&mut self,
|
||||
target: T,
|
||||
new: T,
|
||||
direction: SplitDirection,
|
||||
ratio: f32,
|
||||
) -> bool {
|
||||
match self {
|
||||
SplitTree::Leaf(id) if *id == target => {
|
||||
let old = *id;
|
||||
*self = SplitTree::Split {
|
||||
direction,
|
||||
ratio,
|
||||
preview_ratio: None,
|
||||
dragging: false,
|
||||
first: Box::new(SplitTree::Leaf(old)),
|
||||
second: Box::new(SplitTree::Leaf(new)),
|
||||
};
|
||||
true
|
||||
},
|
||||
SplitTree::Leaf(_) => false,
|
||||
SplitTree::Split { first, second, .. } => {
|
||||
first.split_leaf(target, new, direction, ratio)
|
||||
|| second.split_leaf(target, new, direction, ratio)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 摘除 `target` 叶子并塌缩其父节点,兄弟子树接管腾出的空间。
|
||||
pub fn remove_leaf(&mut self, target: T) -> RemoveOutcome<T> {
|
||||
if let SplitTree::Leaf(id) = self {
|
||||
return if *id == target { RemoveOutcome::WasRoot } else { RemoveOutcome::NotFound };
|
||||
}
|
||||
|
||||
if let SplitTree::Split { first, second, .. } = self {
|
||||
// 直接孩子命中 → 塌缩为兄弟。占位叶子随 *self 覆盖一起丢弃。
|
||||
if matches!(first.as_ref(), SplitTree::Leaf(id) if *id == target) {
|
||||
let survivor = mem::replace(second.as_mut(), SplitTree::Leaf(target));
|
||||
let focus = survivor.first_leaf();
|
||||
*self = survivor;
|
||||
return RemoveOutcome::Collapsed(focus);
|
||||
}
|
||||
if matches!(second.as_ref(), SplitTree::Leaf(id) if *id == target) {
|
||||
let survivor = mem::replace(first.as_mut(), SplitTree::Leaf(target));
|
||||
let focus = survivor.first_leaf();
|
||||
*self = survivor;
|
||||
return RemoveOutcome::Collapsed(focus);
|
||||
}
|
||||
return match first.remove_leaf(target) {
|
||||
RemoveOutcome::NotFound => second.remove_leaf(target),
|
||||
other => other,
|
||||
};
|
||||
}
|
||||
|
||||
RemoveOutcome::NotFound
|
||||
}
|
||||
|
||||
/// 按根路径寻址节点(`false` = 第一个孩子,`true` = 第二个)。
|
||||
pub fn node_mut(&mut self, path: &[bool]) -> Option<&mut SplitTree<T>> {
|
||||
let mut node = self;
|
||||
for &second in path {
|
||||
match node {
|
||||
SplitTree::Split { first, second: s, .. } => {
|
||||
node = if second { s.as_mut() } else { first.as_mut() };
|
||||
},
|
||||
SplitTree::Leaf(_) => return None,
|
||||
}
|
||||
}
|
||||
Some(node)
|
||||
}
|
||||
|
||||
/// 把树铺陈进 `viewport`:每个叶子得到自己的矩形,每个 Split 产出一条
|
||||
/// 分隔条。`use_preview` 时 Split 使用拖拽预览比例(分隔条随指针走),
|
||||
/// 否则使用提交比例(pane 内容保持稳定)。
|
||||
///
|
||||
/// 切割数学与旧壳逐字一致:可用长度 = 总长 - 分隔条,第一段先 floor
|
||||
/// 再双向钳制到"至少一个单元格",第二段吃掉余数。
|
||||
pub fn layout(
|
||||
&self,
|
||||
viewport: Rect,
|
||||
cell_w: f32,
|
||||
cell_h: f32,
|
||||
divider: f32,
|
||||
use_preview: bool,
|
||||
) -> SplitLayout<T> {
|
||||
let mut out = SplitLayout { panes: Vec::new(), dividers: Vec::new() };
|
||||
let mut path = Vec::new();
|
||||
self.collect_rects(viewport, cell_w, cell_h, divider, use_preview, &mut path, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn collect_rects(
|
||||
&self,
|
||||
vp: Rect,
|
||||
cell_w: f32,
|
||||
cell_h: f32,
|
||||
divider: f32,
|
||||
use_preview: bool,
|
||||
path: &mut Vec<bool>,
|
||||
out: &mut SplitLayout<T>,
|
||||
) {
|
||||
match self {
|
||||
SplitTree::Leaf(id) => out.panes.push((*id, vp)),
|
||||
SplitTree::Split { direction, ratio, preview_ratio, first, second, .. } => {
|
||||
let r = if use_preview { preview_ratio.unwrap_or(*ratio) } else { *ratio };
|
||||
match direction {
|
||||
SplitDirection::LeftRight => {
|
||||
let usable = (vp.w - divider).max(cell_w);
|
||||
let first_w =
|
||||
(usable * r).floor().max(cell_w).min((usable - cell_w).max(cell_w));
|
||||
let second_w = (usable - first_w).max(cell_w);
|
||||
out.dividers.push(Divider {
|
||||
rect: Rect::new(vp.x + first_w, vp.y, divider, vp.h),
|
||||
direction: *direction,
|
||||
path: path.clone(),
|
||||
viewport: vp,
|
||||
});
|
||||
path.push(false);
|
||||
first.collect_rects(
|
||||
Rect::new(vp.x, vp.y, first_w, vp.h),
|
||||
cell_w,
|
||||
cell_h,
|
||||
divider,
|
||||
use_preview,
|
||||
path,
|
||||
out,
|
||||
);
|
||||
path.pop();
|
||||
path.push(true);
|
||||
second.collect_rects(
|
||||
Rect::new(vp.x + first_w + divider, vp.y, second_w, vp.h),
|
||||
cell_w,
|
||||
cell_h,
|
||||
divider,
|
||||
use_preview,
|
||||
path,
|
||||
out,
|
||||
);
|
||||
path.pop();
|
||||
},
|
||||
SplitDirection::TopBottom => {
|
||||
let usable = (vp.h - divider).max(cell_h);
|
||||
let first_h =
|
||||
(usable * r).floor().max(cell_h).min((usable - cell_h).max(cell_h));
|
||||
let second_h = (usable - first_h).max(cell_h);
|
||||
out.dividers.push(Divider {
|
||||
rect: Rect::new(vp.x, vp.y + first_h, vp.w, divider),
|
||||
direction: *direction,
|
||||
path: path.clone(),
|
||||
viewport: vp,
|
||||
});
|
||||
path.push(false);
|
||||
first.collect_rects(
|
||||
Rect::new(vp.x, vp.y, vp.w, first_h),
|
||||
cell_w,
|
||||
cell_h,
|
||||
divider,
|
||||
use_preview,
|
||||
path,
|
||||
out,
|
||||
);
|
||||
path.pop();
|
||||
path.push(true);
|
||||
second.collect_rects(
|
||||
Rect::new(vp.x, vp.y + first_h + divider, vp.w, second_h),
|
||||
cell_w,
|
||||
cell_h,
|
||||
divider,
|
||||
use_preview,
|
||||
path,
|
||||
out,
|
||||
);
|
||||
path.pop();
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `(x, y)` 命中的分隔条(含扩边)。`slop` 已按 scale 换算。
|
||||
pub fn hit_divider<'a>(dividers: &'a [Divider], x: f32, y: f32, slop: f32) -> Option<&'a Divider> {
|
||||
dividers.iter().find(|d| {
|
||||
x >= d.rect.x - slop
|
||||
&& x <= d.rect.x + d.rect.w + slop
|
||||
&& y >= d.rect.y - slop
|
||||
&& y <= d.rect.y + d.rect.h + slop
|
||||
})
|
||||
}
|
||||
|
||||
/// 指针位置 → 本次拖拽的原始比例,**不钳制**。刻意不按整格量化:预览必须
|
||||
/// 逐像素跟手才顺滑,比例在提交时才吸附网格。超出 `0..1` 表示指针越过了
|
||||
/// pane 边缘(拖拽关闭领域)。
|
||||
pub fn drag_ratio(
|
||||
direction: SplitDirection,
|
||||
viewport: Rect,
|
||||
divider: f32,
|
||||
cell: f32,
|
||||
x: f32,
|
||||
y: f32,
|
||||
) -> f32 {
|
||||
let (pos, origin, extent) = match direction {
|
||||
SplitDirection::LeftRight => (x, viewport.x, viewport.w),
|
||||
SplitDirection::TopBottom => (y, viewport.y, viewport.h),
|
||||
};
|
||||
let usable = (extent - divider).max(cell);
|
||||
(pos - origin - divider * 0.5) / usable
|
||||
}
|
||||
|
||||
/// 原始拖拽比例 → 预览比例:常规带内跟手;关闭区钉死在边缘,让 pane 可见地
|
||||
/// 塌下去,示意"松手即关"。
|
||||
pub fn preview_ratio(raw: f32) -> f32 {
|
||||
if raw < CLOSE_MARGIN {
|
||||
0.02
|
||||
} else if raw > 1.0 - CLOSE_MARGIN {
|
||||
0.98
|
||||
} else {
|
||||
raw.clamp(RATIO_CLAMP.0, RATIO_CLAMP.1)
|
||||
}
|
||||
}
|
||||
|
||||
/// 松手时是否触发拖拽关闭:`Some(false)` 关第一个孩子,`Some(true)` 关第二个。
|
||||
pub fn drag_close_target(raw: f32) -> Option<bool> {
|
||||
if raw < CLOSE_MARGIN {
|
||||
Some(false)
|
||||
} else if raw > 1.0 - CLOSE_MARGIN {
|
||||
Some(true)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交比例:吸附到整数个单元格(pane 尺寸与字符网格对齐)再硬钳制。
|
||||
pub fn commit_ratio(preview: f32, extent: f32, divider: f32, cell: f32) -> f32 {
|
||||
let cell = cell.max(1.0);
|
||||
let usable = (extent - divider).max(cell);
|
||||
(((preview * usable) / cell).round() * cell / usable).clamp(RATIO_CLAMP.0, RATIO_CLAMP.1)
|
||||
}
|
||||
|
||||
/// 从 `focused` 出发沿 `nav` 方向找最近的 pane。"最近"偏好垂直轴上对齐的
|
||||
/// pane:垂直漂移按 4 倍计入距离,尽量停留在同一行/列。
|
||||
pub fn nav_target<T: Copy + Eq>(panes: &[(T, Rect)], focused: T, nav: SplitNav) -> Option<T> {
|
||||
let (_, frect) = panes.iter().find(|(id, _)| *id == focused)?;
|
||||
let (fcx, fcy) = frect.center();
|
||||
|
||||
let mut best: Option<(T, f32)> = None;
|
||||
for (id, rect) in panes {
|
||||
if *id == focused {
|
||||
continue;
|
||||
}
|
||||
let (cx, cy) = rect.center();
|
||||
let dx = cx - fcx;
|
||||
let dy = cy - fcy;
|
||||
let in_direction = match nav {
|
||||
SplitNav::Left => dx < -1.0,
|
||||
SplitNav::Right => dx > 1.0,
|
||||
SplitNav::Up => dy < -1.0,
|
||||
SplitNav::Down => dy > 1.0,
|
||||
};
|
||||
if !in_direction {
|
||||
continue;
|
||||
}
|
||||
let dist = match nav {
|
||||
SplitNav::Left | SplitNav::Right => dx.abs() + dy.abs() * 4.0,
|
||||
SplitNav::Up | SplitNav::Down => dy.abs() + dx.abs() * 4.0,
|
||||
};
|
||||
if best.map_or(true, |(_, b)| dist < b) {
|
||||
best = Some((*id, dist));
|
||||
}
|
||||
}
|
||||
best.map(|(id, _)| id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn lr(ratio: f32, first: SplitTree<u32>, second: SplitTree<u32>) -> SplitTree<u32> {
|
||||
SplitTree::Split {
|
||||
direction: SplitDirection::LeftRight,
|
||||
ratio,
|
||||
preview_ratio: None,
|
||||
dragging: false,
|
||||
first: Box::new(first),
|
||||
second: Box::new(second),
|
||||
}
|
||||
}
|
||||
|
||||
fn tb(ratio: f32, first: SplitTree<u32>, second: SplitTree<u32>) -> SplitTree<u32> {
|
||||
SplitTree::Split {
|
||||
direction: SplitDirection::TopBottom,
|
||||
ratio,
|
||||
preview_ratio: None,
|
||||
dragging: false,
|
||||
first: Box::new(first),
|
||||
second: Box::new(second),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_and_remove_roundtrip() {
|
||||
let mut tree = SplitTree::leaf(1u32);
|
||||
assert!(tree.split_leaf(1, 2, SplitDirection::LeftRight, 0.5));
|
||||
assert!(tree.split_leaf(2, 3, SplitDirection::TopBottom, 0.5));
|
||||
assert_eq!(tree.leaves(), vec![1, 2, 3]);
|
||||
assert_eq!(tree.first_leaf(), 1);
|
||||
|
||||
// 摘掉 2:其父塌缩,焦点给幸存子树首叶 3。
|
||||
assert_eq!(tree.remove_leaf(2), RemoveOutcome::Collapsed(3));
|
||||
assert_eq!(tree.leaves(), vec![1, 3]);
|
||||
// 不存在的叶子。
|
||||
assert_eq!(tree.remove_leaf(99), RemoveOutcome::NotFound);
|
||||
// 摘到只剩一个 → WasRoot。
|
||||
assert_eq!(tree.remove_leaf(1), RemoveOutcome::Collapsed(3));
|
||||
assert_eq!(tree.remove_leaf(3), RemoveOutcome::WasRoot);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_splits_left_right_with_floor_and_divider() {
|
||||
let tree = lr(0.5, SplitTree::Leaf(1), SplitTree::Leaf(2));
|
||||
let out = tree.layout(Rect::new(100.0, 50.0, 803.0, 600.0), 10.0, 20.0, 3.0, false);
|
||||
|
||||
// usable = 800,first = floor(400) = 400,second = 400。
|
||||
assert_eq!(out.panes.len(), 2);
|
||||
let (_, a) = out.panes[0];
|
||||
let (_, b) = out.panes[1];
|
||||
assert_eq!((a.x, a.w), (100.0, 400.0));
|
||||
assert_eq!((b.x, b.w), (503.0, 400.0));
|
||||
assert_eq!(a.h, 600.0);
|
||||
// 分隔条在两段之间。
|
||||
assert_eq!(out.dividers.len(), 1);
|
||||
let d = &out.dividers[0];
|
||||
assert_eq!((d.rect.x, d.rect.w), (500.0, 3.0));
|
||||
assert!(d.path.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_clamps_each_side_to_a_cell() {
|
||||
// 极端比例:任何一侧都不小于一个单元格。
|
||||
let tree = lr(0.001, SplitTree::Leaf(1), SplitTree::Leaf(2));
|
||||
let out = tree.layout(Rect::new(0.0, 0.0, 103.0, 100.0), 10.0, 20.0, 3.0, false);
|
||||
let (_, a) = out.panes[0];
|
||||
assert_eq!(a.w, 10.0);
|
||||
|
||||
let tree = lr(0.999, SplitTree::Leaf(1), SplitTree::Leaf(2));
|
||||
let out = tree.layout(Rect::new(0.0, 0.0, 103.0, 100.0), 10.0, 20.0, 3.0, false);
|
||||
let (_, b) = out.panes[1];
|
||||
assert_eq!(b.w, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_nested_paths_address_dividers() {
|
||||
// [1 | [2 / 3]]
|
||||
let tree = lr(0.5, SplitTree::Leaf(1), tb(0.5, SplitTree::Leaf(2), SplitTree::Leaf(3)));
|
||||
let out = tree.layout(Rect::new(0.0, 0.0, 1003.0, 600.0), 10.0, 20.0, 3.0, false);
|
||||
assert_eq!(out.panes.len(), 3);
|
||||
assert_eq!(out.dividers.len(), 2);
|
||||
assert!(out.dividers[0].path.is_empty());
|
||||
// 嵌套 Split 是根的第二个孩子。
|
||||
assert_eq!(out.dividers[1].path, vec![true]);
|
||||
assert_eq!(out.dividers[1].direction, SplitDirection::TopBottom);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_preview_only_when_asked() {
|
||||
let mut tree = lr(0.5, SplitTree::Leaf(1), SplitTree::Leaf(2));
|
||||
if let SplitTree::Split { preview_ratio, .. } = &mut tree {
|
||||
*preview_ratio = Some(0.25);
|
||||
}
|
||||
let vp = Rect::new(0.0, 0.0, 803.0, 600.0);
|
||||
let committed = tree.layout(vp, 10.0, 20.0, 3.0, false);
|
||||
let preview = tree.layout(vp, 10.0, 20.0, 3.0, true);
|
||||
assert_eq!(committed.panes[0].1.w, 400.0);
|
||||
assert_eq!(preview.panes[0].1.w, 200.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn divider_hit_uses_slop() {
|
||||
let tree = lr(0.5, SplitTree::Leaf(1), SplitTree::Leaf(2));
|
||||
let out = tree.layout(Rect::new(0.0, 0.0, 803.0, 600.0), 10.0, 20.0, 3.0, false);
|
||||
// 分隔条在 x=400..403。
|
||||
assert!(hit_divider(&out.dividers, 395.0, 300.0, 8.0).is_some());
|
||||
assert!(hit_divider(&out.dividers, 410.0, 300.0, 8.0).is_some());
|
||||
assert!(hit_divider(&out.dividers, 380.0, 300.0, 8.0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_ratio_tracks_pointer_unclamped() {
|
||||
let vp = Rect::new(100.0, 0.0, 803.0, 600.0);
|
||||
// 指针在可用区中点:(501.5 - 100 - 1.5) / 800 = 0.5。
|
||||
let r = drag_ratio(SplitDirection::LeftRight, vp, 3.0, 10.0, 501.5, 0.0);
|
||||
assert!((r - 0.5).abs() < 1e-6);
|
||||
// 越过左缘 → 负值(不钳制)。
|
||||
assert!(drag_ratio(SplitDirection::LeftRight, vp, 3.0, 10.0, 50.0, 0.0) < 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_curve_pins_close_zone() {
|
||||
assert_eq!(preview_ratio(0.03), 0.02);
|
||||
assert_eq!(preview_ratio(0.97), 0.98);
|
||||
assert_eq!(preview_ratio(0.05), 0.02);
|
||||
// 常规带 clamp 到 0.10..0.90。
|
||||
assert_eq!(preview_ratio(0.07), 0.10);
|
||||
assert_eq!(preview_ratio(0.5), 0.5);
|
||||
assert_eq!(preview_ratio(0.93), 0.90);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_close_targets_squeezed_side() {
|
||||
assert_eq!(drag_close_target(0.01), Some(false));
|
||||
assert_eq!(drag_close_target(0.99), Some(true));
|
||||
assert_eq!(drag_close_target(0.5), None);
|
||||
assert_eq!(drag_close_target(CLOSE_MARGIN), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_snaps_to_whole_cells() {
|
||||
// usable = 800,cell = 10:0.437 → 349.6px → round 350 → 0.4375。
|
||||
let r = commit_ratio(0.437, 803.0, 3.0, 10.0);
|
||||
assert!((r - 0.4375).abs() < 1e-6);
|
||||
// 钳制带仍然生效。
|
||||
assert_eq!(commit_ratio(0.02, 803.0, 3.0, 10.0), 0.10);
|
||||
assert_eq!(commit_ratio(0.98, 803.0, 3.0, 10.0), 0.90);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nav_prefers_aligned_panes() {
|
||||
// 2x2 网格:1 2 / 3 4。
|
||||
let panes = vec![
|
||||
(1u32, Rect::new(0.0, 0.0, 100.0, 100.0)),
|
||||
(2, Rect::new(110.0, 0.0, 100.0, 100.0)),
|
||||
(3, Rect::new(0.0, 110.0, 100.0, 100.0)),
|
||||
(4, Rect::new(110.0, 110.0, 100.0, 100.0)),
|
||||
];
|
||||
assert_eq!(nav_target(&panes, 1, SplitNav::Right), Some(2));
|
||||
assert_eq!(nav_target(&panes, 1, SplitNav::Down), Some(3));
|
||||
assert_eq!(nav_target(&panes, 4, SplitNav::Left), Some(3));
|
||||
assert_eq!(nav_target(&panes, 4, SplitNav::Up), Some(2));
|
||||
assert_eq!(nav_target(&panes, 1, SplitNav::Left), None);
|
||||
// 对角比同行远:从 3 往右应该到 4 而不是 2。
|
||||
assert_eq!(nav_target(&panes, 3, SplitNav::Right), Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_mut_follows_paths() {
|
||||
let mut tree = lr(0.5, SplitTree::Leaf(1), tb(0.5, SplitTree::Leaf(2), SplitTree::Leaf(3)));
|
||||
assert!(matches!(tree.node_mut(&[]), Some(SplitTree::Split { .. })));
|
||||
assert!(matches!(tree.node_mut(&[false]), Some(SplitTree::Leaf(1))));
|
||||
assert!(matches!(tree.node_mut(&[true]), Some(SplitTree::Split { .. })));
|
||||
assert!(matches!(tree.node_mut(&[true, false]), Some(SplitTree::Leaf(2))));
|
||||
assert!(tree.node_mut(&[false, false]).is_none());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user