元素

TextView

TextView 显示用 Markdown、HTML 或应用自己构建的文档块写成的文档:标题、段落、强调、链接、列表与任务列表、引用和 GitHub 的提示块(alert)、代码、表格、图片、公式、脚注和折叠块。它为 AI 模型在聊天中的回复而设计:回复以 Markdown 流式到达,每来一段,文本就带着格式显示出来。解析在 Rust 中完成,文档用 Text 元素和视图绘制。

text-view.tsx
text-view.tsx运行中
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 />);

它负责行为,并自带一套阅读排版:15pt 正文、宽松的 25.5pt 行高,半粗体标题且上方留有空间,链接以主题的 primary 色加下划线,代码使用主题的 font-mono,放在带边框的圆角框中,表格四边都有线、表头行带底色,引用在一侧带一条线,提示块是带图标、采用 GitHub 提示色的着色框。颜色取自主题的令牌(foreground、muted-foreground、border、muted、primary,警示(caution)用 destructive),因此主题和深色外观都会作用于它。每个部件在这些默认样式之上接收应用的样式:TSX 中是 classes 和 styles,Rust 中是按部件给出的类型化样式(TextViewParts)。Rust 用 nsh_base::text_view() 创建它,TSX 用从 nsh/base 导入的 <TextView>。

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

在 Rust 中,text_view() 像 view() 创建视图一样创建它:每个属性一个设置方法(.markdown(..)、.options(..)、.selectable(..)),每个渲染函数一个闭包(.render_image(|image| ..)),每个事件一个监听方法(.on_link_press(..)),根节点使用类型化的样式方法(.p_5()、.text_color(..)),其中的文本样式会传给每个部件。

#基于 Text 构建

TextView 由 Text 元素组成,其中的文本行为与 Text 页面描述的完全一致。每个段落、标题、表格单元格和代码块都是一个原生 Text:一个独立排版、换行、跟随字体缩放的文本块,设置 selectable 后可以选择和复制。加粗、强调、删除线等行内标记是它的片段;行内代码是带片段背景的片段,因此 code: "rounded bg-neutral-100" 会给它一个圆角背景;链接是可点击的片段;图片和行内自定义节点是行内附件。根节点上的文本样式会传给每个部件,就像文本样式从 Text 传给它的片段一样。

设置 selectable 后,整个文档作为一体选择,就像网页一样:拖动(在触摸屏上是长按后拖动选择手柄)可以从一个段落、列表项、单元格或代码块延伸到下一个,复制会取得它们全部的文字。设置了 selectable 的 TextView 本身就是一个 SelectionArea;放在 SelectionArea 里时,它的选区还会延伸到周围的文字和其他 TextView。

TextView 增加的是其余部分:把 Markdown、HTML 或文档读成文档块,文本周围的视图(列表、引用、表格、代码块、折叠块、脚注),流式输出,按部件设定样式,渲染函数和扩展。

#文档内容

内容来自以下三个属性之一,按此顺序读取:

  • markdown:CommonMark 加上 GitHub 的扩展(表格、任务列表、删除线、自动链接、脚注、提示块)、公式($…$ 和 $$…$$)以及 Markdown 中的 HTML。
  • html:按阅读模式的方式显示 HTML。文本、结构和行内格式都会保留;脚本、样式、表单控件和隐藏元素不会显示。片段和完整页面都可以(完整页面显示其 body)。
  • document:应用构建或保存的文档块:TSX 中是普通对象,Rust 中是 nsh_base::text_view::Document(文档模型)。
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 随内容增高。长文档请放进 Scroll(或 List 的一行)中。

#部件样式

class 和 style 设定根节点(各文档块组成的一列)的样式;在那里设置的文本样式(颜色、字号、字体)会传给每个部件。根节点也是部件 root:styles.root 以带类型的方式接收文本样式(style 的类型是视图样式),classes.root 接收类。classes 按部件接收 Tailwind 类,styles 按部件接收内联样式。部件的类排在它自己的默认样式之后,因此优先生效;它的 styles 又优先于它的类。在 Rust 中,部件接收类型化的样式:TextViewParts 为每个 TextViewPart 提供一个细化其样式的函数,作用于提供它的视图下面的每个 TextView(类型化的部件样式),根节点则使用样式方法。默认样式的行高以点为单位:改变某个部件字号的类也要设定行高(Tailwind 的 text-* 类会一并设定)。

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

有些节点同时属于多个部件,按顺序应用:二级标题是 heading 再 heading2;提示块是 blockquote、alert,再是它的种类(alertNote 等);任务标记是 listMarker、taskMarker,完成时再加 taskMarkerChecked;表头行是 tableRow 再 tableHead,表头单元格是 tableCell 再 tableHeaderCell。

