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

元素

TextView

TextView 可顯示以 Markdown、HTML 撰寫的文件,或由應用程式建構的區塊:標題、段落、強調、連結、清單與任務清單、引用與 GitHub 的 alerts、程式碼、表格、圖片、公式、註腳與摺疊區塊。它是為聊天中 AI 模型的回覆而設計:回覆以 Markdown 串流傳入,每個片段都會連同格式一併顯示文字。解析在 Rust 中進行,文件則以 Text 元素與 view 繪製。

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 alert 顏色的底色方框 alerts。其顏色為佈景主題的 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() 建立 view:每個 prop 一個 setter(.markdown(..)、.options(..)、.selectable(..))、每個渲染函式一個 closure(.render_image(|image| ..))、每個事件一個 listener(.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 會自成一個選取範圍;在 可選取的 view 內,其選取範圍也會延伸到周圍的文字與其他 TextView。

TextView 所增添的是其餘部分:將 Markdown、HTML 或文件讀入為區塊、文字周圍的 view(清單、引用、表格、程式碼區塊、摺疊區塊、註腳)、串流、依部分區分的樣式、渲染函式與擴充功能。

#文件

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

  • markdown:CommonMark 加上 GitHub 的擴充(表格、任務清單、刪除線、自動連結、註腳、alerts)、公式($…$ 與 $$…$$),以及 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 的型別與 view 相同),classes.root 接受 class。classes 依部分接受 Tailwind class,styles 依部分接受行內樣式。部分的 class 排在其預設樣式之後,因此會優先;其 styles 又優先於其 class。在 Rust 中,各部分接受型別樣式:TextViewParts 為每個 TextViewPart 提供一個調整其樣式的函式,作用於提供它的 view 之下的每個 TextView(型別部分樣式),根節點則接受樣式方法。預設值中的行高以 point 為單位設定:更改部分大小的 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;alert 為 blockquote、alert,然後是其種類(alertNote,…);任務標記為 listMarker、taskMarker,完成時再加上 taskMarkerChecked;表頭列為 tableRow 接著 tableHead,其儲存格為 tableCell 接著 tableHeaderCell。

