本頁由機器翻譯,可能有錯誤。 閱讀英文原文

元素

TextView

TextView 顯示以 Markdown、HTML 或由應用程式建構的區塊所寫成的文件:標題、段落、強調、連結、清單與任務清單、引用與 GitHub 的提示框(alerts)、程式碼、表格、圖片、公式、註腳及展開區塊(disclosures)。它專為聊天中 AI 模型的回覆而設:回覆以 Markdown 串流送達,每個片段都會連同格式一併顯示文字。解析在 Rust 中進行,文件以 Text 元素與視圖繪製。

text-view.tsx
text-view.tsxLive
import { cleanup, mount, signal, Scroll, Text, View } from "nsh";
import { TextView } from "nsh/base";
import { button, buttonText, desktop, ui } from "./palette";

const REPLY = `## Moving to offline sync

Sync now runs **in the background**, and conflicts merge *per field*:

1. Export with \`nsh export\`
2. Check the dry run
3. Switch over

| Step   | Time  |
|--------|------:|
| Export | 2 min |
| Check  | 5 min |

\`\`\`sh
nsh export --out backup.json
\`\`\`

> [!TIP]
> ${desktop ? "Drag across the paragraphs to select them, then copy." : "Long-press a paragraph to copy it."}`;

function App() {
  const text = signal(REPLY);
  const streaming = signal(false);
  let timer: number | undefined;
  const stop = () => {
    if (timer !== undefined) clearInterval(timer);
    timer = undefined;
    streaming.set(false);
  };
  cleanup(stop);
  // Replays the reply a few characters at a time, as a model streams it.
  const play = () => {
    stop();
    const tokens = REPLY.match(/\s*\S{1,4}|\s+/g) ?? [];
    let i = 0;
    text.set("");
    streaming.set(true);
    timer = setInterval(() => (i < tokens.length ? text.set(text() + tokens[i++]) : stop()), 40);
  };
  return (
    <Scroll style={{ flex: 1, backgroundColor: ui().page }}>
      <View style={{ width: "100%", maxWidth: 680, alignSelf: "center", padding: 20, gap: 16 }}>
        <TextView
          markdown={text()}
          options={{ streaming: streaming() }}
          streamingCaret={streaming() ? <View style={{ width: 8, height: 16, marginLeft: 2, borderRadius: 2, backgroundColor: ui().accent }} /> : undefined}
          selectable
        />
        <View
          onPress={play}
          disabled={streaming()}
          {...button()}
        >
          <Text style={buttonText()}>{streaming() ? "Streaming…" : "Stream it again"}</Text>
        </View>
      </View>
    </Scroll>
  );
}

mount(() => <App />);