部件Rust说明
rootRoot文本视图本身,即各文档块组成的一列;这里的文本样式传给每个部件。
paragraphParagraph每个段落。
headingHeading每个标题,在其级别的部件之前。
heading1 … heading6Heading1 … Heading6对应级别的标题(27、20、17,其余 15pt,半粗体)。
blockquoteBlockquote引用和提示块:其文档块组成的一列,一侧带一条线。
alertAlert每个提示块(> [!NOTE]),在 blockquote 之后:着色的框,图标与其文档块组成的一行。
alertNote、alertTip、alertImportant、alertWarning、alertCautionAlertNote, AlertTip, AlertImportant, AlertWarning, AlertCaution对应种类的提示块。
alertIconAlertIcon提示块的图标:以提示块颜色画出的圆环和其中的符号。
listList每个列表:其列表项组成的一列。
orderedList、unorderedListOrderedList, UnorderedList编号列表和项目符号列表,在 list 之后。
listItemListItem每个列表项:标记与其文档块组成的一行。
listMarkerListMarker列表项的标记(按层级为 •、◦、▪,或 1.),在列表项之前的缩进中右对齐。
taskMarkerTaskMarker任务项的标记:用视图画出的方框,完成时填色并显示 ✓;在 listMarker 之后。
taskMarkerCheckedTaskMarkerChecked已完成任务项的标记,在 taskMarker 之后。
listItemContentListItemContent列表项的文档块组成的一列。
codeBlockCodeBlock代码块:带边框的圆角框,标题行浮在代码之上。
codeBlockHeaderCodeBlockHeader代码块操作按钮(codeBlockActions)所在的行,浮在代码块右上角。
codeBlockScrollCodeBlockScroll包住代码块代码的水平滚动视图。
codeBlockCodeCodeBlockCode代码块的代码(等宽,按原样)。
codeCode文本中的代码(等宽,带圆角底色)。
keyboardKeyboard按键(<kbd>,与代码相同)。
strongStrong加重文本(半粗体)。
emphasisEmphasis强调文本(斜体)。
strikethroughStrikethrough删除线文本。
underlineUnderline下划线文本(<u>、<ins>)。
highlightHighlight高亮文本(<mark>,圆角黄色底)。
superscript、subscriptSuperscript, Subscript上标和下标(字号更小)。
linkLink链接(主题的 primary 色,带下划线)。
imageImage图片。
mathMath文本中的公式;没有 renderMath 时以等宽字体显示其 TeX。
mathBlockMathBlock公式块;没有 renderMath 时以等宽字体显示其 TeX。
tableTable表格:renderTableActions 的内容,然后是单元格的滚动视图。
tableScrollTableScroll表格单元格的横向滚动视图:空间足够时各列适应内容宽度,较窄时换行收缩到每列的下限,再窄则横向滚动。
tableRowTableRow表格的每一行:位于单元格后面的视图,用于背景和边框(文本样式设在 tableCell 上)。
tableHeadTableHead表头行,在 tableRow 之后。
tableCellTableCell每个单元格。
tableHeaderCellTableHeaderCell表头行的单元格(字号较小,颜色淡),在 tableCell 之后。
ruleRule分隔线:用上边框画出的一条线。
detailsDetails折叠块(<details>)。
summarySummary折叠块的摘要;点击它展开或收起。
summaryMarkerSummaryMarker摘要的标记(▸、▾)。
footnotesFootnotes文档块之后的脚注:一列。
footnoteFootnote一条脚注:编号与其文档块组成的一行。
footnoteLabelFootnoteLabel脚注的编号。
footnoteReferenceFootnoteReference文本中对脚注的引用(其编号,字号更小)。
customCustom显示为其内容的自定义节点(没有渲染函数显示它们时)。

#流式 AI 回复

