Lumino 的布局系统:DockPanel / SplitPanel / TabBar 一条龙

前面两篇分别把 Widget 本身和 signaling/messaging 这两条“底噪”线理了一遍,这篇就顺着往上走一层:
DockPanel / SplitPanel / TabBar 这一整套布局 Widget,怎么在页面上拼出一个 IDE 风格的桌面感布局。

用一句话先概括 Lumino 的布局观:

  • 普通 Widget:负责“内容”。
  • 各种 Panel / Bar:负责“怎么把这些内容排布在一个二维平面上”。

典型的几类:

  • Panel:最基础的容器,内部可以挂多个子 Widget。
  • BoxPanel / SplitPanel:负责水平/垂直方向的分割和拉伸。
  • TabBar + TabPanel:提供多标签切换的 UI。
  • DockPanel:把分屏 + 停靠 + Tab 组合在一起,是 IDE 布局的核心。

在 Theia 这类 IDE 里面,最外层的 Shell 一般会持有一个根 DockPanel,然后根据区域(left/right/bottom/main)往里面塞不同 Widget。

先从相对简单的 SplitPanel 说起,它提供了一条可以拖动的分隔线,把空间按比例切给多个子 Widget。

 1import { SplitPanel, Widget } from '@lumino/widgets';
 2
 3// 创建几个简单的内容 Widget
 4function createContent(label: string, color: string): Widget {
 5  const w = new Widget();
 6  w.addClass('my-SplitPanel-Item');
 7  w.node.textContent = label;
 8  w.node.style.background = color;
 9  return w;
10}
11
12const left = createContent('Left Pane', '#f5d0c5');
13const right = createContent('Right Pane', '#c5d0f5');
14
15// 创建一个水平 SplitPanel
16const split = new SplitPanel({ orientation: 'horizontal' });
17split.addWidget(left);
18split.addWidget(right);
19
20SplitPanel.setStretch(left, 1);  // 左右各 1 份
21SplitPanel.setStretch(right, 1);
22
23Widget.attach(split, document.body);

几个要点:

  • orientation 决定分割方向:'horizontal''vertical'
  • setStretch 可以简单控制每个子 Widget 占多大比例。
  • 用户拖动中间的分隔条时,内部会通过 messaging 触发子 Widget 的 onResize,从而驱动重绘。

在 IDE 里,SplitPanel 更像是很底层的一块积木,DockPanel 其实就是在更复杂的场景下组合和管理这些分裂出来的区域。

第二块积木是 Tab,一组 Widget 共享一个可见区域,通过标签切换:

 1import { TabPanel, Widget } from '@lumino/widgets';
 2
 3function createTab(label: string): Widget {
 4  const w = new Widget();
 5  w.addClass('my-TabPanel-Item');
 6  w.node.textContent = label;
 7  return w;
 8}
 9
10const panel = new TabPanel();
11panel.addWidget(createTab('Tab A'));
12panel.addWidget(createTab('Tab B'));
13panel.addWidget(createTab('Tab C'));
14
15Widget.attach(panel, document.body);
16panel.currentIndex = 0; // 默认选中第一个

这里 TabPanel 内部会管理一个 TabBar,以及与之对应的内容区域:

  • TabBar 负责上面那一排可点击的标签。
  • 内容区域负责展示当前激活的那个 Widget。

在 IDE 布局里,这个模式最典型的应用就是“一个编辑区里打开多个文件”的场景:每个编辑器是一个 Widget,中间那排文件名就是 TabBar。

真正让布局变得“桌面感爆表”的是 DockPanel:它把分屏(split)、停靠(dock)和 Tab 三种能力揉到了一起。

一个最小可感受效果的例子:

 1import { DockPanel, Widget } from '@lumino/widgets';
 2
 3function createDockWidget(label: string, color: string): Widget {
 4  const w = new Widget();
 5  w.addClass('my-DockPanel-Item');
 6  w.node.textContent = label;
 7  w.node.style.background = color;
 8  return w;
 9}
