元素
TextView
TextView 显示用 Markdown、HTML 或应用自己构建的文档块写成的文档:标题、段落、强调、链接、列表与任务列表、引用和 GitHub 的提示块(alert)、代码、表格、图片、公式、脚注和折叠块。它为 AI 模型在聊天中的回复而设计:回复以 Markdown 流式到达,每来一段,文本就带着格式显示出来。解析在 Rust 中完成,文档用 Text 元素和视图绘制。
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),
)
}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);在 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 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 随内容增高。长文档请放进 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")
}<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" } }}
/>有些节点同时属于多个部件,按顺序应用:二级标题是 heading 再 heading2;提示块是 blockquote、alert,再是它的种类(alertNote 等);任务标记是 listMarker、taskMarker,完成时再加 taskMarkerChecked;表头行是 tableRow 再 tableHead,表头单元格是 tableCell 再 tableHeaderCell。
| 部件 | Rust | 说明 |
|---|---|---|
root | Root | 文本视图本身,即各文档块组成的一列;这里的文本样式传给每个部件。 |
paragraph | Paragraph | 每个段落。 |
heading | Heading | 每个标题,在其级别的部件之前。 |
heading1 … heading6 | Heading1 … Heading6 | 对应级别的标题(27、20、17,其余 15pt,半粗体)。 |
blockquote | Blockquote | 引用和提示块:其文档块组成的一列,一侧带一条线。 |
alert | Alert | 每个提示块(> [!NOTE]),在 blockquote 之后:着色的框,图标与其文档块组成的一行。 |
alertNote、alertTip、alertImportant、alertWarning、alertCaution | AlertNote, AlertTip, AlertImportant, AlertWarning, AlertCaution | 对应种类的提示块。 |
alertIcon | AlertIcon | 提示块的图标:以提示块颜色画出的圆环和其中的符号。 |
list | List | 每个列表:其列表项组成的一列。 |
orderedList、unorderedList | OrderedList, UnorderedList | 编号列表和项目符号列表,在 list 之后。 |
listItem | ListItem | 每个列表项:标记与其文档块组成的一行。 |
listMarker | ListMarker | 列表项的标记(按层级为 •、◦、▪,或 1.),在列表项之前的缩进中右对齐。 |
taskMarker | TaskMarker | 任务项的标记:用视图画出的方框,完成时填色并显示 ✓;在 listMarker 之后。 |
taskMarkerChecked | TaskMarkerChecked | 已完成任务项的标记,在 taskMarker 之后。 |
listItemContent | ListItemContent | 列表项的文档块组成的一列。 |
codeBlock | CodeBlock | 代码块:带边框的圆角框,标题行浮在代码之上。 |
codeBlockHeader | CodeBlockHeader | 代码块操作按钮(codeBlockActions)所在的行,浮在代码块右上角。 |
codeBlockScroll | CodeBlockScroll | 包住代码块代码的水平滚动视图。 |
codeBlockCode | CodeBlockCode | 代码块的代码(等宽,按原样)。 |
code | Code | 文本中的代码(等宽,带圆角底色)。 |
keyboard | Keyboard | 按键(<kbd>,与代码相同)。 |
strong | Strong | 加重文本(半粗体)。 |
emphasis | Emphasis | 强调文本(斜体)。 |
strikethrough | Strikethrough | 删除线文本。 |
underline | Underline | 下划线文本(<u>、<ins>)。 |
highlight | Highlight | 高亮文本(<mark>,圆角黄色底)。 |
superscript、subscript | Superscript, Subscript | 上标和下标(字号更小)。 |
link | Link | 链接(主题的 primary 色,带下划线)。 |
image | Image | 图片。 |
math | Math | 文本中的公式;没有 renderMath 时以等宽字体显示其 TeX。 |
mathBlock | MathBlock | 公式块;没有 renderMath 时以等宽字体显示其 TeX。 |
table | Table | 表格:renderTableActions 的内容,然后是单元格的滚动视图。 |
tableScroll | TableScroll | 表格单元格的横向滚动视图:空间足够时各列适应内容宽度,较窄时换行收缩到每列的下限,再窄则横向滚动。 |
tableRow | TableRow | 表格的每一行:位于单元格后面的视图,用于背景和边框(文本样式设在 tableCell 上)。 |
tableHead | TableHead | 表头行,在 tableRow 之后。 |
tableCell | TableCell | 每个单元格。 |
tableHeaderCell | TableHeaderCell | 表头行的单元格(字号较小,颜色淡),在 tableCell 之后。 |
rule | Rule | 分隔线:用上边框画出的一条线。 |
details | Details | 折叠块(<details>)。 |
summary | Summary | 折叠块的摘要;点击它展开或收起。 |
summaryMarker | SummaryMarker | 摘要的标记(▸、▾)。 |
footnotes | Footnotes | 文档块之后的脚注:一列。 |
footnote | Footnote | 一条脚注:编号与其文档块组成的一行。 |
footnoteLabel | FootnoteLabel | 脚注的编号。 |
footnoteReference | FootnoteReference | 文本中对脚注的引用(其编号,字号更小)。 |
custom | Custom | 显示为其内容的自定义节点(没有渲染函数显示它们时)。 |
#流式 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)),
))
}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" />}
/>
);
}光标是应用自己的视图,可以用 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),
}
})
}<TextView
markdown={doc}
onLinkPress={({ url }) => {
if (url.startsWith("#")) scrollToAnchor(url.slice(1));
else nav.push({ name: "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)<TextView markdown={doc} links="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);
}
})
}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)}
/>代码块开箱即有语法高亮:围栏上标明语言(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))
}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 和 MathView(Rust 中是 highlighted 和 math_view)代表应用自己的组件(高亮器、公式渲染器,或应用 Rust crate 中的组件)。没有 renderMath 时,公式以等宽字体显示其 TeX(math 和 mathBlock 部件)。
#Markdown 扩展
extensions 把应用自己的语法变成自定义节点,由 renderCustom 显示。每个扩展有一个 name(它生成的节点的 name)和以下四种语法之一:
| 种类 | Rust | 匹配 | 生成的节点 |
|---|---|---|---|
pattern | MarkdownExtension::pattern(name, regex) | 文本中的正则表达式(Rust 的 regex 语法),代码和链接之外 | 行内节点;text 是匹配的文本,attributes 是捕获组:"1"、"2"……以及按名字的命名捕获组 |
language | MarkdownExtension::language(name, language) | 该语言的围栏代码块(```chart) | 块节点;text 是代码,attributes.meta 是信息字符串的其余部分 |
tag | MarkdownExtension::tag(name, tag) | 该标签的 HTML 或 MDX 元素,不区分大小写(<Callout kind="tip">) | 其属性,以及解析后的内容:独占一行的元素为 children(文档块),文本中的元素为 content(行内内容) |
container | MarkdownExtension::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)
}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(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() }):
| 键 | 类型 | 默认值 | 说明 |
|---|---|---|---|
math | boolean | true | 公式:文本中的 $…$,$$…$$ 公式块。 |
html | boolean | true | Markdown 中的 HTML 按 html 属性的方式读取;false 时显示为文本。 |
alerts | boolean | true | GitHub 的提示块:> [!NOTE]、[!TIP]、[!IMPORTANT]、[!WARNING]、[!CAUTION]。 |
frontmatter | boolean | false | 开头的 YAML front matter(--- … ---)成为名为 frontmatter 的自定义节点,其 text 是 YAML。 |
mdx | boolean | false | MDX:JSX 元素成为以标签命名的自定义节点,{expressions} 成为名为 expression 的自定义节点;此时原始 HTML 按文本读取。 |
streaming | boolean | false | 文本仍在到达:末尾未闭合的语法显示成闭合后的样子。 |
#文档模型
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 | 字段 |
|---|---|
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")、head(单元格)、rows?(由单元格组成的行);单元格是 { content } |
rule | |
details | open?、summary(行内内容)、children |
custom | name、attributes?、text?、data?、children?、content? |
行内 type | 字段 |
|---|---|
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 | 同块节点 |
文档还可以有 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"))
}<Button onPress={() => Clipboard.writeText(TextView.plainText(TextView.parse(reply())))}>
<Text>Copy</Text>
</Button>应用也可以只解析一次并保存文档(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(..) | string | Markdown 形式的文档。 | |
html | .html(..) | string | HTML 形式的文档,未提供 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(..) | boolean | false | 整个文档作为一体可选择:选区可以跨越它的段落、标题、单元格和代码块(SelectionArea)。 |
links | .links(..) | "report" | "open" | "report" | 按下链接时的行为:报告它(onLinkPress),设为 "open" 时还会在应用之外打开指向其他网站或应用的链接(链接)。 |
codeBlockScrollbar | .code_block_scrollbar(..) | boolean | true | 代码块显示其水平滚动指示器。 |
tableScrollbar | .table_scrollbar(..) | boolean | true | 表格显示其水平滚动指示器。 |
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。