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.

text-view.tsx
text-view.tsxLive
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),
    )
}

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::Document in 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 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")
}

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.

PartRustWhat it is
rootRootThe text view itself, a column of its blocks; text styles here pass to every part.
paragraphParagraphEvery paragraph.
headingHeadingEvery heading, before its level’s part.
heading1 … heading6Heading1 … Heading6Headings of that level (27, 20, 17, then 15pt, semibold).
blockquoteBlockquoteQuotations and alerts: a column of their blocks with a line at their side.
alertAlertEvery alert (> [!NOTE]), after blockquote: a tinted box, a row of its icon and its blocks.
alertNote, alertTip, alertImportant, alertWarning, alertCautionAlertNote, AlertTip, AlertImportant, AlertWarning, AlertCautionAlerts of that kind.
alertIconAlertIconThe icon of an alert: a ring with a glyph in the alert’s color.
listListEvery list: a column of its items.
orderedList, unorderedListOrderedList, UnorderedListNumbered and bulleted lists, after list.
listItemListItemEvery item: a row of its marker and its blocks.
listMarkerListMarkerThe marker of an item (•, ◦, ▪ by depth, or 1.), right-aligned in the indent before the item.
taskMarkerTaskMarkerThe marker of a task item: a box drawn as a view, filled with a ✓ when done; after listMarker.
taskMarkerCheckedTaskMarkerCheckedThe marker of a done task item, after taskMarker.
listItemContentListItemContentThe column of an item’s blocks.
codeBlockCodeBlockCode blocks: a bordered, rounded box of the code, with the header over it.
codeBlockHeaderCodeBlockHeaderThe row of a code block’s actions (codeBlockActions), floating over its top right corner.
codeBlockScrollCodeBlockScrollThe horizontal scroll view around a code block’s code.
codeBlockCodeCodeBlockCodeThe code of a code block (monospace, as written).
codeCodeCode in text (monospace, on a rounded background).
keyboardKeyboardKeys (<kbd>, as code).
strongStrongStrong text (semibold).
emphasisEmphasisEmphasized text (italic).
strikethroughStrikethroughStruck-through text.
underlineUnderlineUnderlined text (<u>, <ins>).
highlightHighlightHighlighted text (<mark>, on a rounded yellow).
superscript, subscriptSuperscript, SubscriptSuperscript and subscript (smaller).
linkLinkLinks (in the theme’s primary color, underlined).
imageImageImages.
mathMathFormulas in text, shown as their TeX in monospace when there is no renderMath.
mathBlockMathBlockFormula blocks, shown as their TeX in monospace when there is no renderMath.
tableTableTables: renderTableActions’ content, then the scroll view of the cells.
tableScrollTableScrollThe 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.
tableRowTableRowEvery row of a table: a view behind its cells, for backgrounds and borders (text styles go on tableCell).
tableHeadTableHeadThe header row, after tableRow.
tableCellTableCellEvery cell.
tableHeaderCellTableHeaderCellThe header row’s cells (smaller, muted), after tableCell.
ruleRuleThematic breaks: a line drawn as a top border.
detailsDetailsDisclosures (<details>).
summarySummaryThe summary of a disclosure; a press opens and closes it.
summaryMarkerSummaryMarkerThe marker of a summary (▸, ▾).
footnotesFootnotesThe footnotes after the blocks: a column.
footnoteFootnoteA footnote: a row of its number and its blocks.
footnoteLabelFootnoteLabelThe number of a footnote.
footnoteReferenceFootnoteReferenceA reference to a footnote in text (its number, smaller).
customCustomCustom 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)),
        ))
}

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 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),
        }
    })
}

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)

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

PropRustValueReplaces or adds
renderCodeBlock.render_code_block(|code| ..)TextViewCodeBlock (CodeBlock)Replaces a code block (syntax highlighting of your own).
renderCodeBlockAction.render_code_block_action(|action| ..)TextViewCodeActionThe 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| ..)TextViewMathReplaces 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);
            }
        })
}

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

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:

KindRustMatchesThe node
patternMarkdownExtension::pattern(name, regex)A regular expression in text (Rust’s regex syntax), outside code and linksInline; text is the match, attributes its groups: "1", "2", … and named groups by name
languageMarkdownExtension::language(name, language)Fenced code blocks of that language (```chart)A block; text is the code, attributes.meta the rest of the info string
tagMarkdownExtension::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
containerMarkdownExtension::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)
}

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() }):

KeyTypeDefaultDescription
mathbooleantrueFormulas: $…$ in text, $$…$$ blocks.
htmlbooleantrueHTML inside the Markdown is read as the html prop reads it; false shows it as text.
alertsbooleantrueGitHub’s alerts: > [!NOTE], [!TIP], [!IMPORTANT], [!WARNING], [!CAUTION].
frontmatterbooleanfalseYAML front matter (--- … --- first) becomes a custom node frontmatter whose text is the YAML.
mdxbooleanfalseMDX: JSX elements are custom nodes named by their tag, {expressions} custom nodes expression; raw HTML is then read as text.
streamingbooleanfalseThe 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 typeFields
paragraphcontent
headinglevel (1–6), content, anchor?
blockquotechildren, alert? ("note", "tip", "important", "warning", "caution")
listordered?, start?, loose?, items ({ checked?, children })
codeBlocklanguage?, meta?, code
mathtex
tablealign? ("none", "left", "center", "right" per column), head (cells), rows? (rows of cells); a cell is { content }
rule
detailsopen?, summary (inline), children
customname, attributes?, text?, data?, children?, content?
Inline typeFields
texttext
strong, emphasis, strikethrough, underline, highlight, superscript, subscriptcontent
codecode
keyboardtext
linkurl, title?, content
imagesrc, alt?, title?
mathtex
break
footnoteReferencelabel, index
customas 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:

FunctionRustReturns
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"))
}

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

PropRustTypeDefaultDescription
markdown.markdown(..)stringThe document as Markdown.
html.html(..)stringThe document as HTML, when markdown is not given.
document.document(..)TextViewDocumentThe document as blocks, when neither markdown nor html is given.
options.options(..)MarkdownOptionsall defaultsThe 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(..)booleanfalseThe 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(..)booleantrueCode blocks show their horizontal scroll indicator.
tableScrollbar.table_scrollbar(..)booleantrueTables show their horizontal scroll indicator.
streamingCaret.streaming_caret(..)JSX.ElementShown at the end of the document: inline after the last text, else on its own line.
renderCustom.render_custom(|node| ..)(node: TextViewCustomNode) => JSX.ElementShows custom nodes.
renderCodeBlock.render_code_block(|code| ..)(code: TextViewCodeBlock) => JSX.ElementShows 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.ElementThe icon of an action of the app’s.
renderTableActions.render_table_actions(|table| ..)(table: TextViewTable) => JSX.ElementContent above each table.
renderMath.render_math(|math| ..)(math: TextViewMath) => JSX.ElementShows formulas.
renderImage.render_image(|image| ..)(image: TextViewImage) => JSX.ElementShows images.
class, stylestyle methodsstring, 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

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

  • Text: the element every text of TextView is, 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.