10
11const dock = new DockPanel();
12
13const w1 = createDockWidget('Main', '#fef3c7');
14const w2 = createDockWidget('Right-Top', '#e0f2fe');
15const w3 = createDockWidget('Right-Bottom', '#dcfce7');
16
17// 第一个 Widget 作为初始内容
18dock.addWidget(w1);
19
20// 在右侧拆出一个区域
21dock.addWidget(w2, { mode: 'split-right', ref: w1 });
22
23// 在右侧区域再次向下拆分
24dock.addWidget(w3, { mode: 'split-bottom', ref: w2 });
25
26Widget.attach(dock, document.body);

这里的关键是第二个参数 options

  • mode 决定“怎么插入”:
    • 'split-right' / 'split-left' / 'split-top' / 'split-bottom':在对应方向拆出一个新区域。
    • 'tab-after' / 'tab-before':在同一区域新建一个 Tab。
  • ref 指定一个“参考 Widget”,表示你要相对谁进行拆分/停靠。

在 Theia 里,当你从视图菜单里打开某个面板(比如 Outline / Problems),或者把终端拖到底部区域时,背后基本都是对 DockPanel.addWidget 的各种组合调用。

IDE 类应用一个很常见的需求是:记住用户怎么折腾布局的,下次打开时还原。
Lumino 为此提供了布局序列化的能力——可以把当前 DockPanel 的结构 dump 成一个 JSON,然后再 restore 回来。

概念上大概是这样(简化伪代码):

 1import { DockPanel } from '@lumino/widgets';
 2
 3const dock = new DockPanel();
 4
 5// ... 中间用户各种拖拽、拆分、合并 ...
 6
 7// 1. 导出布局(可以存到 localStorage 或服务器)
 8const layout = dock.saveLayout();
 9localStorage.setItem('layout', JSON.stringify(layout));
10
11// 2. 重新创建 DockPanel 时恢复布局
12const saved = localStorage.getItem('layout');
13if (saved) {
14  const layoutObj = JSON.parse(saved);
15  dock.restoreLayout(layoutObj);
16}

Theia 自己在应用层还会加一层封装:
不只是还原「长得怎样」,还要确保对应的 Widget 能重新创建出来并填充到正确位置,这里就会涉及到 ViewRegistry / WidgetFactory 之类的机制——这一块更偏 Theia 自身架构,可以等讲到 Theia Shell 时再展开。

结合前两篇,再把这几个 Widget 放回 Theia 的语境里看一下(简化心智模型):

  • Lumino 的 Widget / DockPanel / TabBar / SplitPanel
    • 负责“物理布局”和“窗口行为”——桌面感的来源。
    • Theia 更多是作为“使用者”和“组织者”,去告诉 Lumino 怎么摆这些砖。
  • Theia 自己的 Shell / View / Contribution 层
    • 决定“有哪些区域”“每个区域里塞什么 Widget”“这些视图怎么注册/销毁/还原”。
    • 在合适的时机调用 dock.addWidget / restoreLayout 等接口。

所以在读 Theia 代码的时候,我的习惯是:

  • 一旦看到涉及“main/left/right/bottom area 布局”的地方,就自动联想到背后一定有 Lumino 的 DockPanel 在运作。
  • 遇到拖拽 / 合并 / 分屏相关逻辑时,会先在 Lumino 这几个布局 Widget 里找“底层行为”,再看 Theia 是怎么在上层包一层自己的抽象的。

从 Widget -> signaling/messaging -> 布局 Widget(DockPanel / SplitPanel / TabBar)这一串看下来,大概可以得到一个还算清晰的图景:

  • Widget:长什么样、放什么内容。
  • signaling / messaging:内部怎么“说话”和“按节奏动起来”。
  • DockPanel / SplitPanel / TabBar:这些砖最后怎么拼成一个能分屏、能拖拽、能记住布局的桌面感 UI。

对我来说,把这几块单独拎出来写一篇,是为了以后再遇到奇怪的布局问题(比如:某个视图拖不对地方 / 重启之后布局错乱),脑子里能快速定位:
到底是 Theia 应用层的“视图注册/还原”出了问题,还是 Lumino 那边的布局行为需要再去翻一遍源码。