随着回复增长传入它,并设置 options={{ streaming: true }}(Rust 中是 MarkdownOptions { streaming: true, .. })。仍在到达的文本末尾常常留着未闭合的语法(**bold、`code、[a link](htt、刚到达表头的表格);流式模式会把它显示成闭合后的样子,读者不会看到零散的 ** 或 [。文档块按位置保留:已完成的块保留它们的视图,只有正在写的块会重新生成,因此长回复的更新依然很轻。

streamingCaret 跟随文档末尾:以行内方式显示在最后一段文本的末尾,无论最后一个块在哪里结束(段落、列表项、代码块、表格的最后一个单元格);否则单独一行显示在最后一个块之后(分隔线、渲染函数的内容)。回复结束后去掉它。

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

光标是应用自己的视图,可以用 nsh/animated 让它闪烁或脉动,新的文档块也可以用应用自己的动画淡入。

#链接

链接是可点击的片段。点击链接会以 { url } 调用 onLinkPress;打开什么由应用决定(应用自己的页面、WebView、滚动到某个标题)。标题带有锚点(GitHub 的规则:## Getting started 是 #getting-started),因此指向 #getting-started 的链接会以这个字符串到达,脚注引用则以 #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 和 open_web(TSX 中是 scrollToAnchor 和 nav)代表应用自己的函数。

设置 links="open" 后,按下指向其他网站或应用的链接(带 scheme 的 URL:https:、mailto: 等)时,在 onLinkPress 报告之后,还会用 openUrl 在应用之外打开它,受应用的 openUrl 能力约束;文档内的锚点(#…)只报告。默认值 "report" 什么都不打开。

use nsh_base::{TextViewLinks, text_view};

text_view().markdown(doc).links(TextViewLinks::Open)

#渲染函数

渲染函数把应用自己的内容放进文档。每个渲染函数接收一个部件的值并返回其内容(TSX 中是 JSX,Rust 中是元素);只要值不变,部件就保留为它渲染的内容,因此流式输出时只有正在写的部件会重新渲染。

属性Rust值替换或添加
renderCodeBlock.render_code_block(|code| ..)TextViewCodeBlock(CodeBlock)替换代码块(应用自己的语法高亮)。
renderCodeBlockAction.render_code_block_action(|action| ..)TextViewCodeAction应用自己的代码块操作(codeBlockActions)的图标;外面的按钮由 TextView 绘制。
renderTableActions.render_table_actions(|table| ..)TextViewTable(Table)在表格上方添加内容(复制、导出)。
renderMath.render_math(|math| ..)TextViewMath替换公式:{ tex, display },display 表示 $$…$$ 公式块。
renderImage.render_image(|image| ..)TextViewImage(Image)替换图片({ src, alt, title }):自己的加载方式、尺寸、点击查看大图。
renderCustom.render_custom(|node| ..)TextViewCustomNode(CustomNode)按 name 显示自定义节点(Markdown 扩展)。

代码块的右上角浮动着操作按钮,外观统一:幽灵按钮,悬停或按下时显示背景;桌面上指针悬停在代码块上时显示,触摸屏上始终显示。copy 是内置的:复制代码,并短暂显示一个对勾。其他操作由应用定义:renderCodeBlockAction 绘制它们的图标,onCodeBlockAction 报告按下(内置的 copy 复制后也会报告)。copy 与 nsh/clipboard 一样写入剪贴板,因此需要应用的 clipboard 权限(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);
            }
        })
}

代码块开箱即有语法高亮:围栏上标明语言(ts、tsx、rust、python、bash、json、toml、yaml、diff 等约两百种)时,代码以 Allsum 的代码配色显示,随外观切换浅色或深色;未标明语言的代码块按原样显示。应用想用自己的高亮时,用 renderCodeBlock 显示代码,使用它自己的组件或它的 Rust crate 中的组件;Rust 视图通过 CodeHighlighter 把高亮器交给 TextView。下面用应用自己的高亮显示代码,并让图片占满宽度:

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 和 MathView(Rust 中是 highlighted 和 math_view)代表应用自己的组件(高亮器、公式渲染器,或应用 Rust crate 中的组件)。没有 renderMath 时,公式以等宽字体显示其 TeX(math 和 mathBlock 部件)。

#Markdown 扩展

extensions 把应用自己的语法变成自定义节点,由 renderCustom 显示。每个扩展有一个 name(它生成的节点的 name)和以下四种语法之一:

种类Rust匹配生成的节点
patternMarkdownExtension::pattern(name, regex)文本中的正则表达式(Rust 的 regex 语法),代码和链接之外行内节点;text 是匹配的文本,attributes 是捕获组:"1"、"2"……以及按名字的命名捕获组
languageMarkdownExtension::language(name, language)该语言的围栏代码块(```chart)块节点;text 是代码,attributes.meta 是信息字符串的其余部分
tagMarkdownExtension::tag(name, tag)该标签的 HTML 或 MDX 元素,不区分大小写(<Callout kind="tip">)其属性,以及解析后的内容:独占一行的元素为 children(文档块),文本中的元素为 content(行内内容)
containerMarkdownExtension::container(name, container)容器 :::name … :::内部 Markdown 组成的块节点(children);:::note[Heads up] 得到 attributes.label,{key=value} 得到其他属性
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(Rust 中是 bar_chart)代表应用自己的图表。提及(mention)的捕获组在 node.attributes?.["1"](Rust 中是 node.attributes["1"]:ada)中,完整匹配在 node.text(@ada)中。没有渲染函数的自定义节点显示其内容。

