Elementos
TextView
TextView exibe um documento escrito em Markdown, em HTML ou como blocos que o app monta: títulos, parágrafos, ênfase, links, listas e listas de tarefas, citações e alertas do GitHub, código, tabelas, imagens, fórmulas, notas de rodapé e disclosures. Ele foi feito para as respostas de modelos de IA em um chat: a resposta chega em streaming como Markdown, e cada trecho mostra o texto com a formatação já aplicada. O parsing acontece em Rust, e o documento é desenhado com elementos Text e views.
import { cleanup, mount, signal, Scroll, Text, View } from "nsh";
import { TextView } from "nsh/base";
import { button, buttonText, desktop, ui } from "./palette";
const REPLY = `## Moving to offline sync
Sync now runs **in the background**, and conflicts merge *per field*:
1. Export with \`nsh export\`
2. Check the dry run
3. Switch over
| Step | Time |
|--------|------:|
| Export | 2 min |
| Check | 5 min |
\`\`\`sh
nsh export --out backup.json
\`\`\`
> [!TIP]
> ${desktop ? "Drag across the paragraphs to select them, then copy." : "Long-press a paragraph to copy it."}`;
function App() {
const text = signal(REPLY);
const streaming = signal(false);
let timer: number | undefined;
const stop = () => {
if (timer !== undefined) clearInterval(timer);
timer = undefined;
streaming.set(false);
};
cleanup(stop);
// Replays the reply a few characters at a time, as a model streams it.
const play = () => {
stop();
const tokens = REPLY.match(/\s*\S{1,4}|\s+/g) ?? [];
let i = 0;
text.set("");
streaming.set(true);
timer = setInterval(() => (i < tokens.length ? text.set(text() + tokens[i++]) : stop()), 40);
};
return (
<Scroll style={{ flex: 1, backgroundColor: ui().page }}>
<View style={{ width: "100%", maxWidth: 680, alignSelf: "center", padding: 20, gap: 16 }}>
<TextView
markdown={text()}
options={{ streaming: streaming() }}
streamingCaret={streaming() ? <View style={{ width: 8, height: 16, marginLeft: 2, borderRadius: 2, backgroundColor: ui().accent }} /> : undefined}
selectable
/>
<View
onPress={play}
disabled={streaming()}
{...button()}
>
<Text style={buttonText()}>{streaming() ? "Streaming…" : "Stream it again"}</Text>
</View>
</View>
</Scroll>
);
}
mount(() => <App />);Ele tem comportamento próprio e traz uma tipografia de leitura própria: texto de 15pt em linhas generosas de 25,5pt, títulos em semibold com espaço acima, links sublinhados na cor primary do tema, código em font-mono do tema numa caixa com borda e cantos arredondados, tabelas com linhas em todos os lados e uma linha de cabeçalho tingida, citações com uma linha ao lado e alertas como caixas tingidas com um ícone nas cores de alerta do GitHub. Suas cores são os tokens do tema (foreground, muted-foreground, border, muted, primary e destructive para um alerta de cautela), então um tema e a aparência escura chegam até ele. Cada parte recebe os estilos do app sobre esses padrões: classes e styles em TSX, estilos tipados por parte (TextViewParts) em Rust. Em Rust, cria-se um com nsh_base::text_view(); em TSX, com <TextView> de nsh/base.
use nsh_base::{TextViewLinkPressEvent, text_view, theme};
use nsh_core::prelude::*;
const README: &str = "# Release notes
**Faster lists** and a new `TextView`:
- Markdown and HTML
- [x] Streaming replies
- [ ] Your own syntax
> [!TIP]
> Style every part with `TextViewParts`.";
fn release_notes() -> impl IntoElement {
scroll().flex_1().child(
text_view()
.markdown(README)
.on_link_press(|TextViewLinkPressEvent { url, .. }: &TextViewLinkPressEvent| log::info!("open {url}"))
.p_5()
.text_base()
.text_color(theme().foreground),
)
}import { mount, Scroll } from "nsh";
import { TextView } from "nsh/base";
const README = `# Release notes
**Faster lists** and a new \`TextView\`:
- Markdown and HTML
- [x] Streaming replies
- [ ] Your own syntax
> [!TIP]
> Style every part with \`classes\`.`;
function App() {
return (
<Scroll class="flex-1" contentClass="p-5">
<TextView
markdown={README}
class="text-base text-neutral-900 dark:text-neutral-100"
classes={{
heading1: "text-3xl",
code: "rounded bg-neutral-100 dark:bg-neutral-800",
link: "text-blue-600",
alertTip: "border-l-4 border-green-600 pl-3",
}}
onLinkPress={({ url }) => console.log("open", url)}
/>
</Scroll>
);
}
mount(App);Em Rust, text_view() cria um assim como view() cria uma view: um setter por prop (.markdown(..), .options(..), .selectable(..)), um closure por função de renderização (.render_image(|image| ..)), um listener por evento (.on_link_press(..)) e os métodos de estilo tipados para a raiz (.p_5(), .text_color(..)), cujos estilos de texto passam para todas as partes.
#Construído sobre Text
TextView é feito de elementos Text, e seu texto se comporta exatamente como a página de Text descreve. Cada parágrafo, título, célula de tabela e bloco de código é um Text nativo: um bloco que faz layout, quebra linhas, acompanha a escala da fonte e, com selectable, seleciona e copia. Strong, emphasis, tachado e as demais marcas inline são seus spans; o código inline é um span com fundo de span, de modo que code: "rounded bg-neutral-100" lhe dá um fundo arredondado; os links são spans pressionáveis; imagens e nós personalizados inline são anexos inline. Os estilos de texto na raiz passam para todas as partes, assim como os estilos de texto passam de um Text para seus spans.
Com selectable, o documento inteiro é selecionado como um só, como uma página web: um arrasto (ou, em telas de toque, um toque longo e as alças) vai de um parágrafo, item de lista, célula ou bloco de código ao seguinte, e Copiar leva o texto de todos eles. Um selectable TextView seleciona como um só por conta própria; dentro de uma view selecionável, sua seleção também continua pelos textos e outras TextViews ao redor.
O que TextView acrescenta é o resto: ler Markdown, HTML ou um documento em blocos, as views ao redor dos textos (listas, citações, tabelas, blocos de código, disclosures, notas de rodapé), streaming, estilos por parte, funções de renderização e extensões.
#O documento
O conteúdo vem de uma entre três props, lidas nesta ordem:
markdown: CommonMark com as adições do GitHub (tabelas, listas de tarefas, tachado, autolinks, notas de rodapé, alertas), fórmulas ($…$e$$…$$) e HTML dentro do Markdown.html: HTML como um leitor o mostra. Texto, estrutura e formatação inline passam; scripts, estilos, controles de formulário e elementos ocultos ficam de fora. Um fragmento ou uma página inteira funcionam (uma página mostra seu body).document: blocos que o app monta ou mantém: objetos simples em TSX,nsh_base::text_view::Documentem Rust (O modelo de documento).
use nsh_base::text_view;
use nsh_base::text_view::{Block, Document, Inline};
use nsh_core::prelude::*;
fn shipping() -> impl IntoElement {
text_view().html(r#"<h2>Shipping</h2><p>Orders ship in <b>2 days</b>. <a href="/faq">FAQ</a></p>"#)
}
fn total() -> impl IntoElement {
text_view().document(Document::new(vec![
Block::Heading { level: 2, content: vec![Inline::text("Total")], anchor: None },
Block::paragraph(vec![Inline::Strong { content: vec![Inline::text("$42.00")] }]),
]))
}<TextView html={`<h2>Shipping</h2><p>Orders ship in <b>2 days</b>. <a href="/faq">FAQ</a></p>`} />
<TextView document={{
blocks: [
{ type: "heading", level: 2, content: [{ type: "text", text: "Total" }] },
{ type: "paragraph", content: [{ type: "strong", content: [{ type: "text", text: "$42.00" }] }] },
],
}} />TextView cresce com o seu conteúdo. Coloque-o em um Scroll (ou em uma linha de um List) para documentos longos.
#Estilizando as partes
class e style estilizam a raiz, uma coluna dos blocos; os estilos de texto definidos ali (cor, tamanho, fonte) passam para todas as partes. A raiz também é a parte root: styles.root recebe estilos de texto com seus tipos (style é tipado como o de uma view) e classes.root classes. classes recebe classes do Tailwind por parte e styles recebe estilos inline por parte. As classes de uma parte vêm depois dos estilos padrão dela, então prevalecem sobre eles; seus styles prevalecem sobre as classes. Em Rust, as partes recebem estilos tipados: TextViewParts dá a cada TextViewPart uma função que refina seu estilo, para todo TextView abaixo da view que o fornece (Estilos tipados por parte), e a raiz recebe os métodos de estilo. As alturas de linha nos padrões são definidas em pontos: uma classe que muda o tamanho de uma parte também define sua altura de linha (as classes text-* do Tailwind fazem isso).
use nsh_base::{TextViewPart, TextViewParts, text_view, theme};
use nsh_core::prelude::*;
fn reply(markdown: Signal<SharedString>) -> impl IntoElement {
provide_context(
TextViewParts::new()
.part(TextViewPart::Heading, |s| s.text_color(rgb(0x0a0a0a)))
.part(TextViewPart::Heading2, |s| s.text_xl().mt_2())
.part(TextViewPart::Blockquote, |s| {
s.border_l(px(2.0)).border_color(rgb(0xd4d4d4)).pl_3().text_color(rgb(0x525252))
})
.part(TextViewPart::CodeBlock, |s| s.rounded_lg().bg(rgb(0xf5f5f5)))
.part(TextViewPart::CodeBlockHeader, |s| s.px_3().pt_2())
.part(TextViewPart::CodeBlockCode, |s| s.p_3().text_size(px(13.0)))
.part(TextViewPart::Table, |s| s.rounded_lg().border_1().border_color(rgb(0xe5e5e5)))
.part(TextViewPart::TableHead, |s| s.bg(rgb(0xfafafa)))
.part(TextViewPart::TableCell, |s| {
s.px_3().py_2().border_t_1().border_color(rgb(0xe5e5e5))
})
.part(TextViewPart::TaskMarkerChecked, |s| s.text_color(rgb(0x16a34a)))
.part(TextViewPart::Link, |s| s.text_color(theme().primary)),
);
text_view()
.markdown(move || markdown.get())
.gap_3()
.text_size(px(15.0))
.line_height(px(24.0))
.text_color(rgb(0x262626))
.font_family("Inter")
}<TextView
markdown={reply()}
class="gap-3 text-[15px] leading-6 text-neutral-800"
classes={{
heading: "text-neutral-950",
heading2: "text-xl mt-2",
blockquote: "border-l-2 border-neutral-300 pl-3 text-neutral-600",
codeBlock: "rounded-lg bg-neutral-100",
codeBlockHeader: "px-3 pt-2",
codeBlockCode: "p-3 text-[13px]",
table: "rounded-lg border border-neutral-200",
tableHead: "bg-neutral-50",
tableCell: "px-3 py-2 border-t border-neutral-200",
taskMarkerChecked: "text-green-600",
}}
styles={{ root: { fontFamily: "Inter" }, link: { color: "#2563eb" } }}
/>Alguns nós são várias partes, aplicadas em ordem: um título de nível 2 é heading e depois heading2; um alerta é blockquote, alert e depois o seu tipo (alertNote, …); um marcador de tarefa é listMarker, taskMarker e depois taskMarkerChecked quando concluída; a linha de cabeçalho é tableRow e depois tableHead, e suas células tableCell e depois tableHeaderCell.
| Parte | Rust | O que é |
|---|---|---|
root |
Root |
A própria view de texto, uma coluna de seus blocos; os estilos de texto aqui passam para todas as partes. |
paragraph |
Paragraph |
Todo parágrafo. |
heading |
Heading |
Todo título, antes da parte do seu nível. |
heading1 … heading6 |
Heading1 … Heading6 |
Títulos desse nível (27, 20, 17 e depois 15pt, semibold). |
blockquote |
Blockquote |
Citações e alertas: uma coluna de seus blocos com uma linha ao lado. |
alert |
Alert |
Todo alerta (> [!NOTE]), depois de blockquote: uma caixa tingida, uma linha com seu ícone e seus blocos. |
alertNote, alertTip, alertImportant, alertWarning, alertCaution |
AlertNote, AlertTip, AlertImportant, AlertWarning, AlertCaution |
Alertas desse tipo. |
alertIcon |
AlertIcon |
O ícone de um alerta: um anel com um glifo na cor do alerta. |
list |
List |
Toda lista: uma coluna de seus itens. |
orderedList, unorderedList |
OrderedList, UnorderedList |
Listas numeradas e com marcadores, depois de list. |
listItem |
ListItem |
Todo item: uma linha com seu marcador e seus blocos. |
listMarker |
ListMarker |
O marcador de um item (•, ◦, ▪ conforme a profundidade, ou 1.), alinhado à direita no recuo antes do item. |
taskMarker |
TaskMarker |
O marcador de um item de tarefa: uma caixa desenhada como uma view, preenchida com um ✓ quando concluída; depois de listMarker. |
taskMarkerChecked |
TaskMarkerChecked |
O marcador de um item de tarefa concluído, depois de taskMarker. |
listItemContent |
ListItemContent |
A coluna dos blocos de um item. |
codeBlock |
CodeBlock |
Blocos de código: uma caixa com borda e cantos arredondados para o código, com o cabeçalho sobre ela. |
codeBlockHeader |
CodeBlockHeader |
A linha das ações de um bloco de código (codeBlockActions), flutuando sobre o canto superior direito. |
codeBlockScroll |
CodeBlockScroll |
A scroll view horizontal ao redor do código de um bloco de código. |
codeBlockCode |
CodeBlockCode |
O código de um bloco de código (monoespaçado, como foi escrito). |
code |
Code |
Código em texto (monoespaçado, sobre um fundo arredondado). |
keyboard |
Keyboard |
Teclas (<kbd>, como código). |
strong |
Strong |
Texto em destaque forte (semibold). |
emphasis |
Emphasis |
Texto enfatizado (itálico). |
strikethrough |
Strikethrough |
Texto tachado. |
underline |
Underline |
Texto sublinhado (<u>, <ins>). |
highlight |
Highlight |
Texto realçado (<mark>, sobre um amarelo arredondado). |
superscript, subscript |
Superscript, Subscript |
Sobrescrito e subscrito (menores). |
link |
Link |
Links (na cor primary do tema, sublinhados). |
image |
Image |
Imagens. |
math |
Math |
Fórmulas em texto, tipografadas quando não há renderMath. |
mathBlock |
MathBlock |
Blocos de fórmula, tipografados e centralizados quando não há renderMath. |
table |
Table |
Tabelas: o conteúdo de renderTableActions e depois a scroll view das células. |
tableScroll |
TableScroll |
A scroll view horizontal das células de uma tabela: as colunas se ajustam ao conteúdo quando há espaço, quebram linha até um mínimo quando a view é mais estreita e, além disso, a tabela rola. |
tableRow |
TableRow |
Toda linha de uma tabela: uma view atrás de suas células, para fundos e bordas (os estilos de texto vão em tableCell). |
tableHead |
TableHead |
A linha de cabeçalho, depois de tableRow. |
tableCell |
TableCell |
Toda célula. |
tableHeaderCell |
TableHeaderCell |
As células da linha de cabeçalho (menores, esmaecidas), depois de tableCell. |
rule |
Rule |
Quebras temáticas: uma linha desenhada como uma borda superior. |
details |
Details |
Disclosures (<details>). |
summary |
Summary |
O resumo de um disclosure; um toque o abre e o fecha. |
summaryMarker |
SummaryMarker |
O marcador de um resumo (▸, ▾). |
footnotes |
Footnotes |
As notas de rodapé depois dos blocos: uma coluna. |
footnote |
Footnote |
Uma nota de rodapé: uma linha com seu número e seus blocos. |
footnoteLabel |
FootnoteLabel |
O número de uma nota de rodapé. |
footnoteReference |
FootnoteReference |
Uma referência a uma nota de rodapé no texto (seu número, menor). |
custom |
Custom |
Nós personalizados mostrados como seu conteúdo (quando nenhuma função de renderização os mostra). |
#Respostas de IA em streaming
Passe a resposta conforme ela cresce, com options={{ streaming: true }} (MarkdownOptions { streaming: true, .. } em Rust). O fim de um texto que ainda está chegando muitas vezes deixa a sintaxe aberta (**bold, `code, [um link](htt, a table whose header just arrived); o streaming a mostra como ficará depois de fechada, de modo que o leitor nunca vê ** ou [ soltos. Os blocos são mantidos por posição: os blocos completos mantêm suas views, e só o bloco que está sendo escrito é refeito, então uma resposta longa continua barata de atualizar.
streamingCaret acompanha o fim do documento: inline no fim do último texto, onde quer que o último bloco termine (um parágrafo, um item de lista, um bloco de código, a última célula de uma tabela), ou então em uma linha própria depois do último bloco (uma linha horizontal, o conteúdo de uma função de renderização). Remova-o quando a resposta terminar.
use nsh_base::{MarkdownOptions, text_view};
use nsh_core::prelude::*;
/// A reply the app appends to as chunks arrive, with `done` set at the end.
fn reply(text: Signal<SharedString>, done: Signal<bool>) -> impl IntoElement {
text_view()
.markdown(move || text.get())
.options(move || MarkdownOptions { streaming: !done.get(), ..Default::default() })
.streaming_caret(when(
move || !done.get(),
|| view().ml_0p5().h_4().w_2().rounded_sm().bg(rgb(0xa3a3a3)),
))
}import { signal, View } from "nsh";
import { TextView } from "nsh/base";
function Reply(props: { stream: AsyncIterable<string> }) {
const text = signal("");
const done = signal(false);
(async () => {
for await (const chunk of props.stream) text.update((t) => t + chunk);
done.set(true);
})();
return (
<TextView
markdown={text()}
options={{ streaming: !done() }}
streamingCaret={done() ? undefined : <View class="ml-0.5 h-4 w-2 rounded-sm bg-neutral-400" />}
/>
);
}O cursor é uma view do próprio app, então pode piscar ou pulsar com a API de animação de nsh, e novos blocos podem surgir com fade usando as animações do app.
#Links
Os links são spans pressionáveis. Um toque em um deles chama onLinkPress com { url }; o app decide o que abrir (uma página própria, um WebView, uma rolagem até um título). Os títulos têm âncoras (as do GitHub: ## Getting started vira #getting-started), então um link para #getting-started chega como essa string, e uma referência a nota de rodapé chega como #footnote-<label>.
use nsh_base::{TextViewLinkPressEvent, text_view};
use nsh_core::prelude::*;
fn doc(markdown: &'static str) -> impl IntoElement {
text_view().markdown(markdown).on_link_press(|TextViewLinkPressEvent { url, .. }: &TextViewLinkPressEvent| {
match url.strip_prefix('#') {
Some(anchor) => scroll_to_anchor(anchor),
None => open_web(url),
}
})
}<TextView
markdown={doc}
onLinkPress={({ url }) => {
if (url.startsWith("#")) scrollToAnchor(url.slice(1));
else nav.push({ name: "web", url });
}}
/>scroll_to_anchor e open_web (scrollToAnchor e nav em TSX) representam as funções do próprio app.
Com links="open", um toque em um link para outro site ou app (uma URL com esquema: https:, mailto:, …) também o abre fora do app com openUrl, sob a capability openUrl do app, depois de onLinkPress tê-lo reportado; as âncoras no documento (#…) são apenas reportadas. O padrão, "report", não abre nada.
use nsh_base::{TextViewLinks, text_view};
text_view().markdown(doc).links(TextViewLinks::Open)<TextView markdown={doc} links="open" />#Funções de renderização
As funções de renderização colocam o conteúdo do próprio app no documento. Cada uma recebe o valor de uma parte e devolve seu conteúdo (JSX em TSX, um elemento em Rust); uma parte mantém o que foi renderizado para ela enquanto seu valor permanece o mesmo, então, durante o streaming, apenas a parte que está sendo escrita é renderizada de novo.
| Prop | Rust | Valor | Substitui ou adiciona |
|---|---|---|---|
renderCodeBlock |
.render_code_block(|code| ..) |
TextViewCodeBlock (CodeBlock) |
Substitui um bloco de código (realce de sintaxe próprio). |
renderCodeBlockAction |
.render_code_block_action(|action| ..) |
TextViewCodeAction |
O ícone de uma ação de bloco de código do app (codeBlockActions); o botão ao redor é de TextView. |
renderTableActions |
.render_table_actions(|table| ..) |
TextViewTable (Table) |
Adiciona conteúdo acima de uma tabela (copiar, exportar). |
renderMath |
.render_math(|math| ..) |
TextViewMath |
Substitui uma fórmula: { tex, display }, display para um bloco $$…$$. |
renderImage |
.render_image(|image| ..) |
TextViewImage (Image) |
Substitui uma imagem ({ src, alt, title }): carregamento e dimensionamento próprios, um visualizador ao tocar. |
renderCustom |
.render_custom(|node| ..) |
TextViewCustomNode (CustomNode) |
Mostra um nó personalizado, pelo seu name (Extensões de Markdown). |
Os blocos de código trazem ações sobre o canto superior direito, todas desenhadas do mesmo modo: botões ghost cujo fundo aparece ao passar o ponteiro ou ao pressionar, exibidos enquanto um ponteiro está sobre o bloco em desktops e sempre em telas de toque. copy já vem embutida: ela copia o código e mostra um check por um instante. As outras ações são do app: renderCodeBlockAction desenha o ícone delas e onCodeBlockAction reporta seus toques (inclusive o de copy, que é embutida, depois de copiar). copy escreve na área de transferência como Clipboard faz, então precisa da capability clipboard do app (nsh.json).
use nsh_base::{TextViewCodeAction, TextViewCodeActionEvent, text_view};
use nsh_core::prelude::*;
fn reply(markdown: Signal<SharedString>) -> impl IntoElement {
text_view()
.markdown(move || markdown.get())
.code_block_actions(vec![
TextViewCodeAction::new("copy").with_label("Copy code"),
TextViewCodeAction::new("run").with_label("Run"),
])
.render_code_block_action(|_action: &TextViewCodeAction| text("▶").text_xs())
.on_code_block_action(|event: &TextViewCodeActionEvent| {
if event.id == "run" {
run(&event.code.code);
}
})
}import { Text } from "nsh";
import { TextView } from "nsh/base";
<TextView
markdown={reply()}
codeBlockActions={[
{ id: "copy", label: "Copy code" },
{ id: "run", label: "Run" },
]}
renderCodeBlockAction={(action) => <Text class="text-xs">▶</Text>}
onCodeBlockAction={({ id, code }) => id === "run" && run(code.code)}
/>Os blocos de código já vêm com realce: para uma linguagem nomeada na cerca (ts, tsx, rust, python, bash, json, toml, yaml, diff e cerca de duzentas outras), o código aparece na paleta de código do Allsum, clara ou escura conforme a aparência; um bloco sem linguagem aparece sem realce. Um app com realce próprio mostra seu código com renderCodeBlock, a partir de um componente próprio ou de um do seu crate Rust; uma view Rust dá a TextView seu realçador com CodeHighlighter. Aqui, código com realce próprio do app e uma imagem que preenche a largura:
use nsh_base::text_view;
use nsh_base::text_view::{CodeBlock, Image};
use nsh_base::TextViewMath;
use nsh_core::prelude::*;
fn reply(markdown: Signal<SharedString>) -> impl IntoElement {
text_view()
.markdown(move || markdown.get())
.render_code_block(|code: &CodeBlock| highlighted(code.language.clone(), code.code.clone()))
.render_image(|img: &Image| {
image(img.src.clone())
.label(img.alt.clone())
.aspect_ratio(16.0 / 9.0)
.w_full()
.rounded_lg()
})
.render_math(|math: &TextViewMath| math_view(math.tex.to_string(), math.display))
}import { Image, View } from "nsh";
<TextView
markdown={reply()}
renderCodeBlock={(code) => <Highlighted language={code.language} code={code.code} />}
renderImage={(image) => (
<Image src={image.src} accessibilityLabel={image.alt} class="aspect-video w-full rounded-lg" />
)}
renderMath={(math) => <MathView tex={math.tex} display={math.display} />}
/>Highlighted e MathView (highlighted e math_view em Rust) representam os componentes do próprio app (um realçador, um renderizador de fórmulas ou um componente do seu crate Rust). Sem renderMath, TextView tipografa uma fórmula por conta própria (as partes math e mathBlock) com o suporte a TeX e as fontes do KaTeX, na cor do texto, igual em todas as plataformas; o TeX que ele não consegue ler aparece como seu código-fonte em monoespaçado.
#Extensões de Markdown
extensions transforma a sintaxe do app em nós personalizados, que renderCustom exibe. Cada extensão tem um name
(o name dos nós que ela cria) e um de quatro tipos de sintaxe:
| Tipo | Rust | Corresponde a | O nó |
|---|---|---|---|
pattern |
MarkdownExtension::pattern(name, regex) |
Uma expressão regular no texto (sintaxe regex do Rust), fora de código e links |
Inline; text é a correspondência, attributes seus grupos: "1", "2", … e os grupos nomeados pelo nome |
language |
MarkdownExtension::language(name, language) |
Blocos de código delimitados dessa linguagem (```chart) |
A block; text is the code, attributes.meta o restante da string de informações |
tag |
MarkdownExtension::tag(name, tag) |
Elementos HTML ou MDX dessa tag, em qualquer caixa (<Callout kind="tip">) |
Seus atributos e seu conteúdo interpretado: children (blocos) para um elemento em linhas próprias, content (inline) dentro do texto |
container |
MarkdownExtension::container(name, container) |
Contêineres :::name … ::: |
Um bloco com o Markdown interno (children); :::note[Heads up] fornece attributes.label e {key=value} os demais atributos |
use nsh_base::text_view::{CustomNode, Document};
use nsh_base::{MarkdownExtension, text_view};
use nsh_core::prelude::*;
const MESSAGE: &str = r#"Thanks @ada! Here are the numbers:
```chart
12, 19, 7, 25
```
<Callout kind="tip">Charts update live.</Callout>
:::note[Heads up]
Numbers are in thousands.
:::"#;
fn custom(node: &CustomNode) -> AnyElement {
match node.name.as_str() {
"mention" => text(node.text.clone())
.font_weight(FontWeight::SEMIBOLD)
.text_color(rgb(0x2563eb))
.into_element(),
"chart" => bar_chart(node.text.split(',').filter_map(|v| v.trim().parse().ok()).collect())
.into_element(),
"callout" => view()
.rounded_lg()
.bg(rgb(0xfffbeb))
.p_3()
.child(text_view().document(Document::new(node.children.clone())))
.into_element(),
_ => view()
.gap_1()
.border_l(px(4.0))
.border_color(rgb(0x3b82f6))
.pl_3()
.child(
text(node.attributes.get("label").cloned().unwrap_or_else(|| "Note".into()))
.font_weight(FontWeight::SEMIBOLD),
)
.child(text_view().document(Document::new(node.children.clone())))
.into_element(),
}
}
fn message() -> impl IntoElement {
text_view()
.markdown(MESSAGE)
.extensions(vec![
MarkdownExtension::pattern("mention", r"@(\w+)"),
MarkdownExtension::language("chart", "chart"),
MarkdownExtension::tag("callout", "callout"),
MarkdownExtension::container("note", "note"),
])
.render_custom(custom)
}import { Text, View } from "nsh";
import { TextView } from "nsh/base";
import type { MarkdownExtension, TextViewCustomNode } from "nsh/base";
const extensions: MarkdownExtension[] = [
{ name: "mention", pattern: "@(\\w+)" },
{ name: "chart", language: "chart" },
{ name: "callout", tag: "callout" },
{ name: "note", container: "note" },
];
const message = `Thanks @ada! Here are the numbers:
\`\`\`chart
12, 19, 7, 25
\`\`\`
<Callout kind="tip">Charts update live.</Callout>
:::note[Heads up]
Numbers are in thousands.
:::`;
function Custom(props: { node: TextViewCustomNode }) {
const node = props.node;
switch (node.name) {
case "mention":
return <Text class="font-semibold text-blue-600">{node.text}</Text>;
case "chart":
return <BarChart values={node.text!.split(",").map(Number)} />;
case "callout":
return (
<View class="rounded-lg bg-amber-50 p-3">
<TextView document={{ blocks: node.children ?? [] }} />
</View>
);
default:
return (
<View class="gap-1 border-l-4 border-blue-500 pl-3">
<Text class="font-semibold">{node.attributes?.label ?? "Note"}</Text>
<TextView document={{ blocks: node.children ?? [] }} />
</View>
);
}
}
<TextView markdown={message} extensions={extensions} renderCustom={(node) => <Custom node={node} />} />;BarChart (bar_chart em Rust) representa o gráfico próprio do app. O grupo do padrão de menção está em
node.attributes?.["1"] (node.attributes["1"] em Rust: ada) e a correspondência inteira em node.text (@ada). Um nó personalizado sem função de renderização exibe seu conteúdo.
Uma view retornada para um nó inline (um chip) se posiciona na linha por meio do próprio verticalAlign: align-middle
(verticalAlign: "middle") a centraliza em relação ao texto, e a linha mantém o lineHeight do parágrafo, a menos que a view seja mais alta.
#Opções
options escolhe a sintaxe de Markdown lida além do CommonMark e do GitHub; em Rust é um MarkdownOptions com os
mesmos campos (MarkdownOptions { streaming: true, ..Default::default() }):
| Chave | Tipo | Padrão | Descrição |
|---|---|---|---|
math |
boolean |
true |
Fórmulas: $…$ no texto, blocos $$…$$. |
html |
boolean |
true |
O HTML dentro do Markdown é lido como a prop html o lê; false o exibe como texto. |
alerts |
boolean |
true |
Alertas do GitHub: > [!NOTE], [!TIP], [!IMPORTANT], [!WARNING], [!CAUTION]. |
frontmatter |
boolean |
false |
O front matter em YAML (--- … --- no início) vira um nó personalizado frontmatter cujo text é o YAML. |
mdx |
boolean |
false |
MDX: elementos JSX são nós personalizados nomeados pela tag, nós personalizados {expressions} expression; o HTML bruto passa então a ser lido como texto. |
streaming |
boolean |
false |
O texto ainda está chegando: a sintaxe que o final deixa em aberto é exibida como se estivesse fechada. |
#O modelo de documento
A prop document e os valores das funções de renderização são objetos simples, cada nó identificado por seu type. Os tipos
vêm de nsh/base: TextViewDocument, TextViewBlock, TextViewInline, TextViewCodeBlock, TextViewTable,
TextViewListItem, TextViewImage, TextViewFootnote, TextViewAlert e TextViewCustomNode. Em Rust são
os tipos de nsh_base::text_view (Document, Block, Inline, CodeBlock, Table, ListItem, Image,
Footnote, Alert, CustomNode): Block e Inline são enums cujas variantes são os tipos abaixo em PascalCase
(Block::Heading { level, content, anchor }, Inline::Strong { content }), com os mesmos campos em snake case.
Bloco type |
Campos |
|---|---|
paragraph |
content |
heading |
level (1–6), content, anchor? |
blockquote |
children, alert? ("note", "tip", "important", "warning", "caution") |
list |
ordered?, start?, loose?, items ({ checked?, children }) |
codeBlock |
language?, meta?, code |
math |
tex |
table |
align? ("none", "left", "center", "right" por coluna), head (células), rows? (linhas de células); uma célula é { content } |
rule |
|
details |
open?, summary (inline), children |
custom |
name, attributes?, text?, data?, children?, content? |
Inline type |
Campos |
|---|---|
text |
text |
strong, emphasis, strikethrough, underline, highlight, superscript, subscript |
content |
code |
code |
keyboard |
text |
link |
url, title?, content |
image |
src, alt?, title? |
math |
tex |
break |
|
footnoteReference |
label, index |
custom |
como o bloco |
Um documento também tem footnotes?: { label, index, children }, exibidas após os blocos na ordem da primeira
referência. Um app pode armazenar respostas como documentos, construí-las a partir de seus próprios dados ou misturar nós personalizados que
renderCustom exibe.
#Interpretação e texto simples
TextView também lê documentos sem exibi-los, de forma síncrona:
| Função | Rust | Retorna |
|---|---|---|
TextView.parse(markdown, options?, extensions?) |
TextView::parse(markdown, options, extensions) |
O TextViewDocument que a prop markdown exibiria. |
TextView.parseHtml(html, extensions?) |
TextView::parse_html(html, extensions) |
O TextViewDocument que a prop html exibiria. |
TextView.plainText(document) |
TextView::plain_text(document), Document::plain_text() |
O texto do documento sem marcação: blocos separados por linhas em branco, itens de lista e linhas de tabela por quebras de linha, células por tabulações. |
Um botão de copiar para uma resposta inteira:
use nsh_base::{TextView, button};
use nsh_core::prelude::*;
fn copy_reply(reply: Signal<SharedString>) -> impl IntoElement {
button()
.on_press(move || {
let document = TextView::parse(reply.get().to_string(), None, None);
set_clipboard(TextView::plain_text(document));
})
.child(text("Copy"))
}<Button onPress={() => Clipboard.writeText(TextView.plainText(TextView.parse(reply())))}>
<Text>Copy</Text>
</Button>Um app também pode interpretar uma vez e guardar o documento (document={doc}), ou alterá-lo antes de exibi-lo.
#Em Rust
As views em Rust usam o mesmo componente do crate nsh-base, com os exemplos acima nas abas de Rust. Três coisas
são específicas do Rust: o realçador do app, os estilos tipados por parte e os plugins de Markdown, cada um fornecido a todo TextView abaixo
de uma view com provide_context. Os tipos de documento ficam em nsh_base::text_view, onde Parser também lê
documentos com plugins (Parser::new().plugin(..).markdown(source)).
#Realce de sintaxe
TextView realça o código com syntect na paleta de código do Allsum (feature highlight de nsh-base, ativada por padrão);
um bloco grande é exibido sem formatação de imediato e recebe suas cores quando uma thread em segundo plano o realça.
CodeHighlighter é o realçador do app em seu lugar, fornecido a todo TextView abaixo de uma view com provide_context.
Ele recebe cada bloco de código e retorna os estilos de intervalos de bytes do código (em ordem, sem sobreposição; o que ele deixa
de fora fica sem formatação); TextView exibe o código como spans com esses estilos, dentro do bloco de código habitual (cabeçalho, ações,
scroll view, partes). O realçador pode ser qualquer coisa que transforme código em intervalos estilizados: tree-sitter, syntect com
um tema do app, …
use std::ops::Range;
use nsh_base::{CodeHighlighter, text_view::CodeBlock};
use nsh_core::{element::StyleRefinement, prelude::*};
/// The app's highlighter (here with syntect, set up once by the app).
fn highlight(code: &CodeBlock) -> Vec<(Range<usize>, StyleRefinement)> {
my_syntect::ranges(code.language.as_deref(), &code.code)
.map(|(range, color, bold)| {
let style = StyleRefinement::default().text_color(color);
(range, if bold { style.font_weight(FontWeight::BOLD) } else { style })
})
.collect()
}
fn chat() -> impl IntoElement {
provide_context(CodeHighlighter::new(highlight));
view().child(messages())
}
my_syntect::ranges representa a ligação do app com o realçador e messages a view do chat no app.
renderCodeBlock, quando fornecido, exibe o bloco de código no lugar de tudo isso.
#Estilos tipados por parte
TextViewParts estiliza as partes com estilos tipados em vez de strings de classes, para views em Rust: cada TextViewPart (um por
parte da tabela acima) recebe uma função que refina seu StyleRefinement. Fornecidos com
provide_context, eles se aplicam a todo TextView abaixo, antes de seu classes e styles; as cores do tema lidas neles
acompanham a aparência clara e escura.
use nsh_base::{TextViewPart, TextViewParts, theme};
use nsh_core::prelude::*;
fn chat_parts() {
provide_context(
TextViewParts::new()
.part(TextViewPart::Heading1, |s| s.text_2xl().font_weight(FontWeight::BOLD))
.part(TextViewPart::Code, |s| s.bg(theme().muted).rounded(px(4.0)))
.part(TextViewPart::Link, |s| s.text_color(theme().primary)),
);
}
#Plugins de Markdown
Um MarkdownPlugin lê a sintaxe de Markdown em Rust: ele recebe cada nó da árvore de sintaxe (mdast do markdown-rs) antes
de o parser lê-lo e retorna um bloco ou conteúdo inline próprio, geralmente um CustomNode; ele também pode exibir os
nós personalizados que cria. Uma view fornece plugins a todo TextView abaixo dela:
use nsh_base::text_view::{mdast, CustomNode, Inline, MarkdownPlugin, MarkdownPlugins, ParseContext};
use nsh_core::prelude::*;
struct Tickers;
impl MarkdownPlugin for Tickers {
fn name(&self) -> &str {
"tickers"
}
/// `$AAPL` in inline code becomes a ticker node.
fn inline(&self, node: &mdast::Node, _cx: &mut ParseContext<'_>) -> Option<Inline> {
let mdast::Node::InlineCode(code) = node else { return None };
let symbol = code.value.strip_prefix('$')?;
Some(Inline::Custom(CustomNode::new("ticker").with_text(symbol)))
}
fn render(&self, node: &CustomNode) -> Option<AnyElement> {
(node.name == "ticker").then(|| text(node.text.clone()).font_weight(FontWeight::BOLD).into_element())
}
}
fn chat() -> impl IntoElement {
provide_context(MarkdownPlugins::new().with(Tickers));
view().child(messages())
}
O render de um plugin vem primeiro, depois renderCustom, depois o conteúdo do nó. configure ativa mais da
sintaxe do markdown-rs, e ParseContext lê os filhos de um nó como o parser faz.
#Props
| Prop | Rust | Tipo | Padrão | Descrição |
|---|---|---|---|---|
markdown |
.markdown(..) |
string |
O documento como Markdown. | |
html |
.html(..) |
string |
O documento como HTML, quando markdown não é fornecido. |
|
document |
.document(..) |
TextViewDocument |
O documento como blocos, quando nem markdown nem html são fornecidos. |
|
options |
.options(..) |
MarkdownOptions |
todos os padrões | A sintaxe de Markdown lida (Opções). |
extensions |
.extensions(..) |
MarkdownExtension[] |
[] |
A sintaxe do app, como nós personalizados (Extensões de Markdown). |
classes |
.classes(..) |
TextViewClasses |
{} |
Classes do Tailwind por parte. |
styles |
.styles(..) |
TextViewStyles |
{} |
Estilos inline por parte (cada um um StyleProp<TextStyle>), sobre as classes. |
selectable |
.selectable(..) |
boolean |
false |
O documento é selecionável como um só: uma seleção percorre seus parágrafos, títulos, células e blocos de código (Seleção de texto). |
links |
.links(..) |
"report" | "open" |
"report" |
O que um toque em um link faz: o reporta (onLinkPress) e, com "open", também abre fora do app um link para outro site ou app (Links). |
codeBlockScrollbar |
.code_block_scrollbar(..) |
boolean |
true |
Os blocos de código exibem o indicador de rolagem horizontal. |
tableScrollbar |
.table_scrollbar(..) |
boolean |
true |
As tabelas exibem o indicador de rolagem horizontal. |
streamingCaret |
.streaming_caret(..) |
JSX.Element |
Exibido no fim do documento: inline após o último texto; caso contrário, em linha própria. | |
renderCustom |
.render_custom(|node| ..) |
(node: TextViewCustomNode) => JSX.Element |
Exibe nós personalizados. | |
renderCodeBlock |
.render_code_block(|code| ..) |
(code: TextViewCodeBlock) => JSX.Element |
Exibe blocos de código. | |
codeBlockActions |
.code_block_actions(..) |
TextViewCodeAction[] |
[] |
As ações no canto superior direito de cada bloco de código: { id: "copy" } embutidas, as demais do app. |
renderCodeBlockAction |
.render_code_block_action(|action| ..) |
(action: TextViewCodeAction) => JSX.Element |
O ícone de uma ação do app. | |
renderTableActions |
.render_table_actions(|table| ..) |
(table: TextViewTable) => JSX.Element |
Conteúdo acima de cada tabela. | |
renderMath |
.render_math(|math| ..) |
(math: TextViewMath) => JSX.Element |
Exibe fórmulas. | |
renderImage |
.render_image(|image| ..) |
(image: TextViewImage) => JSX.Element |
Exibe imagens. | |
class, style |
métodos de estilo | string, StyleProp<ViewStyle> |
A raiz, uma coluna dos blocos; seus estilos de texto passam para todas as partes (estilos de texto tipados: styles.root). |
Em Rust, as classes e os estilos por parte são TextViewClasses e TextViewStyles; uma view em Rust geralmente estiliza as
partes com TextViewParts.
#Eventos
| Evento | Rust | Descrição |
|---|---|---|
onLinkPress |
.on_link_press(|event: &TextViewLinkPressEvent| ..) |
Um link foi pressionado: { url }: sua URL, #anchor para um título, #footnote-<label> para uma nota de rodapé. |
onCodeBlockAction |
.on_code_block_action(|event: &TextViewCodeActionEvent| ..) |
Uma ação de um bloco de código foi pressionada (depois de copy copiar). |
onImagePress |
.on_image_press(|image: &Image| ..) |
Uma imagem fora de um link foi pressionada: { image }, para o app exibi-la maior (um visualizador com zoom próprio). |
#Acessibilidade
Os leitores de tela leem o documento em ordem, texto por texto. TextView atribui à estrutura seus papéis: títulos são
cabeçalhos, links são links (o título deles é a dica), marcadores de tarefa são caixas de seleção com seu estado marcado, o
resumo de um disclosure é um botão que informa se está expandido, e imagens são lidas pelo texto alternativo.
#Relacionados
- Text: o elemento que cada texto de
TextViewé, com seus spans, links, anexos inline e seleção. - Seleção de texto: uma seleção abrangendo vários
TextViews e textos. nsh/base: os outros componentes base.- Estilização e Tailwind.