Layout de la console avec Yoga
Text.js exécute Yoga en WebAssembly avec un facteur d’échelle des points de 1, pour que chaque boîte occupe des cellules entières. Le texte est segmenté avec Intl.Segmenter et mesuré en cellules.
Yoga, le moteur flexbox utilisé par React Native et compilé en WebAssembly, calcule le layout de chaque frame de Text.js. Les boîtes et le texte partagent un même arbre Yoga. La console est une grille de cellules de caractères : chaque position et chaque dimension calculée doit donc correspondre à un nombre entier de cellules.
Un point vaut une cellule
Yoga arrondit ses résultats sur une grille de pixels définie par le facteur d’échelle des points. À 2, comme sur un écran Retina, les bords peuvent tomber sur des demi-points. Text.js charge Yoga une seule fois et fixe le facteur à 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;}Avec un facteur de 1, Yoga arrondit chaque bord à une unité entière, et Text.js considère qu’une unité vaut une cellule. Yoga arrondit la position absolue de chaque bord et déduit les dimensions des bords arrondis, de sorte que les boîtes voisines partagent un bord. Divisez une ligne de 81 colonnes en deux boîtes width="50%" : l’une reçoit 40 colonnes, l’autre 41, sans espace ni chevauchement.
Text.js applique lui-même les valeurs CSS par défaut : flexDirection vaut row et flexShrink vaut 1, comme dans un navigateur.
Mesure du texte
Les nœuds de texte sont des feuilles de l’arbre Yoga dotées d’une fonction de mesure. Yoga l’appelle avec la largeur disponible, et Text.js renvoie les dimensions du texte en cellules :
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 };}Le texte qui tient dans la largeur disponible est renvoyé à sa taille naturelle. Le texte qui ne tient pas est réparti sur plusieurs lignes selon la largeur disponible, et sa hauteur correspond au nombre de lignes. Le nœud conserve sa dernière répartition en lignes, ce qui évite de la recalculer lorsque la même largeur est mesurée à nouveau.
Mesure des groupes de graphèmes
La propriété length d’une chaîne compte les unités de code UTF-16, ce qui renseigne peu sur le nombre de cellules qu’elle occupe. Le texte est d’abord découpé en groupes de graphèmes avec Intl.Segmenter : e suivi d’un accent combinatoire forme ainsi un seul groupe, tout comme une séquence d’emojis reliés par des liants sans chasse. Une largeur est ensuite attribuée à chaque groupe :
| Texte | Cellules | Raison |
|---|---|---|
abc | 3 | Une cellule par caractère |
你好 | 4 | Deux cellules pour les caractères larges d’Asie de l’Est |
🚀 | 2 | Deux cellules pour la présentation emoji |
é (e + U+0301) | 1 | Aucune cellule pour les marques combinatoires |
─│┌▼←⠋ | 6 | Une cellule fixe pour les caractères de tracé de boîtes, les formes géométriques, les flèches et le braille |
Les bordures, le symbole ▼, les flèches et les frames des indicateurs de chargement sont toujours mesurés comme une cellule, de sorte qu’une bordure de boîte ne décale jamais son contenu.
Les caractères ASCII imprimables ne passent pas par la segmentation, et les autres largeurs sont mises en cache par point de code et par groupe.
Retour à la ligne
<Text> accepte un mode wrap :
wrap(par défaut) coupe aux espaces, et à l’intérieur d’un mot lorsque celui-ci est plus large que la ligne.nowrapconserve une seule ligne et laisse la boîte rogner ce qui dépasse.truncate-end,truncate-middleettruncate-startconservent une seule ligne et remplacent ce qui ne tient pas par….
La troncature compte elle aussi les cellules : une ligne de texte chinois est donc coupée à la limite d’un caractère, et les points de suspension occupent exactement une cellule.
En pratique
Ici, une barre latérale de 30 colonnes se trouve à côté d’un panneau qui occupe la largeur restante :
<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>Dans une console de 100 colonnes, la barre latérale occupe 30 colonnes, bordure comprise, et le panneau de conversation occupe les 70 autres. Une bordure occupe une cellule de chaque côté, le titre de la session est donc tronqué à 28 cellules.