#选项

options 选择在 CommonMark 和 GitHub 语法之外读取的 Markdown 语法;在 Rust 中它是字段相同的 MarkdownOptions(MarkdownOptions { streaming: true, ..Default::default() }):

键类型默认值说明
mathbooleantrue公式:文本中的 $…$,$$…$$ 公式块。
htmlbooleantrueMarkdown 中的 HTML 按 html 属性的方式读取;false 时显示为文本。
alertsbooleantrueGitHub 的提示块:> [!NOTE]、[!TIP]、[!IMPORTANT]、[!WARNING]、[!CAUTION]。
frontmatterbooleanfalse开头的 YAML front matter(--- … ---)成为名为 frontmatter 的自定义节点,其 text 是 YAML。
mdxbooleanfalseMDX:JSX 元素成为以标签命名的自定义节点,{expressions} 成为名为 expression 的自定义节点;此时原始 HTML 按文本读取。
streamingbooleanfalse文本仍在到达:末尾未闭合的语法显示成闭合后的样子。

#文档模型

document 属性和渲染函数收到的值都是普通对象,每个节点以 type 标明种类。类型从 nsh/base 导入:TextViewDocument、TextViewBlock、TextViewInline、TextViewCodeBlock、TextViewTable、TextViewListItem、TextViewImage、TextViewFootnote、TextViewAlert 和 TextViewCustomNode。在 Rust 中它们是 nsh_base::text_view 的类型(Document、Block、Inline、CodeBlock、Table、ListItem、Image、Footnote、Alert、CustomNode):Block 和 Inline 是枚举,其变体是下表的类型改为 PascalCase(Block::Heading { level, content, anchor }、Inline::Strong { content }),字段相同,改为 snake case。

块 type字段
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")、head(单元格)、rows?(由单元格组成的行);单元格是 { content }
rule
detailsopen?、summary(行内内容)、children
customname、attributes?、text?、data?、children?、content?
行内 type字段
texttext
strong、emphasis、strikethrough、underline、highlight、superscript、subscriptcontent
codecode
keyboardtext
linkurl、title?、content
imagesrc、alt?、title?
mathtex
break
footnoteReferencelabel、index
custom同块节点

文档还可以有 footnotes?:{ label, index, children },按首次引用的顺序显示在文档块之后。应用可以把回复存成文档、用自己的数据构建文档,或者混入由 renderCustom 显示的自定义节点。

#解析与纯文本

TextView 也可以同步地读取文档而不显示它:

函数Rust返回
TextView.parse(markdown, options?, extensions?)TextView::parse(markdown, options, extensions)markdown 属性会显示的 TextViewDocument。
TextView.parseHtml(html, extensions?)TextView::parse_html(html, extensions)html 属性会显示的 TextViewDocument。
TextView.plainText(document)TextView::plain_text(document)、Document::plain_text()去掉标记的文档文本:文档块之间以空行分隔,列表项和表格行以换行分隔,单元格以制表符分隔。

复制整条回复的按钮:

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

应用也可以只解析一次并保存文档(document={doc}),或者在显示之前修改它。

#在 Rust 中使用

Rust 视图从 nsh-base crate 使用同一个组件,上面的示例都有对应的 Rust 标签页。有三样是 Rust 专有的:应用自己的高亮器、按部件给出的类型化样式和 Markdown 插件,都通过 provide_context 交给一个视图下面的每个 TextView。文档类型位于 nsh_base::text_view 下,那里的 Parser 还能带插件读取文档(Parser::new().plugin(..).markdown(source))。

#语法高亮

TextView 用 syntect 以 Allsum 的代码配色高亮代码(nsh-base 的 feature highlight,默认开启);较大的代码块先按原样显示,在后台线程高亮后再着色。CodeHighlighter 是代替它的应用自己的高亮器,通过 provide_context 交给一个视图下面的所有 TextView。它收到每个代码块,返回代码中各字节范围的样式(按顺序、互不重叠;未覆盖的部分保持原样);TextView 以带这些样式的片段显示代码,仍放在它通常的代码块中(标题行、操作、滚动视图、部件)。高亮器可以是任何能把代码变成带样式范围的工具:tree-sitter、使用应用自己主题的 syntect 等。

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 代表应用与其高亮器之间的胶水代码,messages 代表应用的聊天视图。如果提供了 renderCodeBlock,它会代替以上这些显示代码块。