它自行負責行為,並附帶專屬的閱讀排版:15pt 文字配寬鬆的 25.5pt 行高、半粗體標題且上方留有空間、連結以主題的 primary 顏色加底線、程式碼使用主題的 font-mono 並置於有邊框的圓角方框中、表格四邊皆有線並帶有淡色表頭列、引用旁帶一條線,而提示框則是以 GitHub 提示顏色加上圖示的淡色方框。其顏色來自主題的 token(foreground、muted-foreground、border、muted、primary,以及用於警示的 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::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() 建立視圖:每個 prop 一個 setter(.markdown(..)、.options(..)、.selectable(..))、每個渲染函式一個閉包(.render_image(|image| ..))、每個事件一個監聽器(.on_link_press(..)),以及用於根的類型化樣式方法(.p_5()、.text_color(..)),其文字樣式會傳給每個部分。

#建基於 Text

TextView 由 Text 元素組成,其文字行為完全如 Text 頁面所述。每個段落、標題、表格儲存格和程式碼區塊都是一個原生的 Text:一個會排版、換行、跟隨字體縮放,並且在配合 selectable 時可選取及複製的區塊。粗體、斜體、刪除線及其他行內標記是它的 span;行內程式碼是帶有 span 背景 的 span,因此 code: "rounded bg-neutral-100" 會為它加上圓角背景;連結是 可按壓的 span;圖片及行內自訂節點是 行內附件。根上的文字樣式會傳給每個部分,就像 文字樣式 由 Text 傳給其 span 一樣。

配合 selectable,整份文件可像網頁一樣整體選取:拖曳(在觸控螢幕上則為長按再拖動控點)可由一個段落、清單項目、儲存格或程式碼區塊延伸至下一個,而複製會取得所有這些內容的文字。selectable TextView 會自行單獨選取;在 可選取視圖 內,其選取範圍亦會延伸至周圍的文字及其他 TextView。

TextView 所新增的是其餘部分:將 Markdown、HTML 或文件讀取為區塊、文字周圍的視圖(清單、引用、表格、程式碼區塊、展開區塊、註腳)、串流、按部分設定樣式、渲染函式及擴充。

#文件

內容為三個 prop 之一,按此順序讀取:

  • 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::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 接受 class。classes 按部分接受 Tailwind class,styles 按部分接受行內樣式。部分的 class 排在其自身預設樣式之後,因此優先;其 styles 則優先於其 class。在 Rust 中,各部分接受類型化樣式:TextViewParts 為每個 TextViewPart 提供一個細化其樣式的函式,作用於提供它的視圖以下的每個 TextView(類型化部分樣式),而根則接受樣式方法。預設值中的行高以點(pt)設定:更改某部分大小的 class 也會一併設定其行高(Tailwind 的 text-* class 即如此)。

use nsh_base::{TextViewPart, TextViewParts, text_view, theme};
use nsh_core::prelude::*;

fn reply(markdown: Signal<SharedString>) -> impl IntoElement {
    provide_context(
        TextViewParts::new()
            .part(TextViewPart::Heading, |s| s.text_color(rgb(0x0a0a0a)))
            .part(TextViewPart::Heading2, |s| s.text_xl().mt_2())
            .part(TextViewPart::Blockquote, |s| {
                s.border_l(px(2.0)).border_color(rgb(0xd4d4d4)).pl_3().text_color(rgb(0x525252))
            })
            .part(TextViewPart::CodeBlock, |s| s.rounded_lg().bg(rgb(0xf5f5f5)))
            .part(TextViewPart::CodeBlockHeader, |s| s.px_3().pt_2())
            .part(TextViewPart::CodeBlockCode, |s| s.p_3().text_size(px(13.0)))
            .part(TextViewPart::Table, |s| s.rounded_lg().border_1().border_color(rgb(0xe5e5e5)))
            .part(TextViewPart::TableHead, |s| s.bg(rgb(0xfafafa)))
            .part(TextViewPart::TableCell, |s| {
                s.px_3().py_2().border_t_1().border_color(rgb(0xe5e5e5))
            })
            .part(TextViewPart::TaskMarkerChecked, |s| s.text_color(rgb(0x16a34a)))
            .part(TextViewPart::Link, |s| s.text_color(theme().primary)),
    );
    text_view()
        .markdown(move || markdown.get())
        .gap_3()
        .text_size(px(15.0))
        .line_height(px(24.0))
        .text_color(rgb(0x262626))
        .font_family("Inter")
}

有些節點由數個部分組成,並按順序套用:2 級標題是 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 時由其自行排版。
mathBlock MathBlock 公式區塊,在沒有 renderMath 時由其自行排版並置中。
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, a table whose header just arrived);串流會將它顯示為閉合後的樣子,讀者因而不會看到多餘的 ** 或 [。區塊按位置保留:已完成的區塊保留其視圖,只有正在寫入的區塊會重新建立,因此長回覆的更新成本依然低廉。

streamingCaret 跟隨文件末端:若最後一個區塊在行內結束(段落、清單項目、程式碼區塊、表格的最後一個儲存格),它便位於最後一段文字的末端,否則另起一行置於最後一個區塊之後(分隔線、渲染函式的內容)。回覆完成後請將它移除。

use nsh_base::{MarkdownOptions, text_view};
use nsh_core::prelude::*;

/// A reply the app appends to as chunks arrive, with `done` set at the end.
fn reply(text: Signal<SharedString>, done: Signal<bool>) -> impl IntoElement {
    text_view()
        .markdown(move || text.get())
        .options(move || MarkdownOptions { streaming: !done.get(), ..Default::default() })
        .streaming_caret(when(
            move || !done.get(),
            || view().ml_0p5().h_4().w_2().rounded_sm().bg(rgb(0xa3a3a3)),
        ))
}

插入符是應用程式自己的視圖,因此可用 nsh 的動畫 API 閃爍或脈動,新區塊亦可配合應用程式的動畫淡入。

#連結

連結是 可按壓的 span。按壓連結會呼叫 onLinkPress,並傳入 { url };開啟甚麼由應用程式決定(自己的頁面、WebView、捲動至某個標題)。標題有錨點(GitHub 式的:## Getting started 是 #getting-started),因此指向 #getting-started 的連結會以該字串送達,而註腳參照則以 #footnote-<label> 送達。

use nsh_base::{TextViewLinkPressEvent, text_view};
use nsh_core::prelude::*;

fn doc(markdown: &'static str) -> impl IntoElement {
    text_view().markdown(markdown).on_link_press(|TextViewLinkPressEvent { url, .. }: &TextViewLinkPressEvent| {
        match url.strip_prefix('#') {
            Some(anchor) => scroll_to_anchor(anchor),
            None => open_web(url),
        }
    })
}

scroll_to_anchor 和 open_web(TSX 中為 scrollToAnchor 和 nav)代表應用程式自己的函式。

設定 links="open" 後,按壓指向其他網站或應用程式的連結(帶 scheme 的 URL:https:、mailto:、……)時,亦會以 openUrl 在應用程式外開啟,受應用程式的 openUrl 權限規限,並且在 onLinkPress 回報之後;文件內的錨點(#…)則只會回報。預設值 "report" 不會開啟任何內容。

use nsh_base::{TextViewLinks, text_view};

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

#渲染函式

渲染函式將應用程式自己的內容放入文件。每個函式接收某個部分的值,並回傳其內容(TSX 中為 JSX,Rust 中為元素);只要值不變,該部分便會保留為它渲染的內容,因此串流期間只有正在寫入的部分會重新渲染。

Prop 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 像 Clipboard 一樣寫入剪貼簿,因此需要應用程式的 clipboard 權限(nsh.json)。

use nsh_base::{TextViewCodeAction, TextViewCodeActionEvent, text_view};
use nsh_core::prelude::*;

fn reply(markdown: Signal<SharedString>) -> impl IntoElement {
    text_view()
        .markdown(move || markdown.get())
        .code_block_actions(vec![
            TextViewCodeAction::new("copy").with_label("Copy code"),
            TextViewCodeAction::new("run").with_label("Run"),
        ])
        .render_code_block_action(|_action: &TextViewCodeAction| text("▶").text_xs())
        .on_code_block_action(|event: &TextViewCodeActionEvent| {
            if event.id == "run" {
                run(&event.code.code);
            }
        })
}

程式碼區塊開箱即有語法突顯:對於圍欄上指明的語言(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::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 時,TextView 會自行排版公式(math 和 mathBlock 部分),使用 KaTeX 的 TeX 支援與字型,以文字的顏色顯示,在每個平台上都一致;無法讀取的 TeX 則以等寬的原始碼顯示。

#Markdown 擴充功能

extensions 把應用程式自己的語法轉換為自訂節點,並由 renderCustom 顯示。每個擴充功能都有一個 name(即其所產生節點的 name),以及四種語法之一:

種類 Rust 匹配對象 節點
pattern MarkdownExtension::pattern(name, regex) 文字中的正規表示式(Rust 的 regex 語法),程式碼和連結之外 行內節點;text 為匹配內容,attributes 為其群組:"1"、"2"、…,具名群組則以名稱存取
language MarkdownExtension::language(name, language) 該語言的圍欄程式碼區塊(```chart) A block; text is the code, 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::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)。沒有 render 函式的自訂節點會顯示其內容。行內節點所回傳的 view(例如 chip)會按自身的 verticalAlign 放置在行內:align-middle(verticalAlign: "middle")會使其對齊文字中線,除非該 view 較高,否則該行會保持段落的 lineHeight。

#選項

options 選擇在 CommonMark 和 GitHub 語法之外所讀取的 Markdown 語法;在 Rust 中則是具有相同欄位的 MarkdownOptions(MarkdownOptions { streaming: true, ..Default::default() }):

鍵 型別 預設值 說明
math boolean true 公式:文字中用 $…$,區塊用 $$…$$。
html boolean true Markdown 內的 HTML 會按 html prop 的讀取方式處理;false 則將其顯示為文字。
alerts boolean true GitHub 的 alerts:> [!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 prop 和 render 函式的值都是純物件,每個節點以其 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 是 enum,其 variant 即下列型別的 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 prop 會顯示的 TextViewDocument。
TextView.parseHtml(html, extensions?) TextView::parse_html(html, extensions) html prop 會顯示的 TextViewDocument。
TextView.plainText(document) TextView::plain_text(document)、Document::plain_text() 文件不含標記的文字:區塊以空行分隔,清單項目和表格列以換行分隔,儲存格以 Tab 分隔。

整則回覆的複製按鈕:

use nsh_base::{TextView, button};
use nsh_core::prelude::*;

fn copy_reply(reply: Signal<SharedString>) -> impl IntoElement {
    button()
        .on_press(move || {
            let document = TextView::parse(reply.get().to_string(), None, None);
            set_clipboard(TextView::plain_text(document));
        })
        .child(text("Copy"))
}

應用程式也可以只解析一次並保留該文件(document={doc}),或在顯示前修改它。

#在 Rust 中

Rust 的 view 使用來自 nsh-base crate 的同一個元件,上述範例可在其 Rust 分頁中找到。有三項是 Rust 獨有的:應用程式的 highlighter、按部件設定的具型別樣式,以及 Markdown 外掛,三者都透過 provide_context 提供給 view 之下的每個 TextView。文件型別位於 nsh_base::text_view 之下,Parser 亦可配合外掛讀取文件(Parser::new().plugin(..).markdown(source))。

#語法突顯

TextView 使用 syntect,按 Allsum 的程式碼色板突顯程式碼(nsh-base 的功能 highlight,預設啟用);較大的區塊會先以純文字即時顯示,待背景執行緒完成突顯後再套用顏色。CodeHighlighter 是取而代之的應用程式自訂 highlighter,透過 provide_context 提供給 view 之下的每個 TextView。它會取得每個程式碼區塊,並回傳其程式碼中各 byte 範圍的樣式(按順序且不重疊;未涵蓋的部分為純文字);TextView 會在其慣常的程式碼區塊(標題、操作、捲動 view、部件)內,以這些樣式將程式碼顯示為多個 span。highlighter 可以是任何能將程式碼轉為帶樣式範圍的東西:tree-sitter、使用應用程式自選主題的 syntect,等等。

use std::ops::Range;

use nsh_base::{CodeHighlighter, text_view::CodeBlock};
use nsh_core::{element::StyleRefinement, prelude::*};

/// The app's highlighter (here with syntect, set up once by the app).
fn highlight(code: &CodeBlock) -> Vec<(Range<usize>, StyleRefinement)> {
    my_syntect::ranges(code.language.as_deref(), &code.code)
        .map(|(range, color, bold)| {
            let style = StyleRefinement::default().text_color(color);
            (range, if bold { style.font_weight(FontWeight::BOLD) } else { style })
        })
        .collect()
}

fn chat() -> impl IntoElement {
    provide_context(CodeHighlighter::new(highlight));
    view().child(messages())
}

my_syntect::ranges 代表應用程式與其 highlighter 之間的銜接程式碼,messages 代表應用程式的聊天 view。如有提供 renderCodeBlock,則由它取代以上所有內容來顯示程式碼區塊。

#具型別的部件樣式

TextViewParts 以具型別的樣式取代 class 字串來設定部件樣式,供 Rust 的 view 使用:每個 TextViewPart(對應上表中的每個部件)都有一個用來調整其 StyleRefinement 的函式。透過 provide_context 提供後,它們會套用於其下的每個 TextView,先於其 classes 和 styles;其中讀取的主題顏色會隨淺色和深色外觀變化。

use nsh_base::{TextViewPart, TextViewParts, theme};
use nsh_core::prelude::*;

fn chat_parts() {
    provide_context(
        TextViewParts::new()
            .part(TextViewPart::Heading1, |s| s.text_2xl().font_weight(FontWeight::BOLD))
            .part(TextViewPart::Code, |s| s.bg(theme().muted).rounded(px(4.0)))
            .part(TextViewPart::Link, |s| s.text_color(theme().primary)),
    );
}

#Markdown 外掛

MarkdownPlugin 在 Rust 中讀取 Markdown 語法:它會在解析器讀取語法樹的每個節點(markdown-rs 的 mdast)之前先取得該節點,並回傳自己的區塊或行內內容,通常是 CustomNode;它亦可顯示自己產生的自訂節點。view 會向其下的每個 TextView 提供外掛:

use nsh_base::text_view::{mdast, CustomNode, Inline, MarkdownPlugin, MarkdownPlugins, ParseContext};
use nsh_core::prelude::*;

struct Tickers;

impl MarkdownPlugin for Tickers {
    fn name(&self) -> &str {
        "tickers"
    }

    /// `$AAPL` in inline code becomes a ticker node.
    fn inline(&self, node: &mdast::Node, _cx: &mut ParseContext<'_>) -> Option<Inline> {
        let mdast::Node::InlineCode(code) = node else { return None };
        let symbol = code.value.strip_prefix('$')?;
        Some(Inline::Custom(CustomNode::new("ticker").with_text(symbol)))
    }

    fn render(&self, node: &CustomNode) -> Option<AnyElement> {
        (node.name == "ticker").then(|| text(node.text.clone()).font_weight(FontWeight::BOLD).into_element())
    }
}

fn chat() -> impl IntoElement {
    provide_context(MarkdownPlugins::new().with(Tickers));
    view().child(messages())
}

外掛的 render 排在最前,其次是 renderCustom,然後才是節點的內容。configure 會啟用更多 markdown-rs 的語法,ParseContext 則按解析器的方式讀取節點的子節點。

#Props

Prop 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 class。
styles .styles(..) TextViewStyles {} 按部件設定的行內樣式(每項為 StyleProp<TextStyle>),優先於 class。
selectable .selectable(..) boolean false 整份文件可作為一個整體選取:選取範圍可跨越其段落、標題、儲存格和程式碼區塊(文字選取)。
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 中,按部件設定的 class 和樣式為 TextViewClasses 和 TextViewStyles;Rust 的 view 通常以 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| ..) 按下了不在連結內的圖片:{ image },供應用程式以較大尺寸顯示(應用程式自己的可縮放檢視器)。

#無障礙

螢幕閱讀器會按順序逐段朗讀文件。TextView 為其結構提供角色:標題為 header,連結為 link(其標題為提示),任務標記為帶有勾選狀態的核取方塊,disclosure 的摘要為會說明是否已展開的按鈕,圖片則朗讀其 alt 文字。

#相關內容

  • Text:TextView 中每段文字所用的元素,包括其 span、連結、行內附件和選取。
  • 文字選取:跨越多個 TextView 和文字的單一選取。
  • nsh/base:其他基礎元件。
  • 樣式與 Tailwind。