Эта страница переведена машинным способом и может содержать ошибки. Читать оригинал на английском

Элементы

TextView

TextView показывает документ, написанный на Markdown, на HTML или в виде блоков, которые строит приложение: заголовки, абзацы, выделение, ссылки, списки и списки задач, цитаты и alerts GitHub, код, таблицы, изображения, формулы, сноски и раскрывающиеся блоки. Он создан для ответов ИИ-моделей в чате: ответ приходит потоком в виде 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, заголовки semibold с отступом сверху, ссылки, подчёркнутые цветом primary темы, код шрифтом font-mono темы в рамке со скруглёнными углами, таблицы с линиями со всех сторон и тонированной строкой заголовка, цитаты с линией сбоку и alerts в виде тонированных блоков со значком в цветах alerts GitHub. Его цвета — токены темы (foreground, muted-foreground, border, muted, primary и destructive для предупреждения), поэтому тема и тёмное оформление действуют и здесь. Каждая часть принимает стили приложения поверх этих значений по умолчанию: classes и styles в TSX, типизированные стили по частям (TextViewParts) в Rust. В Rust его создаёт nsh_base::text_view(), в TSX — <TextView> из nsh/base.

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() создаёт представление: сеттер для каждого свойства (.markdown(..), .options(..), .selectable(..)), замыкание для каждой render-функции (.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 выделяется сам по себе; внутри выделяемого представления его выделение продолжается и в окружающие тексты и другие TextView.

Всё остальное добавляет TextView: чтение Markdown, HTML или документа в виде блоков, представления вокруг текстов (списки, цитаты, таблицы, блоки кода, раскрывающиеся блоки, сноски), потоковую передачу, стили по частям, render-функции и расширения.

#Документ

Содержимое задаётся одним из трёх свойств, которые читаются в таком порядке:

  • markdown: CommonMark с дополнениями GitHub (таблицы, списки задач, зачёркивание, автоссылки, сноски, alerts), формулами ($…$ и $$…$$) и HTML внутри Markdown.
  • html: HTML в том виде, как его показывает режим чтения. Текст, структура и встроенное форматирование сохраняются; скрипты, стили, элементы форм и скрытые элементы отбрасываются. Подходит как фрагмент, так и целая страница (показывается её body).
  • document: блоки, которые приложение строит или хранит: обычные объекты в TSX, nsh_base::text_view::Document в Rust (Модель документа).
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 — классы. classes принимает классы Tailwind по частям, а styles — инлайновые стили по частям. Классы части идут после её собственных стилей по умолчанию, поэтому имеют приоритет; её styles имеют приоритет над её классами. В Rust части принимают типизированные стили: TextViewParts даёт каждому TextViewPart функцию, уточняющую его стиль, для каждого TextView ниже предоставляющего её представления (Типизированные стили частей), а корень принимает методы стилей. Высота строк в значениях по умолчанию задана в пунктах: класс, меняющий размер части, задаёт и её высоту строки (классы 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")
}

Некоторые узлы состоят из нескольких частей, которые применяются по порядку: заголовок уровня 2 — это heading, затем heading2; alert — это blockquote, alert, затем его вид (alertNote, …); маркер задачи — это listMarker, taskMarker, затем taskMarkerChecked, если задача выполнена; строка заголовка — это tableRow, затем tableHead, а её ячейки — tableCell, затем tableHeaderCell.

ЧастьRustЧто это
rootRootСам текстовый вид — колонка его блоков; текстовые стили здесь передаются всем частям.
paragraphParagraphКаждый абзац.
headingHeadingКаждый заголовок, перед частью его уровня.
heading1 … heading6Heading1 … Heading6Заголовки этого уровня (27, 20, 17, затем 15pt, semibold).
blockquoteBlockquoteЦитаты и alerts: колонка их блоков с линией сбоку.
alertAlertКаждый alert (> [!NOTE]), после blockquote: тонированный блок, строка из его значка и блоков.
alertNote, alertTip, alertImportant, alertWarning, alertCautionAlertNote, AlertTip, AlertImportant, AlertWarning, AlertCautionAlerts этого вида.
alertIconAlertIconЗначок alert: кольцо с глифом цвета alert.
listListКаждый список: колонка его элементов.
orderedList, unorderedListOrderedList, UnorderedListНумерованные и маркированные списки, после list.
listItemListItemКаждый элемент: строка из его маркера и блоков.
listMarkerListMarkerМаркер элемента (•, ◦, ▪ по глубине или 1.), выровненный по правому краю в отступе перед элементом.
taskMarkerTaskMarkerМаркер элемента-задачи: квадрат, нарисованный как представление, заполняется ✓, когда задача выполнена; после listMarker.
taskMarkerCheckedTaskMarkerCheckedМаркер выполненной задачи, после taskMarker.
listItemContentListItemContentКолонка блоков элемента.
codeBlockCodeBlockБлоки кода: рамка со скруглёнными углами с кодом и заголовком над ним.
codeBlockHeaderCodeBlockHeaderСтрока действий блока кода (codeBlockActions), плавающая над его правым верхним углом.
codeBlockScrollCodeBlockScrollГоризонтальная прокручиваемая область вокруг кода блока кода.
codeBlockCodeCodeBlockCodeКод блока кода (моноширинный, как написан).
codeCodeКод в тексте (моноширинный, на скруглённом фоне).
keyboardKeyboardКлавиши (<kbd>, как код).
strongStrongЖирный текст (semibold).
emphasisEmphasisВыделенный текст (курсив).
strikethroughStrikethroughЗачёркнутый текст.
underlineUnderlineПодчёркнутый текст (<u>, <ins>).
highlightHighlightПодсвеченный текст (<mark>, на скруглённом жёлтом фоне).
superscript, subscriptSuperscript, SubscriptВерхний и нижний индекс (мельче).
linkLinkСсылки (цвета primary темы, подчёркнутые).
imageImageИзображения.
mathMathФормулы в тексте, набираются, если нет renderMath.
mathBlockMathBlockБлоки формул, набираются и центрируются, если нет renderMath.
tableTableТаблицы: содержимое renderTableActions, затем прокручиваемая область ячеек.
tableScrollTableScrollГоризонтальная прокручиваемая область ячеек таблицы: столбцы подгоняются под содержимое, если хватает места, переносятся вплоть до минимальной ширины, когда область уже, а за её пределами таблица прокручивается.
tableRowTableRowКаждая строка таблицы: представление позади её ячеек для фонов и границ (текстовые стили задаются в tableCell).
tableHeadTableHeadСтрока заголовка, после tableRow.
tableCellTableCellКаждая ячейка.
tableHeaderCellTableHeaderCellЯчейки строки заголовка (мельче, приглушённые), после tableCell.
ruleRuleТематические разрывы: линия, нарисованная как верхняя граница.
detailsDetailsРаскрывающиеся блоки (<details>).
summarySummaryЗаголовок раскрывающегося блока; нажатие открывает и закрывает его.
summaryMarkerSummaryMarkerМаркер заголовка (▸, ▾).
footnotesFootnotesСноски после блоков: колонка.
footnoteFootnoteСноска: строка из её номера и блоков.
footnoteLabelFootnoteLabelНомер сноски.
footnoteReferenceFootnoteReferenceСсылка на сноску в тексте (её номер, мельче).
customCustomПользовательские узлы, показанные как их содержимое (если их не показывает ни одна render-функция).

#Потоковые ответы ИИ

Передавайте ответ по мере его роста через options={{ streaming: true }} (MarkdownOptions { streaming: true, .. } в Rust). Конец ещё поступающего текста часто оставляет синтаксис незакрытым (**bold, `код, [ссылка](htt, a table whose header just arrived); потоковый режим показывает его так, как он будет выглядеть после закрытия, поэтому читатель никогда не видит лишних ** или [. Блоки хранятся по позиции: завершённые блоки сохраняют свои представления, а заново создаётся только блок, который пишется сейчас, поэтому обновление длинного ответа остаётся дешёвым.

streamingCaret следует за концом документа: встроенно в конце последнего текста, где бы ни заканчивался последний блок (абзац, элемент списка, блок кода, последняя ячейка таблицы), иначе — на отдельной строке после последнего блока (линия, содержимое render-функции). Уберите его, когда ответ завершён.

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

Каретка — собственное представление приложения, поэтому она может мигать или пульсировать с помощью API анимации nsh, а новые блоки могут плавно появляться с помощью анимаций приложения.

#Ссылки

Ссылки — это нажимаемые спаны. Нажатие на ссылку вызывает 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 (scrollToAnchor и nav в TSX) обозначают собственные функции приложения.

С links="open" нажатие на ссылку на другой сайт или приложение (URL со схемой: https:, mailto:, …) также открывает её вне приложения через openUrl при наличии у приложения разрешения openUrl, после того как onLinkPress сообщил о нажатии; якоря в документе (#…) только передаются в обработчик. Значение по умолчанию, "report", ничего не открывает.

use nsh_base::{TextViewLinks, text_view};

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

#Render-функции

Render-функции добавляют в документ собственное содержимое приложения. Каждая принимает значение части и возвращает её содержимое (JSX в TSX, элемент в 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 записывает в буфер обмена так же, как это делает 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-крейта; представление на Rust передаёт TextView свой подсветчик через CodeHighlighter. Вот код с собственной подсветкой приложения и изображение, заполняющее всю ширину:

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 (highlighted и math_view в Rust) обозначают собственные компоненты приложения (подсветчик, средство отрисовки формул или компонент его Rust-крейта). Без renderMath TextView сам набирает формулу (части math и mathBlock) с поддержкой TeX и шрифтами KaTeX, цветом текста, одинаково на всех платформах; TeX, который он не может прочитать, показывается как исходный текст моноширинным шрифтом.

#Расширения Markdown

extensions превращает синтаксис приложения в пользовательские узлы, которые показывает renderCustom. У каждого расширения есть name (name создаваемых им узлов) и один из четырёх видов синтаксиса:

ВидRustЧто находитУзел
patternMarkdownExtension::pattern(name, regex)Регулярное выражение в тексте (синтаксис regex из Rust) вне кода и ссылокСтрочный; text — совпадение, attributes — его группы: "1", "2", … и именованные группы по имени
languageMarkdownExtension::language(name, language)Блоки кода с ограждением на этом языке (```chart)A block; text is the code, attributes.meta — остальная часть info-строки
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) обозначает собственный график приложения. Группа шаблона упоминания находится в node.attributes?.["1"] (в Rust node.attributes["1"]: ada), а всё совпадение целиком — в node.text (@ada). Пользовательский узел без функции отрисовки показывает своё содержимое. Представление, возвращённое для строчного узла (чипа), располагается в своей строке согласно собственному verticalAlign: align-middle (verticalAlign: "middle") центрирует его относительно текста, а строка сохраняет lineHeight абзаца, если только представление не выше.

