← 博客

用 Yoga 布局控制台

Text.js 在 WebAssembly 中运行 Yoga,点缩放系数为 1,因此每个框都落在完整单元格上。文本用 Intl.Segmenter 分段,并以单元格为单位测量。

Adam

Text.js 的每一帧都由编译为 WebAssembly 的 Yoga 布局,Yoga 是 React Native 使用的 flexbox 引擎。盒子和文本共享一棵 Yoga 树。控制台是由字符单元格组成的网格,因此布局输出的所有位置和尺寸都必须是整数个单元格。

一个点对应一个单元格

Yoga 会将结果舍入到由点缩放因子定义的像素网格上。当因子为 2 时,如 Retina 屏幕上的情况,边缘可以落在半个点的位置。Text.js 只加载一次 Yoga,并将因子设为 1:

export function initYoga(): Promise<Yoga> {  if (yoga !== null) return Promise.resolve(yoga);  if (yogaPromise === null) {    yogaPromise = loadYoga().then((instance) => {      yoga = instance;      const config = instance.Config.create();      config.setPointScaleFactor(1);      config.setUseWebDefaults(false);      yogaConfig = config;      return instance;    });    yogaPromise.catch(() => {      yogaPromise = null;    });  }  return yogaPromise;}

当因子为 1 时,Yoga 会将每条边缘舍入到整数单位,Text.js 则将一个单位视为一个单元格。Yoga 先舍入每条边缘的绝对位置,再根据舍入后的边缘计算尺寸,因此相邻盒子共享同一条边缘。将一个 81 列的行分成两个 width="50%" 盒子,其中一个占 40 列,另一个占 41 列,没有间隙,也没有重叠。

Text.js 自行应用 CSS 默认值:flexDirection 为 row,flexShrink 为 1,与浏览器一致。

测量文本

文本节点是带有测量函数的 Yoga 叶节点。Yoga 将可用宽度传给该函数,Text.js 返回以单元格为单位的文本尺寸:

private measure(width: number, widthMode: MeasureMode, _height: number, _heightMode: MeasureMode): { width: number; height: number } {  const natural = this.naturalSize();  if (widthMode === MeasureMode.Undefined || Number.isNaN(width)) return natural;  const avail = Math.max(0, Math.floor(width));  if (natural.width <= avail) {    return { width: widthMode === MeasureMode.Exactly ? avail : natural.width, height: natural.height };  }  if ((this.props.wrap ?? "wrap") !== "wrap") return { width: avail, height: natural.height };  const lines = this.getLines(avail);  let maxLine = 0;  for (const l of lines) if (l.width > maxLine) maxLine = l.width;  return { width: widthMode === MeasureMode.Exactly ? avail : Math.min(avail, maxLine), height: lines.length };}

放得下的文本按自然尺寸返回。放不下的文本按可用宽度换行,高度就是行数。节点会保留上一次换行的结果,因此再次测量相同宽度时,不会重新对文本换行。

测量字素簇

字符串的 length 统计的是 UTF-16 代码单元,无法准确反映文本占用多少个单元格。文本首先通过 Intl.Segmenter 拆分成字素簇,因此 e 后接一个组合重音符构成一个簇,通过零宽连接符连接的 emoji 序列也构成一个簇。然后为每个簇确定宽度:

文本单元格数原因
abc3每个字符占一个单元格
你好4东亚宽字符占两个单元格
🚀2emoji 呈现形式占两个单元格
é(e + U+0301)1组合标记不占单元格
─│┌▼←⠋6制表符号、几何图形、箭头和盲文固定占一个单元格

边框、▼ 标记、箭头和旋转指示器的帧始终按一个单元格测量,因此盒子边框不会挤偏内容。

可打印 ASCII 字符跳过分段,其他宽度按码点和字素簇缓存。

换行

<Text> 接受一个 wrap 模式:

  • wrap(默认值)在空格处换行,单词宽于行宽时则在单词内部换行。
  • nowrap 保留单行,由盒子裁剪。
  • truncate-end、truncate-middle 和 truncate-start 保留单行,并用 … 替换放不下的部分。

截断也按单元格计数,因此一行中文会在字符边界处截断,省略号恰好占一个单元格。

实际用法

这里,一个 30 列宽的侧边栏旁边是占据剩余宽度的面板:

<Box flexDirection="row" width="100%" height="100%">  <Box width={30} flexDirection="column" border="rounded" title="Sessions" overflow="hidden">    <Text wrap="truncate-end">session-03 · refactor the renderer pipeline</Text>  </Box>  <Box flexGrow={1} flexDirection="column" border="single" title="Conversation" paddingX={1}>    <Text>Message 0: 你好 🚀</Text>  </Box></Box>

在 100 列宽的控制台中,侧边栏连同边框占 30 列,对话面板占其余 70 列。边框每侧占一个单元格,因此会话标题会在 28 个单元格处截断。