05 · Workspace、Tab 与 BSP 布局¶
状态/运行时分开后,视图侧还要解决另一个问题:用户看到的是 workspace、tab 和 pane,终端 runtime 却不应该依赖它在屏幕上的坐标。Herdr 用分层容器加一棵二叉分割树表达组织结构,用稳定公开编号承载 CLI/API 契约。
5.1 层级关系¶
flowchart TB
App["AppState"] --> W1["Workspace A"]
App --> W2["Workspace B"]
W1 --> T1["Tab 1"]
W1 --> T2["Tab 2"]
T1 --> L["TileLayout BSP tree"]
L --> P1["PaneState → TerminalId α"]
L --> P2["PaneState → TerminalId β"]
L --> P3["PaneState → TerminalId γ"]
P1 -.lookup.-> R["TerminalRuntimeRegistry"]
P2 -.lookup.-> R
P3 -.lookup.-> R
Workspace 管理一组 tab、工作目录/Git 身份和公开计数器;Tab 管理一棵布局树与 pane map。
5.2 Workspace 不是文件夹别名¶
Workspace 虽然有 cwd,却还承载:
- 稳定 public workspace id;
- 用户自定义名称;
- identity cwd 与显示 cwd;
- Git repository/worktree 识别缓存;
- worktree membership 元数据;
- tabs 和当前 active tab;
- 下一个 public tab/pane 编号;
- sidebar、状态展示需要的摘要。
“identity cwd”值得单独强调。cwd 可能因目录重命名、symlink、worktree 或子目录导航而变化;用户脚本引用的 workspace identity 不应该只是每帧重新 canonicalize 的路径字符串。
5.3 Tab 是布局与焦点边界¶
一个 Tab 保存:
- 自定义名称和公开 tab 编号;
- root/active pane;
TileLayout;PaneId -> PaneStatemap;- zoomed pane;
- 与该 tab 相关的通道/运行协调信息。
把 tab 作为布局边界有两个好处:
- 切 tab 不需要销毁后台 pane 的 PTY;
- 每棵树的几何、focus history 和 zoom 可以独立计算。
5.4 BSP 树的最小模型¶
src/layout.rs 的核心节点只有两种形状:
// 形状化简,字段名以源码为准
enum Node {
Pane(PaneId),
Split {
direction: Direction,
ratio: f32,
first: Box<Node>,
second: Box<Node>,
},
}
例如下列树:
flowchart TB
R["Horizontal · 0.60"] --> A["Pane A"]
R --> V["Vertical · 0.50"]
V --> B["Pane B"]
V --> C["Pane C"]
会把区域先按宽度切成 A 与右侧,再把右侧按高度切成 B/C。每个内部节点只保存方向和比例,因此:
- 序列化结构紧凑;
- resize 时递归重算 rect;
- 交换/move pane 只改叶子;
- 相邻导航可从几何和树路径推导;
- border 由 split 节点自然产生。
5.5 几何计算¶
TileLayout 从 root rect 递归分配:
layout(node, area):
Pane(id) -> rect[id] = area
Split(dir, ratio, a, b):
(first_rect, second_rect) = split_rect(area, dir, ratio)
layout(a, first_rect)
layout(b, second_rect)
split_rect 在整数 cell 网格中 round。小窗口和多层分割会产生舍入误差,所以不能假设每个叶子严格得到浮点比例;最终约束是所有 rect 不越界且覆盖可用区域。
比例通过 valid_split_ratio 夹在 0.1..=0.9,非有限值回落到安全值。这个约束既保护 UI 可操作性,也防止 snapshot/API 输入把 pane 压成不可恢复的零尺寸。
5.6 Split 不是只改树¶
用户请求分屏时,至少有三层变化:
flowchart LR
Action["split action"] --> Tree["BSP 插入新 PaneId"]
Action --> State["创建 PaneState + TerminalState"]
Action --> Runtime["spawn PTY + 注册 runtime"]
Runtime --> Resize["按新 rect resize 所有受影响 PTY"]
Tree --> Focus["更新 focus/history/public id"]
State --> Persist["安排 snapshot"]
TileLayout::split_pane 本身只是纯树变换;业务 action 必须把它放进更大的补偿事务。若 PTY spawn 失败,不能留下没有 runtime 的空 pane,也不能消耗一个用户以为成功的公开编号后静默显示坏状态。
5.7 Focus 与选择历史¶
布局需要区分:
- 当前 focused pane;
- 上一个 focus,用于关闭后回退;
- 通过方向键寻找的几何邻居;
- zoom 前的布局 focus;
- tab/workspace 切换时各自记住的 active 项。
树结构提供父子关系,却不自动提供“向左走应该选谁”。源码会计算 pane rect 与 split border,依据方向、重叠范围和距离选择候选。这样在不规则嵌套布局里,方向导航更接近视觉直觉,而不是简单 DFS 前后项。
5.8 Resize 操作¶
resize 的语义是寻找 focused pane 某个方向上的相关 split,并调整其 ratio。需要注意 focused pane 可能位于 split 的 first 或 second child:
Herdr 通过 split path 和 pane 几何判断该调整哪个祖先节点,再调用 set_ratio_at。比例夹紧确保连续 resize 不会把另一侧彻底挤没。
测试覆盖:
- first/second child 的增减方向;
- missing target 不改变树;
- 自定义 split ratio;
- 多层嵌套下选中正确 border;
- snapshot/restore 后比例保持。
5.9 Zoom 是视图投影,不是破坏布局¶
zoom 某 pane 时,理想语义是“暂时只把它投影到整个内容区域”,而不是把其他叶子从树中删除。Tab 保存 zoomed pane,原 TileLayout 继续存在:
- 取消 zoom 可无损恢复;
- 后台 pane 的 PTY 继续运行;
- snapshot 能表达 zoom 状态;
- focus 和公开编号不变化。
这与 CSS 隐藏类似:隐藏视图不等于销毁领域对象。
5.10 公开编号为何不复用¶
内部 PaneId 只需要当前进程唯一;CLI/API 的 public id 是用户契约。若关闭 pane 3 后立即把新 pane 编成 3,一个迟到的脚本或 pane.wait 可能操作错误对象。
Workspace 保存单调递增的公开计数器,编号在其作用域内稳定且不轻易复用。解析层还支持当前 workspace/tab 等别名,但最终要解析成明确层级和 TerminalId,再执行动作。
这种设计牺牲了编号连续美观,换来异步自动化安全。对控制平面而言,这是正确取舍。
5.11 移动、关闭与空容器¶
移动 pane¶
移动应尽量保持 TerminalId 和 runtime 不变,只改变:
- 源/目标
TileLayout; PaneState所属 map;- 公开位置解析;
- focus、active tab 和可能的空容器清理。
关闭 pane¶
树删除一个叶子后,其 sibling 替换父 split。若 tab 最后一个 pane 关闭,需要决定关闭 tab、创建替代 shell 还是关闭 workspace;这些是 app action 层策略,不属于纯 TileLayout。
关闭 tab/workspace¶
必须遍历所有附着 terminal,而不是只关闭当前可见 pane。若某些 runtime 被移动、共享或正处于 handoff,还要以 registry/ownership 为准,避免 double shutdown。
5.12 Workspace 与 Git 缓存¶
Workspace UI 经常显示 branch、repo、dirty state。直接在 render 中执行 git 会把 pane 数量乘以刷新频率。Herdr 把 Git identity/status 放入缓存,由后台发现和事件更新;render 只读准备好的摘要。
这也是 Workspace 字段看起来超出“tabs 容器”的原因:Git/worktree 是 workspace 身份的一部分,但探测必须被移出热路径。第 12 章会沿异步 worktree 操作完整展开。
5.13 快照形状¶
持久化不直接 serde 内部树,而是用 LayoutSnapshot 镜像 BSP 结构:pane 叶子保存可恢复引用,split 保存 direction/ratio/children。恢复时还要验证:
- 每个 layout leaf 都有 pane snapshot;
- active/focused/zoomed ID 存在;
- ratio 有限且合法;
- 旧版本无 tab 的 workspace 可迁移;
- 无法恢复的 pane 不破坏整棵 workspace。
5.14 可迁移经验¶
- 视图位置和资源身份分开,移动 UI 节点不应重建进程。
- BSP 树是分屏的稳定最小模型,比存绝对坐标更适合 resize 和持久化。
- 公开 ID 应优化异步安全,不应优化连续美观。
- 跨 state/runtime 的 UI 动作要按补偿事务设计。
- zoom、hide、background 是投影状态,不应暗含资源销毁。
下一章穿过 TerminalId 进入活跃 runtime:一串不可打印的 PTY 字节如何驱动 Ghostty VT、终端回包、标题/剪贴板回调、检测文本和最终画面。