#Параметры

options выбирает синтаксис Markdown, который читается сверх CommonMark и GitHub; в Rust это MarkdownOptions с теми же полями (MarkdownOptions { streaming: true, ..Default::default() }):

КлючТипПо умолчаниюОписание
mathbooleantrueФормулы: $…$ в тексте, блоки $$…$$.
htmlbooleantrueHTML внутри Markdown читается так же, как его читает проп html; false показывает его как текст.
alertsbooleantrueОповещения GitHub: > [!NOTE], [!TIP], [!IMPORTANT], [!WARNING], [!CAUTION].
frontmatterbooleanfalseYAML front matter (--- … --- в самом начале) становится пользовательским узлом frontmatter, у которого text — это YAML.
mdxbooleanfalseMDX: JSX-элементы — пользовательские узлы с именем по тегу, {expressions} — пользовательские узлы expression; необработанный HTML при этом читается как текст.
streamingbooleanfalseТекст ещё поступает: синтаксис, оставшийся открытым в его конце, показывается так, как будто он закрыт.

#Модель документа

Проп document и значения функций отрисовки — обычные объекты, каждый узел помечен своим type. Типы берутся из nsh/base: TextViewDocument, TextViewBlock, TextViewInline, TextViewCodeBlock, TextViewTable, TextViewListItem, TextViewImage, TextViewFootnote, TextViewAlert и TextViewCustomNode. В Rust это типы nsh_base::text_view (Document, Block, Inline, CodeBlock, Table, ListItem, Image, Footnote, Alert, CustomNode): Block и Inline — перечисления, варианты которых совпадают с приведёнными ниже типами в PascalCase (Block::Heading { level, content, anchor }, Inline::Strong { content }), с теми же полями в snake case.