部分Rust說明
rootRoot文字 view 本身,由其區塊組成的一欄;此處的文字樣式會傳遞給每個部分。
paragraphParagraph每個段落。
headingHeading每個標題,排在其級別的部分之前。
heading1 … heading6Heading1 … Heading6該級別的標題(27、20、17,其後為 15pt,半粗體)。
blockquoteBlockquote引用與 alerts:由其區塊組成的一欄,側邊帶有一條線。
alertAlert每個 alert(> [!NOTE]),排在 blockquote 之後:帶底色的方框,由其圖示與區塊組成的一列。
alertNote、alertTip、alertImportant、alertWarning、alertCautionAlertNote、AlertTip、AlertImportant、AlertWarning、AlertCaution該種類的 alerts。
alertIconAlertIconalert 的圖示:一個圓環,內有採用 alert 顏色的圖形符號。
listList每個清單:由其項目組成的一欄。
orderedList、unorderedListOrderedList、UnorderedList編號清單與項目符號清單,排在 list 之後。
listItemListItem每個項目:由其標記與區塊組成的一列。
listMarkerListMarker項目的標記(依深度為 •、◦、▪,或 1.),在項目前的縮排中靠右對齊。
taskMarkerTaskMarker任務項目的標記:以 view 繪製的方框,完成時填入 ✓;排在 listMarker 之後。
taskMarkerCheckedTaskMarkerChecked已完成任務項目的標記,排在 taskMarker 之後。
listItemContentListItemContent項目的區塊所在的一欄。
codeBlockCodeBlock程式碼區塊:有邊框的圓角方框,內含程式碼,上方為標頭。
codeBlockHeaderCodeBlockHeader程式碼區塊的動作列(codeBlockActions),浮在其右上角之上。
codeBlockScrollCodeBlockScroll包住程式碼區塊之程式碼的水平捲動 view。
codeBlockCodeCodeBlockCode程式碼區塊的程式碼(等寬字型,照原樣顯示)。
codeCode文字中的程式碼(等寬字型,圓角背景)。
keyboardKeyboard按鍵(<kbd>,視為程式碼)。
strongStrong粗體文字(半粗體)。
emphasisEmphasis強調文字(斜體)。
strikethroughStrikethrough刪除線文字。
underlineUnderline加底線文字(<u>、<ins>)。
highlightHighlight標示文字(<mark>,黃色圓角底)。
superscript、subscriptSuperscript、Subscript上標與下標(較小)。
linkLink連結(佈景主題的 primary 顏色,加底線)。
imageImage圖片。
mathMath文字中的公式,在沒有 renderMath 時由內建排版呈現。
mathBlockMathBlock公式區塊,在沒有 renderMath 時排版並置中。
tableTable表格:renderTableActions 的內容,然後是儲存格的捲動 view。
tableScrollTableScroll表格儲存格的水平捲動 view:空間足夠時各欄貼合其內容,view 較窄時換行縮減至下限,超過後表格才會捲動。
tableRowTableRow表格的每一列:位於其儲存格後方的 view,用於背景與邊框(文字樣式請設於 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, a table whose header just arrived);串流會將其顯示為閉合後的樣子,因此讀者不會看到多餘的 ** 或 [。區塊依位置保留:已完成的區塊保留其 view,只有正在撰寫的區塊會重新建立,因此長回覆的更新成本依然很低。

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

插入符是應用程式自己的 view,因此可以透過 nsh 的動畫 API 閃爍或脈動,新區塊也可以用應用程式的動畫淡入。

#連結

連結是 可按壓的 span。按壓連結時會以 { url } 呼叫 onLinkPress;要開啟什麼由應用程式決定(自己的頁面、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:,…),在 onLinkPress 回報之後,也會依應用程式的 openUrl 權限,以 openUrl 在應用程式外開啟;文件中的錨點(#…)則只回報。預設值 "report" 不會開啟任何東西。

use nsh_base::{TextViewLinks, text_view};

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

#渲染函式

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

PropRust值取代或新增
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 擴充功能)。

程式碼區塊的右上角帶有動作,繪製方式一致:ghost 按鈕,背景僅在懸停或按下時顯示;在桌面上指標位於區塊上方時才顯示,在觸控螢幕上則一律顯示。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 view 則以 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比對對象節點
patternMarkdownExtension::pattern(name, regex)文字中的規則運算式(Rust 的 regex 語法),不含程式碼與連結行內節點;text 是比對結果,attributes 是其群組:"1"、"2"、…,具名群組則以名稱表示
languageMarkdownExtension::language(name, language)該語言的圍欄程式碼區塊(```chart)A block; text is the code, 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::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 函式的自訂節點會直接顯示其內容。行內節點(例如 chip)所回傳的視圖,會依其自身的 verticalAlign 放在所在行中:align-middle(verticalAlign: "middle")會使其對齊文字中線,除非視圖較高,否則該行會維持段落的 lineHeight。

#選項

options 用來選擇除了 CommonMark 與 GitHub 之外還要讀取的 Markdown 語法;在 Rust 中它是具有相同欄位的 MarkdownOptions(MarkdownOptions { streaming: true, ..Default::default() }):

鍵類型預設值說明
mathbooleantrue公式:文字中使用 $…$,區塊使用 $$…$$。
htmlbooleantrueMarkdown 中的 HTML 會依 html prop 的方式讀取;false 則將其顯示為文字。
alertsbooleantrueGitHub 的警示:> [!NOTE]、[!TIP]、[!IMPORTANT]、[!WARNING]、[!CAUTION]。
frontmatterbooleanfalseYAML front matter(開頭為 --- … ---)會成為自訂節點 frontmatter,其 text 為該 YAML。
mdxbooleanfalseMDX:JSX 元素會成為以其標籤命名的自訂節點,{expressions} 自訂節點 expression;此時原始 HTML 會被當作文字讀取。
streamingbooleanfalse文字仍在持續傳入:結尾處尚未閉合的語法會被當作已閉合來顯示。

#文件模型

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 是列舉,其變體為下列型別的 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 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 視圖使用來自 nsh-base crate 的相同元件,上述範例也都有對應的 Rust 分頁。有三項功能是 Rust 獨有的:應用程式的語法醒目提示器、依部件設定的具型別樣式,以及 Markdown 外掛,它們都透過 provide_context 提供給視圖下方的每個 TextView。文件型別位於 nsh_base::text_view 之下,Parser 也能搭配外掛讀取文件(Parser::new().plugin(..).markdown(source))。

#語法醒目提示

TextView 使用 syntect,以 Allsum 的程式碼調色盤醒目提示程式碼(nsh-base 的功能 highlight,預設開啟);大型區塊會先以純文字立即顯示,待背景執行緒完成醒目提示後再套用顏色。CodeHighlighter 是應用程式自己的醒目提示器,用來取代預設者,並透過 provide_context 提供給視圖下方的每個 TextView。它會接收每個程式碼區塊,並回傳其程式碼中各位元組範圍的樣式(依順序、互不重疊;未涵蓋的部分為純文字);TextView 會將程式碼以套用這些樣式的 span 顯示,並放在原有的程式碼區塊內(標頭、動作、捲動視圖、部件)。醒目提示器可以是任何能將程式碼轉為帶樣式範圍的東西: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 代表應用程式與其醒目提示器之間的銜接程式碼,messages 代表應用程式的聊天視圖。若有提供 renderCodeBlock,則會以它取代上述所有內容來顯示程式碼區塊。

#具型別的部件樣式

TextViewParts 以具型別的樣式取代 class 字串來設定部件樣式,適用於 Rust 視圖:每個 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;它也可以顯示自己建立的自訂節點。視圖會將外掛提供給其下方的每個 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

PropRust類型預設值說明
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(..)booleanfalse文件可作為一個整體選取:選取範圍可跨越其段落、標題、儲存格與程式碼區塊(文字選取)。
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 中,依部件設定的 class 與樣式為 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| ..)不在連結中的圖片被按下:{ image },供應用程式以較大尺寸顯示(應用程式自己的可縮放檢視器)。

#無障礙

螢幕閱讀器會依序逐段朗讀文件。TextView 為文件結構賦予角色:標題是 header,連結是 link(其 title 作為提示),任務標記是帶有勾選狀態的 checkbox,disclosure 的摘要是會說明是否已展開的按鈕,圖片則以其替代文字朗讀。

#相關內容

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