Windows Terminal 的 Pane(窗格)设计:从二叉树建模到分割、焦点与关闭流程
本篇技术指南以 Windows Terminal 仓库中的设计文档 doc/specs/#532 - Panes and Split Windows.md(作者 Mike Griese,创建于 2019-05-16,最后更新于 2019-07-07)为主体,系统讲解 Pane(窗格)这一"单窗口多终端会话同屏可见"抽象的设计动机、二叉树数据模型、创建/聚焦/关闭的完整流程,并结合 Pane.h、Pane.cpp 的真实实现与 defaults.json 的默认键位,把 2019 年规格书中"未来考虑"的清单逐条映射到今天的源码,帮助读者既读懂设计文档,又能在源码层面验证每个结论。
1. Pane 解决什么问题:Tab 与 Pane 的分工
原文档 Abstract 一节开宗明义:
- Tab(标签页):允许一个终端窗口内同时运行多个终端会话,但同一时刻只有一个 tab 可见;
- Pane(窗格):允许用户在一个窗口内同时看到多个终端会话的输出——例如一边看着某个日志窗格的输出,一边在另一个窗格里敲命令。
文档指出,这一设计深受 tmux(命令行"终端复用器",terminal multiplexer)启发,其他具有类似功能的应用还包括 screen、terminator、emacs & vim、iTerm2。
2. 总体架构:顶层 Tabs + 嵌套 Panes
原文档 Design 一节给出 Windows Terminal 的顶层划分:
The architecture of the Windows Terminal can be broken into two main pieces: Tabs and Panes.
即应用只有一条顶层 tab 条,每个 tab 内部包含一组 pane。文档 Footnotes 专门回答了"为什么不是顶层 panes + 嵌套 tabs",理由有三:
- 屏幕空间:如果每个 pane 都有自己的 tab 行,窗口分得越碎,越多屏幕空间被 tab 行占据,而终端内容才是应用的核心;
- 无法 zoom:顶层 pane 一旦分根,单个 pane 就无法独占整个窗口,用户必须先关掉其他 pane;
- 代价与折中:该设计的缺点是"把 pane 挪进独立 tab"较难实现,可用交换位置快捷键、zoom 快捷键、右键菜单等方式弥补;文档判断 pane 属于高级用户场景,可发现性略低可以接受。
3. 核心数据模型:Pane 是一棵二叉树
原文档给出了最关键的数据结构定义:
Panes are implemented as a binary tree of panes. A Pane can either be a leaf pane(持有自己的 terminal control), or a parent pane(有两个子节点,自己没有终端,只负责显示子节点的内容)。
分割方向有两种:
- 垂直分割(split vertically):两个窗格被一条垂直分隔线分开,左右并排,记作
[|]; - 水平分割(split horizontally):两个窗格上下堆叠,记作
[-]。
随着新 pane 不断创建,空间持续细分,父 pane 负责控制子节点的大小与显示。
3.1 原文档的三棵示例树(完整保留)
从一个终端开始,创建一次垂直分割,得到左右并排的 A、B。此时实际有 3 个节点:节点 1 是 2、3 的父节点,2 装着 A,3 装着 B:
+---------------+
| | | 1: parent [|]
| | | ├── 2: A
| | | └── 3: B
| A | B |
| | |
| | |
| | |
+---------------+
再水平分割 B,得到 C。节点 3 变成了父节点,B 被移入新节点成为 C 的兄弟:
+---------------+
| | | 1: parent [|]
| | B | ├── 2: A
| | | └── 3: parent [-]
| A +-------+ ├── 4: B
| | | └── 5: C
| | C |
| | |
+---------------+
再水平分割 A,得到 D,形成 2×2 布局:
+---------------+
| | | 1: parent [|]
| A | B | ├── 2: parent [-]
| | | | ├── 4: A
+-------+-------+ | └── 5: D
| | | └── 3: parent [-]
| D | C | ├── 4: B
| | | └── 5: C
+---------------+
文档特别强调了一个容易误解的点:此时画面上看似只有一条水平分隔线和一条垂直分隔线,但实际上每个水平分隔线只属于它分割的那一对窗格。因此用户可以在不影响另一侧的情况下独立拖动每条分隔线,例如:
+---------------+
| | |
| A | |
+-------+ B |
| | |
| D | |
| +-------+
| | C |
+---------------+
3.2 源码对照:SplitState、叶子判定与树遍历
这一模型在 Pane.h 中一一对应:
SplitState枚举(Pane.h#L48-L53):None = 0, Horizontal = 1, Vertical = 2,与文档的[-]、[|]直接对应;- 每个
Pane持有两个子指针_firstChild/_secondChild(Pane.h#L242-L244)以及分割比例_desiredSplitPosition,这正是"父节点没有终端、只有两个子节点"的体现; - 叶子判定
_IsLeaf()是私有方法,GetContent()的实现在头文件中可直接看到:只有叶子才返回内容,父节点返回nullptr(Pane.h#L94)——这从类型层面保证了文档"父节点无法被聚焦"的约束; WalkTree(F f)模板方法(Pane.h#L170-L203)提供深度优先的树遍历:若回调返回void则访问每个节点,否则一旦有节点返回真值即提前结束。FindPane(id)、GetLeafPaneCount()、聚焦/缩放等几乎所有全局操作都是建立在这棵二叉树之上的遍历。
4. 创建 Pane:叶子"升级"为父节点
原文档 "Creating a pane" 一节给出了三步流程:用户决定分割当前聚焦的窗格(它必然是叶子,因为父节点没有自己的终端),新窗格的创建过程是:
- 该叶子转换为父节点;
- 把自己的终端内容移入第一个子节点;
- 把 UI 一分为二,各显示一个子节点。
文档还说明:由宿主应用决定新 pane 里创建什么样的终端,默认使用 default settings profile。
4.1 _Split 源码逐段印证(Pane.cpp#L2276-L2354)
Pane::_Split 是上述三步的实现,关键步骤与文档一一对应:
auto actualSplitType = _convertAutomaticOrDirectionalSplitState(splitType);
// 1) 撤销旧的控制焦点事件订阅——焦点要交给"新父节点"
_gotFocusRevoker.revoke();
_lostFocusRevoker.revoke();
// 2) 清空当前根容器里的子元素
_root.Children().Clear();
_borderFirst.Child(nullptr);
_borderSecond.Child(nullptr);
// 3) 叶子 → 父:把自己的内容包装成第一个子节点
if (!_IsLeaf())
{
// 已是父节点:把现有两个孩子重新包进一个新 Pane,自己再往上提一层
auto first = std::make_shared<Pane>(_firstChild, _secondChild,
_splitState, _desiredSplitPosition);
_firstChild = first;
}
else
{
_firstChild = std::make_shared<Pane>(_takePaneContent()); // 文档第 2 步
_firstChild->_broadcastEnabled = _broadcastEnabled;
}
_splitState = actualSplitType;
_desiredSplitPosition = 1.0f - splitSize; // 新窗格占 splitSize 比例
_secondChild = newPane;
// 若向上/向左分割,交换子节点顺序,使新 pane 成为第一个孩子
if (splitType == SplitDirection::Up || splitType == SplitDirection::Left)
{
std::swap(_firstChild, _secondChild);
}
// 4) 重建 Grid 行列定义并应用分割 → 文档第 3 步
_CreateRowColDefinitions();
_borderFirst.Child(_firstChild->GetRootElement());
_borderSecond.Child(_secondChild->GetRootElement());
...
// 注册子节点的 Close 事件处理,并播放入场动画
_SetupChildCloseHandlers();
_SetupEntranceAnimation();
_id = {}; // 只有叶子才有 ID
几个值得注意的实现细节:
- "向上/向左分割"通过交换子节点实现:新 pane 成为
_firstChild,而函数返回值无论哪种方向都"先返回原窗格",保证调用方拿到的顺序稳定; - 对父节点再次分割时不丢弃结构:而是把原来的两个孩子原样包进一个新
Pane,自己上移一层——这解释了第 3 节那棵示例树中"节点 3 从叶子变成父节点、B被移入新节点"的过程,是纯粹的树重排,不丢失任何分割状态; - 只有叶子持有
_id:父节点在_Split末尾清掉_id,与WalkTree中按 id 定位窗格的语义一致。
4.2 分割参数:SplitDirection、SplitType 与 SplitPaneArgs
新 pane "用什么 profile、分多大"由参数模型描述。ActionArgs.idl 中定义了两个枚举:
enum SplitDirection
{
Automatic = 0, // 自动选择方向(_convertAutomaticOrDirectionalSplitState 会解析)
Up,
Right,
Down,
Left
};
enum SplitType
{
Manual = 0, // 普通分割
Duplicate = 1 // 复制当前窗格的会话/内容
};
SplitPaneArgs 的构造签名(ActionArgs.idl#L264-L269)为:
SplitPaneArgs(SplitType splitMode, SplitDirection split, Single size, INewContentArgs contentArgs);
SplitPaneArgs(SplitDirection split, Single size, INewContentArgs contentArgs);
SplitPaneArgs(SplitDirection split, INewContentArgs contentArgs);
即 split(方向)、size(新窗格占比,对应 _Split 中的 splitSize)、contentArgs(决定新 pane 的终端内容/ profile)三者齐备,正好落实了文档"由宿主应用告诉 pane 要创建什么终端"的设计。分割前还可通过 PreCalculateCanSplit(Pane.h#L127-L130)预判在给定可用空间下能否完成该分割。
5. Panes 打开期间:活动窗格、Tab 状态与分隔线拖动
原文档 "While panes are open" 一节规定了三个行为:
- 只有一个"活动"pane:即该 tab 中最后被聚焦的窗格;当 tab 重新获得焦点时,应恢复聚焦到最后那个窗格;
- Tab 状态跟随活动 pane:tab 的标题文本与图标应反映被聚焦 pane 的内容,焦点切换时 tab 应随之更新;
- 分隔线可拖动:移动分割线时,两侧 terminal control 的尺寸应同步变化。
源码中对应的证据:
Pane::GetActivePane()、WasLastFocused()/_lastActive字段(Pane.h#L74-L99)维护"最后聚焦"语义;GotFocus/LostFocus事件(Pane.h#L224-L225)驱动 tab 标题、图标的联动更新;BuildStartupState/BuildStartupActions(Pane.h#L101-L109)会收集focusedPaneId、已创建 pane 数等信息,把窗格树的布局与焦点位置写进启动状态,从而在应用重启/会话恢复时能还原"打开哪些 pane、哪个被聚焦"——这是文档"tab 获得焦点时恢复最后聚焦窗格"要求在进程级层面的延伸;- 分隔线尺寸调整由
ResizePane(direction)完成(Pane.cpp#L291),并配套一整套"对齐到最小尺寸"的吸附计算(_CalcSnappedDimension、_CalcSnappedChildrenSizes及LayoutSizeNode结构,Pane.h#L307-L313),确保拖动后各窗格不低于其最小尺寸; - 键盘导航由
NavigateDirection(sourcePane, direction, mruPanes)实现(Pane.cpp#L347):它基于PanePoint(x/y 偏移 + 缩放系数)与PaneNeighborSearch在树中寻找"某方向上的相邻窗格",mruPanes(最近使用列表)用于处理不相邻时的兜底跳转。头文件中还有一个编译期助手DirectionMatchesSplit(Pane.h#L332-L351),断言"移动焦点必须跨越分隔线"——即上下穿越水平分割、左右穿越垂直分割。
此外还有 zoom(临时放大单个窗格):Maximize / Restore(Pane.cpp#L2366-L2424)沿树递归,把被放大窗格从 UI 树中摘除、使其独占 tab 内容区,恢复时再挂回原位,由 _zoomed 标志与 togglePaneZoom 命令驱动。
6. 关闭 Pane:两种树形收缩情形
原文档 "Closing a pane" 一节定义:pane 可由用户手动关闭,也可在其终端触发 ConnectionClosed 事件时自动关闭。从树中移除被关闭 pane 后,父节点要按剩余子节点的类型分两种情况处理:
- 剩余子节点是叶子:父节点直接"接管"剩余 pane 的全部状态,剩余窗格内容扩展至父节点整个边界;
- 剩余子节点本身是父节点:父节点直接收养剩余子节点的两个孩子,等价于把父节点从树中拿掉、用剩余子节点顶替。
6.1 _CloseChildRoutine:动画与重挂载(Pane.cpp#L1594-L1713)
源码在结构上忠实实现了上述规则,并且把"接管状态"做成了可见动画:
void Pane::_CloseChildRoutine(const bool closeFirst)
{
// 依据系统"显示动画"开关与应用内开关决定是否播放动画
// GH#7252: 若任一子节点处于 zoom 状态,跳过动画
...
// 创建与被关闭 pane 同尺寸的 dummyGrid 占位
dummyGrid.Background(_themeResources.unfocusedBorderBrush);
dummyGrid.Width(removedOriginalSize.Width);
dummyGrid.Height(removedOriginalSize.Height);
// 关闭一侧设为 Auto,存活一侧设为 *(星号)以吸收全部剩余空间
// 用 DoubleAnimation 把 dummyGrid 从原尺寸动画到 0
animation.Completed(weakThis, closeFirst
{
// 动画结束:把存活子节点的内容重新 parent 到本节点
pane->_CloseChild(closeFirst);
});
}
要点:
- Grid 的 Auto/Star 配合实现了"剩余窗格扩展占满父节点边界":关闭一侧的行列改为
Auto,存活一侧改为1*,dummy grid 缩小到 0 的过程中存活 pane 自然长大; - 事件链路与文档一致:
Pane暴露Closed事件(Pane.h#L220),父节点通过_SetupChildCloseHandlers订阅两个子节点的Closed(token 存在_firstClosedToken/_secondClosedToken);终端侧的连接断开由IsConnectionClosed()(Pane.h#L79)体现。
defaults.json 中还提供了 closeOtherPanes / closePane 两个命令 id(Terminal.CloseOtherPanes、Terminal.ClosePane),分别用于"关闭其余所有窗格"与"关闭当前窗格"。
7. 默认命令与键位(来自 defaults.json)
defaults.json 中 "Pane Management" 区块(defaults.json#L579-L617)列出了 pane 相关的完整命令 id 集合,节选如下:
// Pane Management
{ "command": "closeOtherPanes", "id": "Terminal.CloseOtherPanes" },
{ "command": "closePane", "id": "Terminal.ClosePane" },
{ "command": { "action": "splitPane", "split": "up" }, "id": "Terminal.SplitPaneUp" },
{ "command": { "action": "splitPane", "split": "down" }, "id": "Terminal.SplitPaneDown" },
{ "command": { "action": "splitPane", "split": "left" }, "id": "Terminal.SplitPaneLeft" },
{ "command": { "action": "splitPane", "split": "right" }, "id": "Terminal.SplitPaneRight" },
{ "command": { "action": "splitPane", "splitMode": "duplicate", "split": "down" }, "id": "Terminal.DuplicatePaneDown" },
{ "command": { "action": "splitPane", "splitMode": "duplicate", "split": "right" }, "id": "Terminal.DuplicatePaneRight" },
{ "command": { "action": "splitPane", "splitMode": "duplicate", "split": "auto" }, "id": "Terminal.DuplicatePaneAuto" },
{ "command": { "action": "resizePane", "direction": "down" }, "id": "Terminal.ResizePaneDown" },
{ "command": { "action": "resizePane", "direction": "left" }, "id": "Terminal.ResizePaneLeft" },
{ "command": { "action": "resizePane", "direction": "right" }, "id": "Terminal.ResizePaneRight" },
{ "command": { "action": "resizePane", "direction": "up" }, "id": "Terminal.ResizePaneUp" },
...
{ "command": "toggleBroadcastInput", "id": "Terminal.ToggleBroadcastInput" },
{ "command": "togglePaneZoom", "id": "Terminal.TogglePaneZoom" },
{ "command": "toggleSplitOrientation", "id": "Terminal.ToggleSplitOrientation" },
{ "command": { "action": "movePane", "index": 0 }, "id": "Terminal.MovePaneToTab0" },
{ "command": { "action": "movePane", "window": "new" }, "id": "Terminal.MovePaneToNewWindow" },
其中已带默认按键的条目(defaults.json#L765-L771):
| 按键 | 命令 id | 作用 |
|---|---|---|
ctrl+shift+w |
Terminal.ClosePane |
关闭当前窗格(对应文档"未来考虑"中的 ClosePane 快捷键) |
alt+shift+- |
Terminal.DuplicatePaneDown |
向下"复制式"分割 |
alt+shift+plus |
Terminal.DuplicatePaneRight |
向右"复制式"分割 |
alt+shift+up / down / left / right |
Terminal.ResizePane* |
调整当前窗格尺寸(对应文档"移动分隔线"的键盘版) |
splitPane 还支持 "profile": "..." 参数直接指定新窗格的 profile;右键菜单中的 "Split Pane..." 子菜单则用 "iterateOn": "profiles" 动态为每个 profile 生成 splitPane(auto/up/down/left/right)条目(defaults.json#L683-L707),直接落地了文档"用户应能配置分割时使用的 profile"的诉求。
8. 从"未来考虑"清单到源码现状
原文档 "Future considerations" 列出了 7 项待办(原文标注该清单"绝非全面")。对照当前仓库源码,可以逐条给出实现现状:
| 文档中的待办项 | 源码现状 |
|---|---|
| 用鼠标拖动分隔线调整 pane 大小 | 键盘路径已完整:ResizePane + 吸附计算(_CalcSnappedDimension 等);分隔线本身是 _borderFirst / _borderSecond 两个 Border 元素,并挂了 _borderTappedHandler 点击处理(Pane.h#L236-L237、Pane.h#L317) |
| 缺少 ClosePane 快捷键 | 已有默认键 ctrl+shift+w(见第 7 节) |
| 可配置分割所用 profile | SplitPaneArgs 的 contentArgs / profile 参数 + 菜单动态条目 + Duplicate 复制式分割(ActionArgs.idl#L71-L84) |
| 用 UI 指示哪个 pane 被聚焦(tmux 给分隔线着色/描边) | 父节点通过 _ComputeBorderColor 计算边框颜色,PaneResources 持有 focusedBorderBrush / unfocusedBorderBrush / broadcastBorderBrush 三套画笔(Pane.h#L55-L60),UpdateVisuals / _UpdateBorders 负责刷新——与 tmux 思路一致 |
| 键盘在 pane 间导航焦点 | NavigateDirection(sourcePane, direction, mruPanes) + FocusPane(id) / FindPane(id)(Pane.h#L114-L147) |
| 临时放大单个 pane(zoom) | Maximize / Restore 递归实现 + Terminal.TogglePaneZoom 命令 + _zoomed 状态(Pane.cpp#L2366-L2424) |
| pane 未必需要承载终端,可放任意 UIElement | 抽象为 IPaneContent 接口(Pane 的构造函数入参即为 winrt::TerminalApp::IPaneContent,Pane.h#L65-L72);终端只是其中一种实现 TerminalPaneContent,仓库内还存在 SettingsPaneContent.h、SnippetsPaneContent.h、MarkdownPaneContent.h 等非终端窗格内容 |
从源码结构看,BroadcastKey / BroadcastChar / BroadcastString 与 EnableBroadcast(Pane.h#L153-L156)则属于文档未预见的后续扩展——把同一份输入广播到多个 pane 的全部终端,broadcastBorderBrush 专门用于给处于广播状态的窗格着色。
9. 测试与验证入口
- Pane.h#L402-L403 中
Pane显式声明了friend struct winrt::TerminalApp::implementation::Tab;与friend class ::TerminalAppLocalTests::TabTests;,本地测试即 TabTests.cpp,覆盖了分割、聚焦、关闭等窗格树操作; - CommandlineTest.cpp 中大量用例通过
actionAndArgs.Args().try_as<SplitPaneArgs>()断言命令行参数被正确解析为splitPane动作(如 CommandlineTest.cpp#L740 起的一系列用例),可用于理解--split类参数如何落到第 4 节描述的分割流程上; - 启动状态的还原逻辑(
BuildStartupActions收集各叶子 pane 的参数与focusedPaneId)同样可被 TabTests.cpp 直接验证。
小结
doc/specs/#532 - Panes and Split Windows.md 用一棵二叉树给出了 Windows Terminal 窗格体系的最小完整模型:叶子持终端、父节点只持两个孩子、[|] 与 [-] 两种分割方向互不干扰;创建 pane 是"叶子升级父节点、内容下移一层",关闭 pane 是"父节点按剩余子节点类型接管或收养孙节点"。对照 Pane.h 与 Pane.cpp 可以看到,2019 年规格书中的核心设计——包括 SplitState 枚举、_firstChild/_secondChild 双子结构、子节点 Closed 事件、_desiredSplitPosition 分割比例——在今天的实现里几乎逐字保留;而规格书末尾的"未来考虑"清单(关闭快捷键、profile 可配置、焦点指示、键盘导航、zoom、非终端窗格)则大多已演化为 defaults.json 中可直接绑定按键的命令 id 与 Pane.h 中的具体方法。读这份规格书 + 对应源码,是理解 Windows Terminal 窗格子系统最快的路径。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
