← Blog

Layout do console com Yoga

Text.js executa Yoga em WebAssembly com um fator de escala de pontos de 1, para que cada caixa ocupe células inteiras. O texto é segmentado com Intl.Segmenter e medido em células.

Adam

O Yoga, o motor de flexbox usado pelo React Native e compilado para WebAssembly, calcula o layout de cada quadro do Text.js. Caixas e texto compartilham uma única árvore do Yoga. O console é uma grade de células de caracteres, então toda posição e todo tamanho resultantes precisam ser um número inteiro de células.

Um ponto é uma célula

O Yoga arredonda seus resultados para uma grade de pixels definida pelo fator de escala de pontos. Com o fator em 2, como em uma tela Retina, as bordas podem cair em meios pontos. O Text.js carrega o Yoga uma vez e define o fator como 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;}

Com um fator de 1, o Yoga arredonda cada borda para uma unidade inteira, e o Text.js trata uma unidade como uma célula. O Yoga arredonda a posição absoluta de cada borda e deriva os tamanhos das bordas arredondadas, de modo que caixas vizinhas compartilhem uma borda. Divida uma linha de 81 colunas em duas caixas com width="50%" e uma ficará com 40 colunas, a outra com 41, sem espaço entre elas nem sobreposição.

O próprio Text.js aplica os padrões do CSS: flexDirection é row e flexShrink é 1, como em um navegador.

Medir texto

Os nós de texto são folhas do Yoga com uma função de medição. O Yoga a chama com a largura disponível, e o Text.js retorna o tamanho do texto em células:

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

O texto que cabe é retornado em seu tamanho natural. O texto que não cabe é quebrado na largura disponível, e sua altura é o número de linhas. O nó mantém sua última quebra de linhas, então medir a mesma largura novamente não repete a quebra do texto.

Medir agrupamentos de grafemas

O length de uma string conta unidades de código UTF-16, o que diz pouco sobre quantas células ela preenche. Primeiro, o texto é dividido em agrupamentos de grafemas com Intl.Segmenter, de modo que e seguido de um acento combinante seja um único agrupamento, assim como uma sequência de emojis unida por caracteres de junção de largura zero. Em seguida, cada agrupamento recebe uma largura:

TextoCélulasMotivo
abc3Uma célula por caractere
你好4Caracteres largos do leste asiático ocupam duas
🚀2A apresentação como emoji ocupa duas
é (e + U+0301)1Marcas combinantes não ocupam nenhuma
─│┌▼←⠋6Caracteres de desenho de caixas, formas geométricas, setas e braille têm largura fixada em uma

Bordas, a marca ▼, setas e quadros de indicadores de carregamento são sempre medidos como uma célula, então a borda de uma caixa nunca desalinha seu conteúdo.

O ASCII imprimível dispensa a segmentação, e as demais larguras são armazenadas em cache por ponto de código e por agrupamento.

Quebra de linhas

<Text> aceita um modo wrap:

  • wrap (o padrão) quebra nos espaços e dentro de uma palavra quando ela é mais larga que a linha.
  • nowrap mantém uma linha e deixa a caixa recortá-la.
  • truncate-end, truncate-middle e truncate-start mantêm uma linha e substituem o que não cabe por ….

O truncamento também conta células, então uma linha de texto em chinês é cortada no limite de um caractere, e as reticências ocupam exatamente uma célula.

Na prática

Aqui, uma barra lateral de 30 colunas fica ao lado de um painel que ocupa a largura 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>

Em um console de 100 colunas, a barra lateral ocupa 30 colunas incluindo sua borda, e o painel de conversa ocupa as outras 70. Uma borda ocupa uma célula de cada lado, então o título da sessão é truncado em 28 células.