#类型化的部件样式

TextViewParts 让 Rust 视图用类型化的样式代替类名字符串设定部件样式:每个 TextViewPart(对应上面表格中的一个部件)得到一个细化其 StyleRefinement 的函数。通过 provide_context 提供后,它们作用于下面的每个 TextView,先于其 classes 和 styles 应用;其中读取的主题颜色会跟随浅色和深色外观。

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 插件

MarkdownPlugin 在 Rust 中读取 Markdown 语法:在解析器读取语法树(markdown-rs 的 mdast)的每个节点之前先交给它,它返回自己的文档块或行内内容,通常是一个 CustomNode;它也可以显示自己生成的自定义节点。一个视图为它下面的所有 TextView 提供插件:

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

插件的 render 最先生效,然后是 renderCustom,最后是节点的内容。configure 打开 markdown-rs 的更多语法,ParseContext 按解析器的方式读取节点的子节点。

#属性

属性Rust类型默认值说明
markdown.markdown(..)stringMarkdown 形式的文档。
html.html(..)stringHTML 形式的文档,未提供 markdown 时使用。
document.document(..)TextViewDocument文档块形式的文档,markdown 和 html 都未提供时使用。
options.options(..)MarkdownOptions全部默认值读取的 Markdown 语法(选项)。
extensions.extensions(..)MarkdownExtension[][]应用自己的语法,生成自定义节点(Markdown 扩展)。
classes.classes(..)TextViewClasses{}按部件设定的 Tailwind 类。
styles.styles(..)TextViewStyles{}按部件设定的内联样式(每个都是 StyleProp<TextStyle>),优先于类。
selectable.selectable(..)booleanfalse整个文档作为一体可选择:选区可以跨越它的段落、标题、单元格和代码块(SelectionArea)。
links.links(..)"report" | "open""report"按下链接时的行为:报告它(onLinkPress),设为 "open" 时还会在应用之外打开指向其他网站或应用的链接(链接)。
codeBlockScrollbar.code_block_scrollbar(..)booleantrue代码块显示其水平滚动指示器。
tableScrollbar.table_scrollbar(..)booleantrue表格显示其水平滚动指示器。
streamingCaret.streaming_caret(..)JSX.Element显示在文档末尾:在最后一段文本之后以行内方式显示,否则单独一行。
renderCustom.render_custom(|node| ..)(node: TextViewCustomNode) => JSX.Element显示自定义节点。
renderCodeBlock.render_code_block(|code| ..)(code: TextViewCodeBlock) => JSX.Element显示代码块。
codeBlockActions.code_block_actions(..)TextViewCodeAction[][]每个代码块右上角的操作:{ id: "copy" } 内置,其余由应用定义。
renderCodeBlockAction.render_code_block_action(|action| ..)(action: TextViewCodeAction) => JSX.Element应用自己的操作的图标。
renderTableActions.render_table_actions(|table| ..)(table: TextViewTable) => JSX.Element每个表格上方的内容。
renderMath.render_math(|math| ..)(math: TextViewMath) => JSX.Element显示公式。
renderImage.render_image(|image| ..)(image: TextViewImage) => JSX.Element显示图片。
class、style样式方法string、StyleProp<ViewStyle>根节点,即各文档块组成的一列;其文本样式传给每个部件(带类型的文本样式:styles.root)。

在 Rust 中,按部件给出的类和样式是 TextViewClasses 和 TextViewStyles;Rust 视图通常用 TextViewParts 设定部件样式。

#事件

事件Rust说明
onLinkPress.on_link_press(|event: &TextViewLinkPressEvent| ..)链接被点击:{ url },其 URL;标题为 #anchor,脚注为 #footnote-<label>。
onCodeBlockAction.on_code_block_action(|event: &TextViewCodeActionEvent| ..)代码块的操作被按下(copy 会在复制后报告)。
onImagePress.on_image_press(|image: &Image| ..)不在链接中的图片被点击:该图片,由应用放大显示(应用自己的可缩放查看器)。

#无障碍

读屏软件按顺序逐个文本朗读文档。TextView 为结构赋予角色:标题是标题(header),链接是链接(其 title 作为提示),任务标记是带勾选状态的复选框,折叠块的摘要是会说明是否展开的按钮,图片按其替代文本(alt)朗读。

#相关内容

  • Text:TextView 中每段文本所用的元素,包括它的片段、链接、行内附件和选择。
  • SelectionArea:跨越多个 TextView 和文字的同一个选区。
  • nsh/base:其他基础组件。
  • 样式与 Tailwind。