Блок typeПоля
paragraphcontent
headinglevel (1–6), content, anchor?
blockquotechildren, alert? ("note", "tip", "important", "warning", "caution")
listordered?, start?, loose?, items ({ checked?, children })
codeBlocklanguage?, meta?, code
mathtex
tablealign? ("none", "left", "center", "right" для каждого столбца), head (ячейки), rows? (строки ячеек); ячейка — это { content }
rule
detailsopen?, summary (строчные), children
customname, attributes?, text?, data?, children?, content?
Строчный элемент typeПоля
texttext
strong, emphasis, strikethrough, underline, highlight, superscript, subscriptcontent
codecode
keyboardtext
linkurl, title?, content
imagesrc, alt?, title?
mathtex
break
footnoteReferencelabel, index
customкак у блока

У документа также есть footnotes?: { label, index, children }, они показываются после блоков в порядке первого упоминания. Приложение может хранить ответы как документы, собирать их из собственных данных или подмешивать пользовательские узлы, которые показывает renderCustom.

#Разбор и простой текст

TextView также читает документы, не показывая их, синхронно:

ФункцияRustВозвращает
TextView.parse(markdown, options?, extensions?)TextView::parse(markdown, options, extensions)TextViewDocument, который показал бы проп markdown.
TextView.parseHtml(html, extensions?)TextView::parse_html(html, extensions)TextViewDocument, который показал бы проп html.
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"))
}

