首页
/ Windows Terminal 的 Pane(窗格)设计:从二叉树建模到分割、焦点与关闭流程

Windows Terminal 的 Pane(窗格)设计:从二叉树建模到分割、焦点与关闭流程

2026-09-06 12:02:15作者:尤峻淳Whitney

本篇技术指南以 Windows Terminal 仓库中的设计文档 doc/specs/#532 - Panes and Split Windows.md(作者 Mike Griese,创建于 2019-05-16,最后更新于 2019-07-07)为主体,系统讲解 Pane(窗格)这一"单窗口多终端会话同屏可见"抽象的设计动机、二叉树数据模型、创建/聚焦/关闭的完整流程,并结合 Pane.hPane.cpp 的真实实现与 defaults.json 的默认键位,把 2019 年规格书中"未来考虑"的清单逐条映射到今天的源码,帮助读者既读懂设计文档,又能在源码层面验证每个结论。

Windows Terminal 中左右并排的两个窗格:左侧为 PowerShell 会话,右侧为自定义配色主题的 shell 会话

1. Pane 解决什么问题:Tab 与 Pane 的分工

原文档 Abstract 一节开宗明义:

  • Tab(标签页):允许一个终端窗口内同时运行多个终端会话,但同一时刻只有一个 tab 可见
  • Pane(窗格):允许用户在一个窗口内同时看到多个终端会话的输出——例如一边看着某个日志窗格的输出,一边在另一个窗格里敲命令。

文档指出,这一设计深受 tmux(命令行"终端复用器",terminal multiplexer)启发,其他具有类似功能的应用还包括 screenterminatoremacs & vimiTerm2

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",理由有三:

  1. 屏幕空间:如果每个 pane 都有自己的 tab 行,窗口分得越碎,越多屏幕空间被 tab 行占据,而终端内容才是应用的核心;
  2. 无法 zoom:顶层 pane 一旦分根,单个 pane 就无法独占整个窗口,用户必须先关掉其他 pane;
  3. 代价与折中:该设计的缺点是"把 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 原文档的三棵示例树(完整保留)

