← Blog

Układanie konsoli za pomocą Yoga

Text.js uruchamia Yoga w WebAssembly ze współczynnikiem skali punktów równym 1, więc każdy prostokąt trafia w całe komórki. Tekst jest dzielony za pomocą Intl.Segmenter i mierzony w komórkach.

Adam

Layout każdej klatki Text.js oblicza Yoga, silnik flexbox używany przez React Native, skompilowany do WebAssembly. Prostokąty i tekst współdzielą jedno drzewo Yoga. Konsola jest siatką komórek znakowych, więc każda wynikowa pozycja i każdy rozmiar muszą być całkowitą liczbą komórek.

Jeden punkt to jedna komórka

Yoga zaokrągla wyniki do siatki pikseli określonej przez współczynnik skali punktów. Przy wartości 2, jak na ekranie Retina, krawędzie mogą wypadać w połowie punktu. Text.js ładuje Yoga raz i ustawia współczynnik na 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;}

Przy współczynniku 1 Yoga zaokrągla każdą krawędź do pełnej jednostki, a Text.js traktuje jedną jednostkę jako jedną komórkę. Yoga zaokrągla bezwzględną pozycję każdej krawędzi i wylicza rozmiary z zaokrąglonych krawędzi, więc sąsiadujące prostokąty mają wspólną krawędź. Podziel wiersz o 81 kolumnach na dwa prostokąty z width="50%", a jeden otrzyma 40 kolumn, drugi 41, bez odstępu i nakładania się.

Text.js sam stosuje domyślne wartości CSS: flexDirection ma wartość row, a flexShrink ma wartość 1, jak w przeglądarce.

Pomiar tekstu

Węzły tekstowe są liśćmi drzewa Yoga z funkcją pomiaru. Yoga wywołuje ją z dostępną szerokością, a Text.js zwraca rozmiar tekstu w komórkach:

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 };}

Tekst, który się mieści, jest zwracany w swoim naturalnym rozmiarze. Tekst, który się nie mieści, jest zawijany do dostępnej szerokości, a jego wysokość jest liczbą wierszy. Węzeł zachowuje ostatni wynik zawijania, więc ponowny pomiar tej samej szerokości nie powoduje ponownego zawijania tekstu.

Pomiar klastrów grafemowych

Właściwość length ciągu znaków zlicza jednostki kodowe UTF-16, co niewiele mówi o tym, ile komórek zajmuje tekst. Najpierw tekst jest dzielony na klastry grafemowe za pomocą Intl.Segmenter, więc e z następującym po nim akcentem łączącym stanowi jeden klaster, podobnie jak sekwencja emoji połączona łącznikami o zerowej szerokości. Następnie każdemu klastrowi przypisywana jest szerokość:

TekstKomórkiDlaczego
abc3Jedna komórka na znak
你好4Szerokie znaki wschodnioazjatyckie zajmują dwie
🚀2Prezentacja emoji zajmuje dwie
é (e + U+0301)1Znaki łączące nie zajmują żadnej
─│┌▼←⠋6Znaki ramek, kształty geometryczne, strzałki i brajl mają stałą szerokość jednej komórki

Obramowania, znak ▼, strzałki i klatki wskaźnika ładowania są zawsze mierzone jako jedna komórka, więc obramowanie prostokąta nigdy nie przesuwa jego zawartości poza linię.

Drukowalne znaki ASCII pomijają segmentację, a pozostałe szerokości są buforowane dla każdego punktu kodowego i klastra.

Zawijanie

<Text> przyjmuje tryb wrap:

  • wrap (domyślny) łamie tekst na spacjach, a także wewnątrz słowa, gdy jest ono szersze od wiersza.
  • nowrap zachowuje jeden wiersz i pozwala prostokątowi go przyciąć.
  • truncate-end, truncate-middle i truncate-start zachowują jeden wiersz i zastępują to, co się nie mieści, znakiem ….

Skracanie również zlicza komórki, więc wiersz tekstu chińskiego jest ucinany na granicy znaku, a wielokropek zajmuje dokładnie jedną komórkę.

W praktyce

Tutaj panel boczny o szerokości 30 kolumn znajduje się obok panelu, który zajmuje pozostałą szerokość:

<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>

W konsoli o szerokości 100 kolumn panel boczny zajmuje 30 kolumn wraz z obramowaniem, a panel rozmowy zajmuje pozostałe 70. Obramowanie ma szerokość jednej komórki z każdej strony, więc tytuł sesji jest skracany do 28 komórek.