Elements
TextView
TextView shows a document written in Markdown, in HTML or as blocks the app builds: headings, paragraphs, emphasis,
links, lists and task lists, quotes and GitHub’s alerts, code, tables, images, formulas, footnotes and disclosures. It
is made for the replies of AI models in a chat: the reply streams in as Markdown, and each chunk shows the text with its
formatting in place. Parsing happens in Rust, and the document is drawn with Text elements and views.
import { cleanup, mount, signal, Scroll, Text, View } from "nsh";
import { TextView } from "nsh/base";
import { desktop, size, 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}
activeStyle={{ opacity: 0.75 }}
style={
desktop
? { ...ui().raised, alignSelf: "flex-start", borderRadius: 8, height: size.control, paddingHorizontal: 12, justifyContent: "center", opacity: streaming() ? 0.5 : 1 }
: { ...ui().raised, borderRadius: 12, paddingVertical: 10, alignItems: "center", opacity: streaming() ? 0.5 : 1 }
}
>
<Text style={{ color: ui().text, fontSize: desktop ? 13 : 15, fontWeight: "600" }}>{streaming() ? "Streaming…" : "Stream it again"}</Text>
</View>
</View>
</Scroll>
);
}
mount(() => <App />);It owns behavior and comes with a reading typography of its own: 15pt text in generous 25.5pt lines, semibold
headings with room above them, links underlined in the theme’s primary color, code in the theme’s font-mono in a bordered, rounded box, tables ruled on every side with a tinted header row, quotes with a line at their side and
alerts as tinted boxes with an icon in GitHub’s alert colors. Its colors are the theme’s tokens (foreground, muted-foreground, border,
muted, primary, and destructive for a caution), so a theme and the dark appearance reach
it. Each part takes the app’s styles over these defaults: classes and styles in TSX, typed styles by part
(TextViewParts) in Rust. Rust makes one with nsh_base::text_view(), TSX with <TextView> from nsh/base.
use nsh_base::{TextViewLinkPressEvent, text_view, theme};
use nsh_core::ui::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);In Rust, text_view() makes one as view() makes a view: a setter per prop (.markdown(..), .options(..),
.selectable(..)), a closure per render function (.render_image(|image| ..)), a listener per event
(.on_link_press(..)) and the typed style methods for its root (.p_5(), .text_color(..)), whose text styles pass
to every part.
#Built on Text
TextView is made of Text elements, and its text behaves exactly as the Text page describes. Every
paragraph, heading, table cell and code block is one native Text: one block that lays out, wraps, follows the font
scale and, with selectable, selects and copies. Strong, emphasis, struck-through and the other inline marks
are its spans; inline code is a span with a span background, so
code: "rounded bg-neutral-100" gives it a rounded background; links are
pressable spans; images and inline custom nodes are
inline attachments. Text styles on the root pass to every part, as
text styles pass from a Text to its spans.
With selectable, the whole document selects as one, as a web page does: a drag (or, on touch screens, a long press
and the handles) runs from one paragraph, list item, cell or code block to the next, and Copy takes the text of them
all. A selectable TextView is a SelectionArea of its own; inside a
SelectionArea, its selection also runs on into the texts and other TextViews around it.
What TextView adds is the rest: reading Markdown, HTML or a document into blocks, the views around the texts (lists,
quotes, tables, code blocks, disclosures, footnotes), streaming, styles by part, render functions and extensions.
#The document
The content is one of three props, read in this order:
markdown: CommonMark with GitHub’s additions (tables, task lists, strikethrough, autolinks, footnotes, alerts), formulas ($…$and$$…$$) and HTML inside the Markdown.html: HTML as a reader shows it. Text, structure and inline formatting come through; scripts, styles, form controls and hidden elements stay out. A fragment or a whole page both work (a page shows its body).document: blocks the app builds or keeps: plain objects in TSX,nsh_base::text_view::Documentin Rust (The document model).
use nsh_base::text_view;
use nsh_base::text_view::{Block, Document, Inline};
use nsh_core::ui::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 grows with its content. Put it in a Scroll (or a row of a List) for long
documents.
#Styling the parts
class and style style the root, a column of the blocks; text styles set there (color, size, font) pass to every
part. The root is also the part root: styles.root takes text styles with their types (style is typed as a
view’s), and classes.root classes. classes takes Tailwind classes by part and styles inline styles by part. A part’s classes come after its own
default styles, so they win; its styles win over its classes. In Rust, the parts take typed styles: TextViewParts
gives each TextViewPart a function refining its style, for every TextView below the view that provides it
(Typed part styles), and the root takes the style methods. Line heights in the defaults are set in points: a
class that changes a part’s size sets its line height too (Tailwind’s text-* classes do).
use nsh_base::{TextViewPart, TextViewParts, text_view, theme};
use nsh_core::ui::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" } }}
/>Some nodes are several parts, which apply in order: a level 2 heading is heading then heading2; an alert is
blockquote, alert, then its kind (alertNote, …); a task marker is listMarker, taskMarker, then
taskMarkerChecked when done; the header row is tableRow then tableHead, and its cells tableCell then
tableHeaderCell.
| Part | Rust | What it is |
|---|---|---|
root | Root | The text view itself, a column of its blocks; text styles here pass to every part. |
paragraph | Paragraph | Every paragraph. |
heading | Heading | Every heading, before its level’s part. |
heading1 … heading6 | Heading1 … Heading6 | Headings of that level (27, 20, 17, then 15pt, semibold). |
blockquote | Blockquote | Quotations and alerts: a column of their blocks with a line at their side. |
alert | Alert | Every alert (> [!NOTE]), after blockquote: a tinted box, a row of its icon and its blocks. |
alertNote, alertTip, alertImportant, alertWarning, alertCaution | AlertNote, AlertTip, AlertImportant, AlertWarning, AlertCaution | Alerts of that kind. |
alertIcon | AlertIcon | The icon of an alert: a ring with a glyph in the alert’s color. |
list | List | Every list: a column of its items. |
orderedList, unorderedList | OrderedList, UnorderedList | Numbered and bulleted lists, after list. |
listItem | ListItem | Every item: a row of its marker and its blocks. |
listMarker | ListMarker | The marker of an item (•, ◦, ▪ by depth, or 1.), right-aligned in the indent before the item. |
taskMarker | TaskMarker | The marker of a task item: a box drawn as a view, filled with a ✓ when done; after listMarker. |
taskMarkerChecked | TaskMarkerChecked | The marker of a done task item, after taskMarker. |
listItemContent | ListItemContent | The column of an item’s blocks. |
codeBlock | CodeBlock | Code blocks: a bordered, rounded box of the code, with the header over it. |
codeBlockHeader | CodeBlockHeader | The row of a code block’s actions (codeBlockActions), floating over its top right corner. |
codeBlockScroll | CodeBlockScroll | The horizontal scroll view around a code block’s code. |
codeBlockCode | CodeBlockCode | The code of a code block (monospace, as written). |
code | Code | Code in text (monospace, on a rounded background). |
keyboard | Keyboard | Keys (<kbd>, as code). |
strong | Strong | Strong text (semibold). |
emphasis | Emphasis | Emphasized text (italic). |
strikethrough | Strikethrough | Struck-through text. |
underline | Underline | Underlined text (<u>, <ins>). |
highlight | Highlight | Highlighted text (<mark>, on a rounded yellow). |
superscript, subscript | Superscript, Subscript | Superscript and subscript (smaller). |
link | Link | Links (in the theme’s primary color, underlined). |
image | Image | Images. |
math | Math | Formulas in text, shown as their TeX in monospace when there is no renderMath. |
mathBlock | MathBlock | Formula blocks, shown as their TeX in monospace when there is no renderMath. |
table | Table | Tables: renderTableActions’ content, then the scroll view of the cells. |
tableScroll | TableScroll | The horizontal scroll view of a table’s cells: columns fit their content when there is room, wrap down to a floor when the view is narrower, and past that the table scrolls. |
tableRow | TableRow | Every row of a table: a view behind its cells, for backgrounds and borders (text styles go on tableCell). |
tableHead | TableHead | The header row, after tableRow. |
tableCell | TableCell | Every cell. |
tableHeaderCell | TableHeaderCell | The header row’s cells (smaller, muted), after tableCell. |
rule | Rule | Thematic breaks: a line drawn as a top border. |
details | Details | Disclosures (<details>). |
summary | Summary | The summary of a disclosure; a press opens and closes it. |
summaryMarker | SummaryMarker | The marker of a summary (▸, ▾). |
footnotes | Footnotes | The footnotes after the blocks: a column. |
footnote | Footnote | A footnote: a row of its number and its blocks. |
footnoteLabel | FootnoteLabel | The number of a footnote. |
footnoteReference | FootnoteReference | A reference to a footnote in text (its number, smaller). |
custom | Custom | Custom nodes shown as their content (when no render function shows them). |
#Streaming AI replies
Pass the reply as it grows, with options={{ streaming: true }} (MarkdownOptions { streaming: true, .. } in Rust). The end of a text that is still arriving often leaves
syntax open (**bold, `code, [a link](htt, a table whose header just arrived); streaming shows it as it will
look once closed, so the reader never sees stray ** or [. Blocks are kept by position: the blocks that are complete
keep their views, and only the block being written is made again, so a long reply stays cheap to update.
streamingCaret follows the end of the document: inline at the end of the last text, wherever the last block ends (a
paragraph, a list item, a code block, a table’s last cell), else on a line of its own after the last block (a rule,
content of a render function). Remove it when the reply is done.
use nsh_base::{MarkdownOptions, text_view};
use nsh_core::ui::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" />}
/>
);
}The caret is the app’s own view, so it can blink or pulse with nsh/animated, and new
blocks can fade in with the app’s animations.
#Links
Links are pressable spans. A press on one calls onLinkPress with { url }; the app decides what it opens (a page of its own, a
WebView, a scroll to a heading). Headings have anchors (GitHub’s: ## Getting started is
#getting-started), so a link to #getting-started arrives as that string, and a footnote reference arrives as
#footnote-<label>.
use nsh_base::{TextViewLinkPressEvent, text_view};
use nsh_core::ui::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 and open_web (scrollToAnchor and nav in TSX) stand for the app’s own functions.
With links="open", a press on a link to another site or app (a URL with a scheme: https:, mailto:, …) also
opens it outside the app with openUrl, under the app’s openUrl capability,
after onLinkPress reported it; anchors in the document (#…) are only reported. The default, "report", opens
nothing.
use nsh_base::{TextViewLinks, text_view};
text_view().markdown(doc).links(TextViewLinks::Open)<TextView markdown={doc} links="open" />#Render functions
Render functions put the app’s own content into the document. Each one takes the value of a part and returns its content (JSX in TSX, an element in Rust); a part keeps what was rendered for it while its value stays the same, so during streaming only the part being written is rendered again.
| Prop | Rust | Value | Replaces or adds |
|---|---|---|---|
renderCodeBlock | .render_code_block(|code| ..) | TextViewCodeBlock (CodeBlock) | Replaces a code block (syntax highlighting of your own). |
renderCodeBlockAction | .render_code_block_action(|action| ..) | TextViewCodeAction | The icon of a code block action of the app’s (codeBlockActions); the button around it is TextView’s. |
renderTableActions | .render_table_actions(|table| ..) | TextViewTable (Table) | Adds content above a table (copy, export). |
renderMath | .render_math(|math| ..) | TextViewMath | Replaces a formula: { tex, display }, display for a $$…$$ block. |
renderImage | .render_image(|image| ..) | TextViewImage (Image) | Replaces an image ({ src, alt, title }): your own loading, sizing, a viewer on press. |
renderCustom | .render_custom(|node| ..) | TextViewCustomNode (CustomNode) | Shows a custom node, by its name (Markdown extensions). |
Code blocks carry actions over their top right corner, all drawn alike: ghost buttons whose background shows while
hovered or pressed, shown while a pointer is over the block on desktops and always on touch screens. copy is built
in: it copies the code and shows a check for a moment. Other actions are the app’s: renderCodeBlockAction draws
their icon and onCodeBlockAction reports their presses (the built-in copy too, after it copied). copy writes the
clipboard as nsh/clipboard does, so it needs the app’s clipboard capability (nsh.json).
use nsh_base::{TextViewCodeAction, TextViewCodeActionEvent, text_view};
use nsh_core::ui::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)}
/>Code blocks are highlighted out of the box: for a language named on the fence (ts, tsx, rust, python, bash,
json, toml, yaml, diff and about two hundred more), the code shows in Allsum’s code palette, light or dark with the
appearance; a block without a language shows plain. An app with highlighting of its own shows its code with
renderCodeBlock, from its own component or one of its Rust crate; a Rust view gives TextView its highlighter with
CodeHighlighter. Here code with highlighting of the app’s own, and an image that fills the
width:
use nsh_base::text_view;
use nsh_base::text_view::{CodeBlock, Image};
use nsh_base::TextViewMath;
use nsh_core::ui::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 and MathView (highlighted and math_view in Rust) stand for the app’s own components (a
highlighter, a formula renderer, or a component of its Rust crate). Without renderMath a formula shows as its TeX in monospace (math and mathBlock parts).
#Markdown extensions
extensions turns syntax of the app’s into custom nodes, which renderCustom shows. Each extension has a name
(the name of the nodes it makes) and one of four kinds of syntax:
| Kind | Rust | Matches | The node |
|---|---|---|---|
pattern | MarkdownExtension::pattern(name, regex) | A regular expression in text (Rust’s regex syntax), outside code and links | Inline; text is the match, attributes its groups: "1", "2", … and named groups by name |
language | MarkdownExtension::language(name, language) | Fenced code blocks of that language (```chart) | A block; text is the code, attributes.meta the rest of the info string |
tag | MarkdownExtension::tag(name, tag) | HTML or MDX elements of that tag, any case (<Callout kind="tip">) | Its attributes, its content parsed: children (blocks) for an element on its own lines, content (inline) inside text |
container | MarkdownExtension::container(name, container) | Containers :::name … ::: | A block of the Markdown inside (children); :::note[Heads up] gives attributes.label, {key=value} the other attributes |
use nsh_base::text_view::{CustomNode, Document};
use nsh_base::{MarkdownExtension, text_view};
use nsh_core::ui::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 in Rust) stands for the app’s own chart. The mention pattern’s group is in
node.attributes?.["1"] (node.attributes["1"] in Rust: ada) and the whole match in node.text (@ada). A custom node with no render function shows its content.
#Options
options picks the Markdown syntax read beyond CommonMark and GitHub’s; in Rust it is a MarkdownOptions with the
same fields (MarkdownOptions { streaming: true, ..Default::default() }):
| Key | Type | Default | Description |
|---|---|---|---|
math | boolean | true | Formulas: $…$ in text, $$…$$ blocks. |
html | boolean | true | HTML inside the Markdown is read as the html prop reads it; false shows it as text. |
alerts | boolean | true | GitHub’s alerts: > [!NOTE], [!TIP], [!IMPORTANT], [!WARNING], [!CAUTION]. |
frontmatter | boolean | false | YAML front matter (--- … --- first) becomes a custom node frontmatter whose text is the YAML. |
mdx | boolean | false | MDX: JSX elements are custom nodes named by their tag, {expressions} custom nodes expression; raw HTML is then read as text. |
streaming | boolean | false | The text is still arriving: syntax its end leaves open shows as if it were closed. |
#The document model
The document prop and the render functions’ values are plain objects, each node tagged by its type. The types
come from nsh/base: TextViewDocument, TextViewBlock, TextViewInline, TextViewCodeBlock, TextViewTable,
TextViewListItem, TextViewImage, TextViewFootnote, TextViewAlert and TextViewCustomNode. In Rust they are
the types of nsh_base::text_view (Document, Block, Inline, CodeBlock, Table, ListItem, Image,
Footnote, Alert, CustomNode): Block and Inline are enums whose variants are the types below in PascalCase
(Block::Heading { level, content, anchor }, Inline::Strong { content }), with the same fields in snake case.
Block type | Fields |
|---|---|
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" per column), head (cells), rows? (rows of cells); a cell is { content } |
rule | |
details | open?, summary (inline), children |
custom | name, attributes?, text?, data?, children?, content? |
Inline type | Fields |
|---|---|
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 | as the block |
A document also has footnotes?: { label, index, children }, shown after the blocks in the order of their first
reference. An app can store replies as documents, build them from its own data, or mix in custom nodes that
renderCustom shows.
#Parsing and plain text
TextView also reads documents without showing them, synchronously:
| Function | Rust | Returns |
|---|---|---|
TextView.parse(markdown, options?, extensions?) | TextView::parse(markdown, options, extensions) | The TextViewDocument the markdown prop would show. |
TextView.parseHtml(html, extensions?) | TextView::parse_html(html, extensions) | The TextViewDocument the html prop would show. |
TextView.plainText(document) | TextView::plain_text(document), Document::plain_text() | The document’s text without markup: blocks separated by blank lines, list items and table rows by line breaks, cells by tabs. |
A copy button for a whole reply:
use nsh_base::{TextView, button};
use nsh_core::ui::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>An app can also parse once and keep the document (document={doc}), or change it before showing it.
#In Rust
Rust views use the same component from the nsh-base crate, with the examples above in their Rust tabs. Three things
are Rust’s own: an app’s highlighter, typed styles by part and Markdown plugins, each given to every TextView below
a view with provide_context. The document types are under nsh_base::text_view, where Parser also reads
documents with plugins (Parser::new().plugin(..).markdown(source)).
#Syntax highlighting
TextView highlights code with syntect in Allsum’s code palette (nsh-base’s feature highlight, on by default);
a large block shows plain at once and takes its colors when a background thread has highlighted it.
CodeHighlighter is the app’s highlighter in its place, given to every TextView below a view with provide_context.
It gets each code block and returns the styles of byte ranges of its code (in order, not overlapping; what they leave
out is plain); TextView shows the code as spans with those styles, inside its usual code block (header, actions,
scroll view, parts). The highlighter can be anything that turns code into styled ranges: tree-sitter, syntect with
a theme of the app’s, …
use std::ops::Range;
use nsh_base::{CodeHighlighter, text_view::CodeBlock};
use nsh_core::ui::{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 stands for the app’s glue to its highlighter and messages for the app’s view of the chat.
renderCodeBlock, when given, shows the code block in place of all of this.
#Typed part styles
TextViewParts styles parts with typed styles instead of class strings, for Rust views: each TextViewPart (one per
part of the table above) gets a function refining its StyleRefinement. Given with
provide_context, they apply to every TextView below, before its classes and styles; theme colors read in them
follow the light and dark appearance.
use nsh_base::{TextViewPart, TextViewParts, theme};
use nsh_core::ui::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)),
);
}
#Markdown plugins
A MarkdownPlugin reads Markdown syntax in Rust: it gets each node of the syntax tree (markdown-rs’s mdast) before
the parser reads it, and returns a block or inline content of its own, usually a CustomNode; it can also show the
custom nodes it makes. A view provides plugins to every TextView below it:
use nsh_base::text_view::{mdast, CustomNode, Inline, MarkdownPlugin, MarkdownPlugins, ParseContext};
use nsh_core::ui::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())
}
A plugin’s render comes first, then renderCustom, then the node’s content. configure turns on more of
markdown-rs’s syntax, and ParseContext reads a node’s children as the parser does.
#Props
| Prop | Rust | Type | Default | Description |
|---|---|---|---|---|
markdown | .markdown(..) | string | The document as Markdown. | |
html | .html(..) | string | The document as HTML, when markdown is not given. | |
document | .document(..) | TextViewDocument | The document as blocks, when neither markdown nor html is given. | |
options | .options(..) | MarkdownOptions | all defaults | The Markdown syntax read (Options). |
extensions | .extensions(..) | MarkdownExtension[] | [] | The app’s syntax, as custom nodes (Markdown extensions). |
classes | .classes(..) | TextViewClasses | {} | Tailwind classes by part. |
styles | .styles(..) | TextViewStyles | {} | Inline styles by part (each a StyleProp<TextStyle>), over the classes. |
selectable | .selectable(..) | boolean | false | The document is selectable as one: a selection runs across its paragraphs, headings, cells and code blocks (SelectionArea). |
links | .links(..) | "report" | "open" | "report" | What a press on a link does: reports it (onLinkPress), and with "open" also opens a link to another site or app outside the app (Links). |
codeBlockScrollbar | .code_block_scrollbar(..) | boolean | true | Code blocks show their horizontal scroll indicator. |
tableScrollbar | .table_scrollbar(..) | boolean | true | Tables show their horizontal scroll indicator. |
streamingCaret | .streaming_caret(..) | JSX.Element | Shown at the end of the document: inline after the last text, else on its own line. | |
renderCustom | .render_custom(|node| ..) | (node: TextViewCustomNode) => JSX.Element | Shows custom nodes. | |
renderCodeBlock | .render_code_block(|code| ..) | (code: TextViewCodeBlock) => JSX.Element | Shows code blocks. | |
codeBlockActions | .code_block_actions(..) | TextViewCodeAction[] | [] | The actions over each code block’s top right corner: { id: "copy" } built in, others the app’s. |
renderCodeBlockAction | .render_code_block_action(|action| ..) | (action: TextViewCodeAction) => JSX.Element | The icon of an action of the app’s. | |
renderTableActions | .render_table_actions(|table| ..) | (table: TextViewTable) => JSX.Element | Content above each table. | |
renderMath | .render_math(|math| ..) | (math: TextViewMath) => JSX.Element | Shows formulas. | |
renderImage | .render_image(|image| ..) | (image: TextViewImage) => JSX.Element | Shows images. | |
class, style | style methods | string, StyleProp<ViewStyle> | The root, a column of the blocks; its text styles pass to every part (typed text styles: styles.root). |
In Rust the classes and styles by part are TextViewClasses and TextViewStyles; a Rust view usually styles the
parts with TextViewParts.
#Events
| Event | Rust | Description |
|---|---|---|
onLinkPress | .on_link_press(|event: &TextViewLinkPressEvent| ..) | A link was pressed: { url }: its URL, #anchor for a heading, #footnote-<label> for a footnote. |
onCodeBlockAction | .on_code_block_action(|event: &TextViewCodeActionEvent| ..) | A code block’s action was pressed (after copy copied). |
onImagePress | .on_image_press(|image: &Image| ..) | An image not in a link was pressed: the image, for the app to show it larger (a zoomable viewer of its own). |
#Accessibility
Screen readers read the document in order, text by text. TextView gives the structure its roles: headings are
headers, links are links (their title is the hint), task markers are checkboxes with their checked state, a
disclosure’s summary is a button that says whether it is expanded, and images are read by their alt text.
#Related
- Text: the element every text of
TextViewis, with its spans, links, inline attachments and selection. - SelectionArea: one selection across several
TextViews and texts. nsh/base: the other base components.- Styling and Tailwind.