Приложение также может разобрать документ один раз и сохранить его (document={doc}) или изменить его перед показом.

#В Rust

Представления на Rust используют тот же компонент из крейта nsh-base, а примеры выше приведены на их вкладках Rust. Три вещи свойственны только Rust: подсветчик приложения, типизированные стили по частям и плагины Markdown; каждый из них передаётся каждому TextView ниже по дереву представления с помощью provide_context. Типы документа находятся в nsh_base::text_view, где Parser также читает документы с плагинами (Parser::new().plugin(..).markdown(source)).

#Подсветка синтаксиса

TextView подсвечивает код с помощью syntect в палитре кода Allsum (функция highlight в nsh-base, включена по умолчанию); большой блок сначала показывается без подсветки, а цвета получает, когда фоновый поток его подсветит. CodeHighlighter — это подсветчик приложения, который используется вместо встроенного и передаётся каждому TextView ниже по дереву представления с помощью provide_context. Он получает каждый блок кода и возвращает стили для диапазонов байтов его кода (по порядку, без пересечений; то, что они не охватывают, остаётся обычным); 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, если задан, показывает блок кода вместо всего этого.

#Типизированные стили частей

TextViewParts стилизует части типизированными стилями вместо строк классов, для представлений на 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 читает синтаксис Markdown в Rust: он получает каждый узел синтаксического дерева (mdast из markdown-rs) до того, как его прочитает парсер, и возвращает собственное блочное или строчное содержимое, обычно 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 читает дочерние элементы узла так же, как это делает парser.

#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 по частям.
styles.styles(..)TextViewStyles{}Инлайн-стили по частям (каждый — StyleProp<TextStyle>), поверх классов.
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 классы и стили по частям — это 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 задаёт структуре роли: заголовки — это заголовки, ссылки — ссылки (их title служит подсказкой), маркеры задач — флажки с их состоянием, summary раскрывающегося блока — кнопка, сообщающая, раскрыт ли он, а изображения озвучиваются по их альтернативному тексту.

#См. также

  • Text: элемент, которым является каждый текст TextView, со своими спанами, ссылками, встроенными вложениями и выделением.
  • Выделение текста: одно выделение через несколько TextView и текстов.
  • nsh/base: остальные базовые компоненты.
  • Стилизация и Tailwind.