要素
TextView
TextView は、Markdown、HTML、またはアプリが組み立てたブロックで書かれたドキュメントを表示します。見出し、段落、強調、リンク、リストとタスクリスト、引用と GitHub のアラート、コード、表、画像、数式、脚注、ディスクロージャーに対応します。チャットでの AI モデルの返信のために作られており、返信は Markdown としてストリームで届き、チャンクごとに書式が適用されたテキストが表示されます。パースは Rust で行われ、ドキュメントは Text 要素とビューで描画されます。
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 のアラート色のアイコン付きの色付きボックスで表示します。色はテーマのトークン(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),
)
}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 では、view() がビューを作るのと同じように text_view() で作成します。props ごとのセッター(.markdown(..)、.options(..)、.selectable(..))、レンダー関数ごとのクロージャ(.render_image(|image| ..))、イベントごとのリスナー(.on_link_press(..))、そしてルート用の型付きスタイルメソッド(.p_5()、.text_color(..))があり、そのテキストスタイルは各パーツに渡されます。
#Text をもとに
TextView は Text 要素でできており、そのテキストは Text のページの説明どおりに動作します。すべての段落、見出し、表のセル、コードブロックは 1 つのネイティブな Text です。レイアウト、折り返し、フォントスケールへの追従を行い、selectable を指定すれば選択とコピーもできる 1 つのブロックです。強調、斜体、取り消し線などのインラインマークは スパン です。インラインコードは スパンの背景 を持つスパンなので、code: "rounded bg-neutral-100" で角丸の背景が付きます。リンクは 押せるスパン、画像とインラインのカスタムノードは インラインアタッチメント です。ルートのテキストスタイルは、Text から各スパンへ テキストスタイル が渡るのと同様に、すべてのパーツに渡されます。
selectable を指定すると、Web ページのようにドキュメント全体が 1 つとして選択できます。ドラッグ(タッチスクリーンでは長押しとハンドル)で、ある段落、リスト項目、セル、コードブロックから次のものへ範囲が伸び、コピーではそのすべてのテキストが取得されます。selectable TextView は単独で 1 つとして選択されます。選択可能なビュー の内側では、その選択は周囲のテキストや他の TextView にも続きます。
TextView が加えるのはそれ以外の部分です。Markdown、HTML、ドキュメントをブロックへ読み取ること、テキストの周りのビュー(リスト、引用、表、コードブロック、ディスクロージャー、脚注)、ストリーミング、パーツごとのスタイル、レンダー関数、拡張です。
#ドキュメント
コンテンツは次の 3 つの props のいずれかで、この順に読まれます。
markdown: GitHub の拡張(表、タスクリスト、取り消し線、オートリンク、脚注、アラート)付きの CommonMark、数式($…$と$$…$$)、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 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 は、それを提供するビューの下にあるすべての TextView について、各 TextViewPart にスタイルを調整する関数を与えます(型付きパーツスタイル)。ルートにはスタイルメソッドを使います。デフォルトの行の高さはポイントで設定されているため、パーツのサイズを変えるクラスは行の高さも設定してください(Tailwind の text-* クラスはそうなっています)。
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")
}<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" } }}
/>ノードによっては複数のパーツになり、順に適用されます。レベル 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)),
))
}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 のアニメーション API で点滅やパルスをさせたり、新しいブロックをアプリのアニメーションでフェードインさせたりできます。
#リンク
リンクは 押せるスパン です。押すと 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),
}
})
}<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" を指定すると、他のサイトやアプリへのリンク(スキーム付きの 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 では要素)を返します。パーツは値が変わらない間、レンダーされた内容を保持するため、ストリーミング中は書き込み中のパーツだけが再レンダーされます。
| 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);
}
})
}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 ほか約 200 種)、コードは Allsum のコードパレットで表示され、アピアランスに応じてライトまたはダークになります。言語のないブロックはそのまま表示されます。独自のハイライトを持つアプリは、自前のコンポーネントや Rust クレートのコンポーネントから renderCodeBlock でコードを表示します。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))
}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 クレートのコンポーネント)を表します。renderMath がない場合、TextView は KaTeX の TeX サポートとフォントを使い、テキストの色で、どのプラットフォームでも同じように数式を自分で組版します(math と mathBlock パーツ)。読み取れない TeX は等幅のソースとして表示されます。
#Markdown extensions
extensions はアプリ独自の構文をカスタムノードに変換し、renderCustom がそれを表示します。各拡張には name(生成されるノードの name)と、次の 4 種類の構文のいずれかがあります。
| 種類 | 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 は info 文字列の残り) |
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)
}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)はアプリ独自のチャートを表します。メンションパターンのグループは node.attributes?.["1"](Rust では node.attributes["1"]: ada)に、マッチ全体は node.text(@ada)に入ります。render 関数のないカスタムノードは、その内容を表示します。
インラインノードに対して返されたビュー(チップ)は、自身の verticalAlign に従って行内に配置されます。align-middle(verticalAlign: "middle")はテキストに対して中央揃えにし、ビューの方が高くない限り、その行は段落の lineHeight を保ちます。
#Options
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 フロントマター(先頭の --- … ---)は、text が YAML であるカスタムノード frontmatter になります。 |
mdx | boolean | false | MDX: JSX 要素はタグ名を名前とするカスタムノードになり、{expressions} はカスタムノード expression になります。この場合、生の HTML はテキストとして読み取られます。 |
streaming | boolean | false | テキストがまだ到着中: 末尾で閉じられていない構文は、閉じられているものとして表示されます。 |
#The document model
document プロップと 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 にしたものをバリアントとする enum で(Block::Heading { level, content, anchor }、Inline::Strong { content })、フィールドは同じものをスネークケースにしています。
ブロック 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 が表示するカスタムノードを混在させたりできます。
#Parsing and plain text
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::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})、表示する前に変更したりすることもできます。
#In Rust
Rust のビューは nsh-base クレートの同じコンポーネントを使用し、上記の例には Rust タブがあります。次の 3 つは Rust 固有です: アプリのハイライタ、パーツごとの型付きスタイル、Markdown プラグイン。これらはいずれも、provide_context によってビュー以下のすべての TextView に渡されます。ドキュメントの型は nsh_base::text_view にあり、Parser はプラグイン付きでドキュメントを読み取ることもできます(Parser::new().plugin(..).markdown(source))。
#Syntax highlighting
TextView は、Allsum のコードパレットで syntect を使ってコードをハイライトします(nsh-base のフィーチャー highlight。デフォルトで有効)。大きなブロックはまずプレーンに表示され、バックグラウンドスレッドがハイライトを終えると色が付きます。
CodeHighlighter はこれに代わるアプリ独自のハイライタで、provide_context によってビュー以下のすべての TextView に渡されます。各コードブロックを受け取り、そのコードのバイト範囲に対するスタイルを返します(順序どおりで、重複しないこと。含まれない部分はプレーンになります)。TextView は、通常のコードブロック(ヘッダー、アクション、スクロールビュー、パーツ)の内側に、それらのスタイルを持つスパンとしてコードを表示します。ハイライタは、コードをスタイル付きの範囲に変換するものであれば何でも構いません: 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 が指定されている場合は、これらすべての代わりにコードブロックを表示します。
#Typed part styles
TextViewParts は、Rust のビュー向けに、クラス文字列の代わりに型付きスタイルでパーツをスタイリングします。各 TextViewPart(上の表のパーツごとに 1 つ)は、その 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 plugins
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
| プロップ | Rust | 型 | デフォルト | 説明 |
|---|---|---|---|---|
markdown | .markdown(..) | string | Markdown としてのドキュメント。 | |
html | .html(..) | string | markdown が指定されていない場合の、HTML としてのドキュメント。 | |
document | .document(..) | TextViewDocument | markdown も html も指定されていない場合の、ブロックとしてのドキュメント。 | |
options | .options(..) | MarkdownOptions | すべてデフォルト | 読み取る Markdown 構文(Options)。 |
extensions | .extensions(..) | MarkdownExtension[] | [] | アプリ独自の構文(カスタムノードとして)(Markdown extensions)。 |
classes | .classes(..) | TextViewClasses | {} | パーツごとの Tailwind クラス。 |
styles | .styles(..) | TextViewStyles | {} | パーツごとのインラインスタイル(それぞれ StyleProp<TextStyle>)。クラスより優先されます。 |
selectable | .selectable(..) | boolean | false | ドキュメント全体を 1 つとして選択できます。選択範囲は段落、見出し、セル、コードブロックをまたぎます(Text selection)。 |
links | .links(..) | "report" | "open" | "report" | リンクを押したときの動作: それを通知し(onLinkPress)、"open" の場合は、他のサイトやアプリへのリンクをアプリの外で開きます(Links)。 |
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でパーツをスタイリングします。
#Events
| イベント | 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 }。アプリが画像を拡大表示するためのものです(アプリ独自のズーム可能なビューア)。 |
#Accessibility
スクリーンリーダーは、ドキュメントを順番に、テキストごとに読み上げます。TextView は構造にロールを与えます。見出しはヘッダー、リンクはリンク(そのタイトルがヒント)、タスクマーカーはチェック状態を持つチェックボックス、ディスクロージャーのサマリーは展開されているかどうかを伝えるボタン、画像は alt テキストで読み上げられます。
#Related
- Text:
TextViewのすべてのテキストを構成する要素で、スパン、リンク、インラインアタッチメント、選択を備えます。 - Text selection: 複数の
TextViewとテキストにまたがる 1 つの選択。 nsh/base: その他の基本コンポーネント。- Styling と Tailwind。