← Блог

Макет консоли с Yoga

Text.js запускает Yoga в WebAssembly с коэффициентом масштаба точек 1, поэтому каждый блок попадает на целую ячейку. Текст сегментируется через Intl.Segmenter и измеряется в ячейках.

Adam

Макет каждого кадра 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 с последующим комбинируемым знаком ударения образует один кластер, как и последовательность эмодзи, соединённых символами соединения нулевой ширины. Затем каждому кластеру назначается ширина:

ТекстЯчейкиПричина
abc3Одна ячейка на символ
你好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 ячеек.