Макет консоли с Yoga
Text.js запускает Yoga в WebAssembly с коэффициентом масштаба точек 1, поэтому каждый блок попадает на целую ячейку. Текст сегментируется через Intl.Segmenter и измеряется в ячейках.
Макет каждого кадра Text.js рассчитывает Yoga, движок flexbox, который используется в React Native и скомпилирован в WebAssembly. Блоки и текст входят в одно дерево 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 с последующим комбинируемым знаком ударения образует один кластер, как и последовательность эмодзи, соединённых символами соединения нулевой ширины. Затем каждому кластеру назначается ширина:
| Текст | Ячейки | Причина |
|---|---|---|
abc | 3 | Одна ячейка на символ |
你好 | 4 | Широкие восточноазиатские символы занимают две ячейки |
🚀 | 2 | Представление в виде эмодзи занимает две ячейки |
é (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 ячеек.