← Blog

Das Konsolenlayout mit Yoga gestalten

Text.js führt Yoga in WebAssembly mit einem Punktskalierungsfaktor von 1 aus, sodass jede Box auf einer ganzen Zelle landet. Text wird mit Intl.Segmenter segmentiert und in Zellen gemessen.

Adam

Yoga berechnet das Layout jedes Text.js-Frames. Die flexbox-Engine kommt auch in React Native zum Einsatz und ist hier zu WebAssembly kompiliert. Boxen und Text teilen sich einen Yoga-Baum. Die Konsole ist ein Raster aus Zeichenzellen, daher müssen alle berechneten Positionen und Größen einer ganzen Anzahl von Zellen entsprechen.

Ein Punkt entspricht einer Zelle

Yoga rundet seine Ergebnisse auf ein Pixelraster, das der Punktskalierungsfaktor vorgibt. Bei 2, wie auf einem Retina-Bildschirm, können Kanten auf halben Punkten liegen. Text.js lädt Yoga einmal und setzt den Faktor auf 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;}

Mit einem Faktor von 1 rundet Yoga jede Kante auf eine ganze Einheit, und Text.js behandelt eine Einheit als eine Zelle. Yoga rundet die absolute Position jeder Kante und leitet die Größen aus den gerundeten Kanten ab, sodass benachbarte Boxen eine gemeinsame Kante haben. Teile eine Zeile mit 81 Spalten in zwei Boxen mit width="50%" auf, und eine erhält 40 Spalten, die andere 41, ohne Lücke und ohne Überlappung.

Text.js wendet die CSS-Standardwerte selbst an: flexDirection ist row und flexShrink ist 1, wie in einem Browser.

Text messen

Textknoten sind Blätter im Yoga-Baum mit einer Messfunktion. Yoga ruft sie mit der verfügbaren Breite auf, und Text.js gibt die Größe des Textes in Zellen zurück:

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

Text, der hineinpasst, wird in seiner natürlichen Größe zurückgegeben. Text, der nicht hineinpasst, wird bei der verfügbaren Breite umgebrochen, und seine Höhe entspricht der Anzahl der Zeilen. Der Knoten speichert seinen letzten Umbruch, sodass eine erneute Messung mit derselben Breite den Text nicht erneut umbricht.

Graphemcluster messen

Die length einer Zeichenkette zählt UTF-16-Codeeinheiten, was wenig darüber aussagt, wie viele Zellen sie füllt. Text wird zunächst mit Intl.Segmenter in Graphemcluster aufgeteilt, sodass e gefolgt von einem kombinierenden Akzent einen Cluster bildet, ebenso wie eine durch Zero-Width Joiner verbundene Emoji-Sequenz. Anschließend erhält jeder Cluster eine Breite:

TextZellenBegründung
abc3Eine Zelle pro Zeichen
你好4Breite ostasiatische Zeichen belegen zwei
🚀2Die Emoji-Darstellung belegt zwei
é (e + U+0301)1Kombinierende Zeichen belegen keine
─│┌▼←⠋6Rahmenzeichen, geometrische Formen, Pfeile und Braille sind auf eine Zelle festgelegt

Rahmenzeichen, das Zeichen ▼, Pfeile und Spinner-Frames werden immer als eine Zelle gemessen, sodass der Rahmen einer Box ihren Inhalt nie aus der Ausrichtung schiebt.

Druckbare ASCII-Zeichen überspringen die Segmentierung, und andere Breiten werden pro Codepunkt und pro Cluster zwischengespeichert.

Zeilenumbruch

<Text> akzeptiert einen wrap-Modus:

  • wrap (der Standard) bricht an Leerzeichen um und innerhalb eines Wortes, wenn das Wort breiter als die Zeile ist.
  • nowrap behält eine Zeile bei und lässt die Box sie abschneiden.
  • truncate-end, truncate-middle und truncate-start behalten eine Zeile bei und ersetzen alles, was nicht hineinpasst, durch ….

Auch das Kürzen zählt Zellen, sodass eine Zeile mit chinesischem Text an einer Zeichengrenze abgeschnitten wird und das Auslassungszeichen genau eine Zelle belegt.

In der Praxis

Hier steht eine Seitenleiste mit 30 Spalten neben einem Bereich, der die restliche Breite einnimmt:

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

In einer Konsole mit 100 Spalten belegt die Seitenleiste einschließlich ihres Rahmens 30 Spalten, und der Gesprächsbereich belegt die übrigen 70. Ein Rahmen belegt auf jeder Seite eine Zelle, daher wird der Sitzungstitel bei 28 Zellen gekürzt.