تُرجمت هذه الصفحة آليًا وقد تحتوي على أخطاء. اقرأ الأصل الإنجليزي

العناصر

TextView

TextView يعرض مستندًا مكتوبًا بصيغة Markdown أو HTML أو ككتل يبنيها التطبيق: عناوين، وفقرات، وتأكيدًا، وروابط، وقوائم وقوائم مهام، واقتباسات وتنبيهات 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 من السمة داخل صندوق بحدود وزوايا مستديرة، وجداول بخطوط على كل جوانبها وصف رأس ملوّن، واقتباسات بخط على جانبها، وتنبيهات على هيئة صناديق ملوّنة بأيقونة بألوان تنبيهات 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_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 أو مستند إلى كتل، والواجهات المحيطة بالنصوص (القوائم والاقتباسات والجداول وكتل الشيفرة والعناصر القابلة للطي والهوامش)، والبث، والأنماط حسب الجزء، ودوال العرض، والامتدادات.

#المستند

المحتوى هو واحدة من ثلاث خصائص، تُقرأ بهذا الترتيب:

  • markdown: CommonMark مع إضافات GitHub (الجداول وقوائم المهام والتشطيب والروابط التلقائية والهوامش والتنبيهات)، والمعادلات ($…$ و$$…$$)، وHTML داخل Markdown.
  • html: HTML كما يعرضه القارئ. يمرّ النص والبنية والتنسيق المضمَّن؛ أما النصوص البرمجية والأنماط وعناصر النماذج والعناصر المخفية فتُستبعد. يعمل مقطع HTML وصفحة كاملة على السواء (تعرض الصفحة جسمها).
  • 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 تحت الواجهة التي توفّره (أنماط الأجزاء المكتوبة الأنواع)، ويقبل الجذر دوال الأنماط. ارتفاعات الأسطر في القيم الافتراضية مضبوطة بالنقاط: فالفئة التي تغيّر حجم جزء تضبط ارتفاع سطره أيضًا (كما تفعل فئات text-* في Tailwind).

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ما هو
rootRootعرض النص نفسه، عمود من كتله؛ وأنماط النص هنا تنتقل إلى كل جزء.
paragraphParagraphكل فقرة.
headingHeadingكل عنوان، قبل جزء مستواه.
heading1 … heading6Heading1 … Heading6عناوين ذلك المستوى (27 ثم 20 ثم 17 ثم 15pt، بوزن semibold).
blockquoteBlockquoteالاقتباسات والتنبيهات: عمود من كتلها بخط على جانبه.
alertAlertكل تنبيه (> [!NOTE])، بعد blockquote: صندوق ملوّن، وهو صف من أيقونته وكتله.
alertNote وalertTip وalertImportant وalertWarning وalertCautionAlertNote وAlertTip وAlertImportant وAlertWarning وAlertCautionتنبيهات ذلك النوع.
alertIconAlertIconأيقونة التنبيه: حلقة برمز بلون التنبيه.
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العقد المخصصة المعروضة بمحتواها (حين لا تعرضها دالة عرض).

#بث ردود الذكاء الاصطناعي

مرّر الرد وهو ينمو، بـ options={{ streaming: true }} (MarkdownOptions { streaming: true, .. } في Rust). غالبًا ما تترك نهاية النص الذي ما زال يصل صيغة مفتوحة (**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، ويمكن أن تظهر الكتل الجديدة بتلاشٍ تدريجي بفضل رسوم التطبيق المتحركة.

#الروابط

الروابط امتدادات قابلة للضغط. يستدعي الضغط على أحدها 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)

#دوال العرض

تضع دوال العرض محتوى التطبيق الخاص في المستند. تأخذ كل منها قيمة جزء وتعيد محتواه (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 هو باقي سلسلة المعلومات
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 (bar_chart في Rust) يمثل مخطط التطبيق الخاص. مجموعة نمط الإشارة موجودة في node.attributes?.["1"] (node.attributes["1"] في Rust: ada) والمطابقة كاملة في node.text (@ada). العقدة المخصصة التي لا تملك دالة عرض تعرض محتواها. العرض المُعاد لعقدة داخل السطر (شارة) يوضع في سطره بحسب verticalAlign الخاص به: align-middle (verticalAlign: "middle") يوسّطه على النص، ويحتفظ سطره بقيمة lineHeight الخاصة بالفقرة ما لم يكن العرض أطول.

#الخيارات

options يحدد صيغة Markdown التي تُقرأ إضافةً إلى CommonMark وGitHub؛ وفي Rust هو MarkdownOptions بالحقول نفسها (MarkdownOptions { streaming: true, ..Default::default() }):

المفتاحالنوعالافتراضيالوصف
mathbooleantrueالصيغ الرياضية: $…$ داخل النص، وكتل $$…$$.
htmlbooleantrueيُقرأ HTML داخل Markdown كما تقرؤه الخاصية html؛ وfalse يعرضه كنص.
alertsbooleantrueتنبيهات GitHub: > [!NOTE] و[!TIP] و[!IMPORTANT] و[!WARNING] و[!CAUTION].
frontmatterbooleanfalseتتحول YAML 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 تعدادات (enums) متغيراتها هي الأنواع أدناه بنمط 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 يقرأ أبناء العقدة كما يقرؤهم المحلل.

#الخصائص

الخاصيةRustالنوعالافتراضيالوصف
markdown.markdown(..)stringالمستند بصيغة Markdown.
html.html(..)stringالمستند بصيغة HTML، عند عدم إعطاء markdown.
document.document(..)TextViewDocumentالمستند ككتل، عند عدم إعطاء markdown ولا html.
options.options(..)MarkdownOptionsكل القيم الافتراضيةصيغة Markdown المقروءة (الخيارات).
extensions.extensions(..)MarkdownExtension[][]صيغة التطبيق الخاصة، كعقد مخصصة (امتدادات Markdown).
classes.classes(..)TextViewClasses{}أصناف Tailwind حسب الجزء.
styles.styles(..)TextViewStyles{}أنماط داخل السطر حسب الجزء (كل منها StyleProp<TextStyle>)، فوق الأصناف.
selectable.selectable(..)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 يمنح البنية أدوارها: العناوين الرئيسية هي ترويسات، والروابط هي روابط (عنوانها هو التلميح)، وعلامات المهام هي مربعات اختيار مع حالة تحديدها، وملخص الإفصاح زر يخبر ما إذا كان موسّعًا، وتُقرأ الصور بنص بديلها.

#ذو صلة

  • Text: العنصر الذي يمثّله كل نص في TextView، مع مقاطعه وروابطه ومرفقاته داخل السطر وتحديده.
  • تحديد النص: تحديد واحد عبر عدة TextView ونصوص.
  • nsh/base: المكونات الأساسية الأخرى.
  • التنسيق وTailwind.