用 Yoga 布局控制台
Text.js 在 WebAssembly 中运行 Yoga,点缩放系数为 1,因此每个框都落在完整单元格上。文本用 Intl.Segmenter 分段,并以单元格为单位测量。
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 序列也构成一个簇。然后为每个簇确定宽度:
| 文本 | 单元格数 | 原因 |
|---|---|---|
abc | 3 | 每个字符占一个单元格 |
你好 | 4 | 东亚宽字符占两个单元格 |
🚀 | 2 | emoji 呈现形式占两个单元格 |
é(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 个单元格处截断。