从一个终端开始,创建一次垂直分割,得到左右并排的 AB。此时实际有 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 / _secondChildPane.h#L242-L244)以及分割比例 _desiredSplitPosition,这正是"父节点没有终端、只有两个子节点"的体现;
  • 叶子判定 _IsLeaf() 是私有方法,GetContent() 的实现在头文件中可直接看到:只有叶子才返回内容,父节点返回 nullptrPane.h#L94)——这从类型层面保证了文档"父节点无法被聚焦"的约束;
  • WalkTree(F f) 模板方法(Pane.h#L170-L203)提供深度优先的树遍历:若回调返回 void 则访问每个节点,否则一旦有节点返回真值即提前结束。FindPane(id)GetLeafPaneCount()、聚焦/缩放等几乎所有全局操作都是建立在这棵二叉树之上的遍历。

4. 创建 Pane:叶子"升级"为父节点

原文档 "Creating a pane" 一节给出了三步流程:用户决定分割当前聚焦的窗格(它必然是叶子,因为父节点没有自己的终端),新窗格的创建过程是:

  1. 该叶子转换为父节点
  2. 自己的终端内容移入第一个子节点
  3. 把 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 分割参数:SplitDirectionSplitTypeSplitPaneArgs

新 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 要创建什么终端"的设计。分割前还可通过 PreCalculateCanSplitPane.h#L127-L130)预判在给定可用空间下能否完成该分割。

5. Panes 打开期间:活动窗格、Tab 状态与分隔线拖动

原文档 "While panes are open" 一节规定了三个行为:

  1. 只有一个"活动"pane:即该 tab 中最后被聚焦的窗格;当 tab 重新获得焦点时,应恢复聚焦到最后那个窗格;
  2. Tab 状态跟随活动 pane:tab 的标题文本与图标应反映被聚焦 pane 的内容,焦点切换时 tab 应随之更新;
  3. 分隔线可拖动:移动分割线时,两侧 terminal control 的尺寸应同步变化。

源码中对应的证据:

  • Pane::GetActivePane()WasLastFocused() / _lastActive 字段(Pane.h#L74-L99)维护"最后聚焦"语义;GotFocus / LostFocus 事件(Pane.h#L224-L225)驱动 tab 标题、图标的联动更新;
  • BuildStartupState / BuildStartupActionsPane.h#L101-L109)会收集 focusedPaneId、已创建 pane 数等信息,把窗格树的布局与焦点位置写进启动状态,从而在应用重启/会话恢复时能还原"打开哪些 pane、哪个被聚焦"——这是文档"tab 获得焦点时恢复最后聚焦窗格"要求在进程级层面的延伸;
  • 分隔线尺寸调整由 ResizePane(direction) 完成(Pane.cpp#L291),并配套一整套"对齐到最小尺寸"的吸附计算(_CalcSnappedDimension_CalcSnappedChildrenSizesLayoutSizeNode 结构,Pane.h#L307-L313),确保拖动后各窗格不低于其最小尺寸;
  • 键盘导航由 NavigateDirection(sourcePane, direction, mruPanes) 实现(Pane.cpp#L347):它基于 PanePoint(x/y 偏移 + 缩放系数)与 PaneNeighborSearch 在树中寻找"某方向上的相邻窗格",mruPanes(最近使用列表)用于处理不相邻时的兜底跳转。头文件中还有一个编译期助手 DirectionMatchesSplitPane.h#L332-L351),断言"移动焦点必须跨越分隔线"——即上下穿越水平分割、左右穿越垂直分割。

此外还有 zoom(临时放大单个窗格):Maximize / RestorePane.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.CloseOtherPanesTerminal.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 生成 splitPaneauto/up/down/left/right)条目(defaults.json#L683-L707),直接落地了文档"用户应能配置分割时使用的 profile"的诉求。

8. 从"未来考虑"清单到源码现状

原文档 "Future considerations" 列出了 7 项待办(原文标注该清单"绝非全面")。对照当前仓库源码,可以逐条给出实现现状:

文档中的待办项 源码现状
用鼠标拖动分隔线调整 pane 大小 键盘路径已完整:ResizePane + 吸附计算(_CalcSnappedDimension 等);分隔线本身是 _borderFirst / _borderSecond 两个 Border 元素,并挂了 _borderTappedHandler 点击处理(Pane.h#L236-L237Pane.h#L317
缺少 ClosePane 快捷键 已有默认键 ctrl+shift+w(见第 7 节)
可配置分割所用 profile SplitPaneArgscontentArgs / 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::IPaneContentPane.h#L65-L72);终端只是其中一种实现 TerminalPaneContent,仓库内还存在 SettingsPaneContent.hSnippetsPaneContent.hMarkdownPaneContent.h 等非终端窗格内容

从源码结构看,BroadcastKey / BroadcastChar / BroadcastStringEnableBroadcastPane.h#L153-L156)则属于文档未预见的后续扩展——把同一份输入广播到多个 pane 的全部终端,broadcastBorderBrush 专门用于给处于广播状态的窗格着色。

9. 测试与验证入口

  • Pane.h#L402-L403Pane 显式声明了 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.hPane.cpp 可以看到,2019 年规格书中的核心设计——包括 SplitState 枚举、_firstChild/_secondChild 双子结构、子节点 Closed 事件、_desiredSplitPosition 分割比例——在今天的实现里几乎逐字保留;而规格书末尾的"未来考虑"清单(关闭快捷键、profile 可配置、焦点指示、键盘导航、zoom、非终端窗格)则大多已演化为 defaults.json 中可直接绑定按键的命令 id 与 Pane.h 中的具体方法。读这份规格书 + 对应源码,是理解 Windows Terminal 窗格子系统最快的路径。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
915
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388