العناصر
Tree
تعرض الشجرة عناصر تحتوي على عناصر أخرى: ملفات المشروع ومجلداته، وأقسام الشريط الجانبي للإعدادات، وهيكل المستند. يتولى Tree سلوك الشجرة: العناصر الموسَّعة، والعنصر المحدد، ولوحة المفاتيح، وما يسمعه قارئ الشاشة. صفوفها افتراضية (virtualized): لا يُنشأ إلا ما يظهر منها، ولا يُنشأ أبناء العنصر المطوي أصلًا، فتُفتح شجرة من آلاف العناصر وتُمرَّر فورًا. أما المظهر فهو للتطبيق: لا ترسم الشجرة شيئًا بنفسها، بل ينسّق التطبيق كل صف (itemClass) ويرسم محتواه (renderItem: أيقونة، وتسمية مزاحة بحسب عمق العنصر، وسهم، وشارة).
import { mount, signal, Text, View } from "nsh";
import { Button, Tree, type TreeItem, type TreeRef } from "nsh/base";
import { button, buttonText, desktop, Log, Panel, Split, TreeRow, treeItemClass, ui, wide } from "./palette";
// A project's files: folders that open with their chevron, files, sizes at
// the end, a folder the app keeps out of reach (`disabled`). The tree owns
// the keys and the semantics; every pixel of a row is `itemClass` and
// `TreeRow` over the item's depth.
const files: TreeItem[] = [
{
id: "src",
label: "src",
children: [
{ id: "src/main.rs", label: "main.rs" },
{ id: "src/lib.rs", label: "lib.rs" },
{
id: "src/ui",
label: "ui",
children: [
{ id: "src/ui/mod.rs", label: "mod.rs" },
{ id: "src/ui/tree.rs", label: "tree.rs" },
{ id: "src/ui/sidebar.rs", label: "sidebar.rs" },
{
id: "src/ui/icons",
label: "icons",
children: [
{ id: "src/ui/icons/folder.svg", label: "folder.svg" },
{ id: "src/ui/icons/file.svg", label: "file.svg" },
],
},
],
},
],
},
{
id: "tests",
label: "tests",
children: [
{ id: "tests/tree.rs", label: "tree.rs" },
{ id: "tests/keys.rs", label: "keys.rs" },
],
},
{ id: "target", label: "target", disabled: true, children: [{ id: "target/debug", label: "debug" }] },
{ id: "Cargo.toml", label: "Cargo.toml" },
{ id: "README.md", label: "README.md" },
];
const sizes: Record<string, string> = {
"src/main.rs": "1.2 KB",
"src/lib.rs": "4.8 KB",
"src/ui/mod.rs": "640 B",
"src/ui/tree.rs": "21 KB",
"src/ui/sidebar.rs": "6.3 KB",
"src/ui/icons/folder.svg": "412 B",
"src/ui/icons/file.svg": "388 B",
"tests/tree.rs": "18 KB",
"tests/keys.rs": "3.1 KB",
"Cargo.toml": "906 B",
"README.md": "2.4 KB",
};
const expanded = signal(["src", "src/ui"]);
const selected = signal<string | null>("src/ui/tree.rs");
const events = signal<string[]>([]);
const log = (line: string) => events.set([line, ...events()].slice(0, 4));
let tree: TreeRef | undefined;
function App() {
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>tree</Text>
<Text style={{ color: ui().muted }}>
{desktop
? "Click a folder, or Tab in and use the arrows: Right opens and enters, Left closes and leaves; double-click a file or press Return to act on it"
: "Tap a folder to open it, a file to select it; double-tap a file to act on it"}
</Text>
<Split
gap={14}
inspector={
<>
<Panel title="selected">
<Text style={{ fontSize: 13, color: ui().text }}>{selected() ?? "none"}</Text>
</Panel>
<Panel title="expanded">
<Text style={{ fontSize: 13, color: ui().text }}>{expanded().join(", ") || "none"}</Text>
</Panel>
<Log title="events" lines={events()} minHeight={92} />
</>
}
>
<View style={{ width: wide() ? 300 : undefined, alignSelf: wide() ? "center" : "stretch", gap: 10 }}>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
<Button {...button("secondary", { size: "sm" })} onPress={() => tree?.revealItem("src/ui/icons/file.svg")}>
<Text style={buttonText()}>Reveal file.svg</Text>
</Button>
<Button
{...button("ghost", { size: "sm" })}
disabled={expanded().length === 0}
onPress={() => {
expanded.set([]);
log("collapse all");
}}
>
<Text style={buttonText("ghost")}>Collapse all</Text>
</Button>
</View>
<Tree
ref={(r) => (tree = r)}
items={files}
expanded={expanded()}
selected={selected()}
reveal
accessibilityLabel="Project files"
style={{ height: desktop ? 320 : 360, borderRadius: 12, ...ui().card }}
listStyle={{ padding: 6 }}
itemClass={treeItemClass}
onExpandedChange={({ expanded: next }) => expanded.set(next)}
onSelectedChange={({ selected: next }) => {
selected.set(next);
log(`onSelectedChange({ selected: "${next}" })`);
}}
onExpand={({ id }) => log(`onExpand({ id: "${id}" })`)}
onCollapse={({ id }) => log(`onCollapse({ id: "${id}" })`)}
onAction={({ id }) => log(`onAction({ id: "${id}" })`)}
renderItem={(entry) => <TreeRow entry={entry} detail={entry.disabled ? "excluded" : sizes[entry.id]} />}
/>
</View>
</Split>
</View>
);
}
mount(App);use nsh_base::{TreeEntry, TreeItem, theme, tree};
use nsh_core::prelude::*;
fn files() -> impl IntoElement {
let items = vec![
TreeItem::new("src", "src")
.child(TreeItem::new("src/main.rs", "main.rs"))
.child(TreeItem::new("src/ui", "ui").child(TreeItem::new("src/ui/tree.rs", "tree.rs"))),
TreeItem::new("Cargo.toml", "Cargo.toml"),
];
let selected = signal(None::<SharedString>);
tree()
.items(items)
.default_expanded(vec!["src".into()])
.selected(move || selected.get())
.on_selected_change(move |id: &SharedString| selected.set(Some(id.clone())))
.accessibility_label("Files")
.h(px(320.0))
.list_class("p-1")
.item_class(
"group rounded-md hover:bg-accent selected:bg-primary/10 \
focus:outline-2 focus:-outline-offset-2 focus:outline-ring disabled:opacity-50",
)
.render_item(|entry: &TreeEntry| {
view()
.flex_row()
.gap_2()
.h_8()
.items_center()
.rounded_md()
.pl(px(8.0 + 16.0 * entry.depth as f32))
.child(text(if entry.folder { "▸" } else { " " }).text_color(move || theme().muted_foreground))
.child(text(entry.label.clone()))
})
}import { mount, signal, Text, View } from "nsh";
import { Tree, type TreeItem } from "nsh/base";
const files: TreeItem[] = [
{
id: "src",
label: "src",
children: [
{ id: "src/main.rs", label: "main.rs" },
{ id: "src/ui", label: "ui", children: [{ id: "src/ui/tree.rs", label: "tree.rs" }] },
],
},
{ id: "Cargo.toml", label: "Cargo.toml" },
];
function App() {
const expanded = signal(["src"]);
const selected = signal<string | null>(null);
return (
<Tree
items={files}
expanded={expanded()}
selected={selected()}
onExpandedChange={({ expanded: next }) => expanded.set(next)}
onSelectedChange={({ selected: next }) => selected.set(next)}
accessibilityLabel="Files"
class="h-80 w-64 rounded-lg border border-border"
listClass="p-1"
itemClass="group h-8 rounded-md hover:bg-accent selected:bg-primary/10 focus:outline-2 focus:-outline-offset-2 focus:outline-ring disabled:opacity-50"
renderItem={(entry) => (
<View class="h-8 flex-row items-center gap-2" style={{ paddingLeft: 8 + entry.depth * 16 }}>
<Text class="w-3 text-muted-foreground transition-transform group-aria-expanded:rotate-90">
{entry.folder ? "›" : ""}
</Text>
<Text class="text-sm text-foreground">{entry.label}</Text>
</View>
)}
/>
);
}
mount(App);يأتي Tree من nsh/base، وهي الحزمة nsh-base في Rust. تكون العناصر الموسَّعة والعنصر المحدد من شأن التطبيق عندما يضبط expanded وselected ويحدّثهما في onExpandedChange وonSelectedChange؛ وبدونهما تحتفظ الشجرة بحالتها الخاصة، بادئةً من defaultExpanded وdefaultSelected. تُعرَّف العناصر بـid الخاص بالتطبيق (مسار، أو مفتاح سجل)، وليس بتسميتها أبدًا. في Rust ينشئه tree()، مع دالة ضبط لكل خاصية (.items(..)، .expanded(..)، .render_item(..))، ودالة مستمع لكل حدث، ودوال التنسيق المطبَّعة.
#العناصر
items هي عناصر المستوى الأعلى، وكل منها TreeItem:
| الحقل | النوع | الوصف |
|---|---|---|
id | string | ما يعرّفه في expanded وselected والأحداث: فريد في الشجرة (يُسجَّل المعرّف المكرر، ولا يُوسَّع أو يُحدَّد إلا عنصره الأول). |
label | string | ما يعرضه صفه ما لم يرسمه renderItem، وما يقوله قارئ الشاشة، وما يطابقه الكتابة السريعة (typeahead). |
children | TreeItem[] | العناصر التي تحته بالترتيب. إذا وُجد أي منها فهو مجلد: يتوسع وينطوي. |
disabled | boolean | لا يستطيع المستخدم تحديده أو توسيعه أو طيّه: تتجاوزه الضغطات والمفاتيح، وتنطبق تنسيقات disabled:. يبقى أبناؤه كما يحدد expanded. |
يتلقى renderItem كائن TreeEntry لكل صف ظاهر: id وlabel وdisabled للعنصر، وdepth (0 في المستوى الأعلى، و1 تحت عنصر من المستوى الأعلى، …) وما إذا كان folder. هذا هو ما يمثّله العنصر، أما حالات الصف فهي فئاته (classes): يُرسم المحتوى مرة واحدة لكل عنصر ويحتفظ بعناصر عرضه (views) أثناء تحديد الصف أو توسيعه أو طيّه، فيدور السهم بانتقال سلس.
#الخصائص
| الخاصية | Rust | النوع | الافتراضي | الوصف |
|---|---|---|---|---|
items | .items(..) | TreeItem[] | [] | عناصر المستوى الأعلى، لكل منها العناصر التي تحته. |
expanded | .expanded(..) | string[] | لا شيء | معرّفات العناصر الموسَّعة (متحكَّم بها). يظهر العنصر ما دام كل عنصر فوقه موسَّعًا. |
defaultExpanded | .default_expanded(..) | string[] | [] | العناصر الموسَّعة في البداية، ما دام expanded غير مضبوط. |
selected | .selected(..) | string | null | لا شيء | معرّف العنصر المحدد (متحكَّم به)، وnull لعدم وجود تحديد. |
defaultSelected | .default_selected(..) | string | لا شيء | العنصر المحدد في البداية، ما دام selected غير مضبوط. |
disabled | .disabled(..) | boolean | false | شجرة للقراءة فقط: لا ضغطات ولا مفاتيح، ولا محطة لتركيز لوحة المفاتيح، وdisabled: على كل صف؛ تعرض ما يقوله expanded وselected. |
reveal | .reveal(..) | boolean | false | يؤدي التوسيع والطي إلى نقل الصفوف التي بعد العنصر إلى أماكنها الجديدة بحركة نابضية تُنفَّذ أصلًا على المنصة. تتوقف عند تقليل الحركة. |
accessibilityLabel | .accessibility_label(..) | string | لا شيء | ما يسمّي به قارئ الشاشة الشجرة (“Files”). |
renderItem | .render_item(..) | (entry: TreeEntry) => JSX.Element | لا شيء | محتوى الصف، للصفوف الظاهرة؛ وبدونه يعرض الصف تسمية العنصر. |
itemClass، itemStyle | .item_class(..)، .item_style(..) | string، StyleProp<TextStyle> | لا شيء | كل صف: ارتفاعه وحشوه وسطحه وحالاته (أدناه)؛ وتصل تنسيقات النص فيه إلى التسمية. |
listClass، listStyle | .list_class(..)، .list_style(..) | string، StyleProp<ViewStyle> | لا شيء | عرض التمرير للصفوف داخل الشجرة: حشوه وفجواته. |
class، style | دوال التنسيق | string، StyleProp<ViewStyle> | لا شيء | الشجرة نفسها. أعطها ارتفاعًا (أو flex-1 داخل أب له ارتفاع): تُمرَّر الصفوف داخلها، ولا يُنشأ إلا الظاهر منها. |
#الأحداث
| الحدث | Rust | الحمولة | متى |
|---|---|---|---|
onExpandedChange | .on_expanded_change(..) | { expanded: string[] } | وسّع المستخدم عنصرًا أو طواه (ضغطة، أو Return، أو Space، أو Right أو Left، أو revealItem): المعرّفات الموسَّعة كما ستكون. |
onSelectedChange | .on_selected_change(..) | { selected: string } | حدد المستخدم عنصرًا آخر (ضغطة، أو مفاتيح الأسهم، أو Home، أو End، أو الكتابة السريعة). |
onExpand | .on_expand(..) | { id: string } | وسّع المستخدم العنصر الذي معرّفه id، بعد onExpandedChange. |
onCollapse | .on_collapse(..) | { id: string } | طوى المستخدم العنصر الذي معرّفه id، بعد onExpandedChange. |
onAction | .on_action(..) | { id: string } | تصرّف المستخدم على العنصر الذي معرّفه id: Return على العنصر المحدد إذا لم يكن له أبناء، أو نقرة مزدوجة أو لمسة مزدوجة على أي عنصر، بعد أحداث الضغطة نفسها. لا تبلّغ العناصر المعطلة ولا الشجرة المعطلة عنه أبدًا. |
يتبع التحديدُ مفاتيحَ الأسهم، لذا يفتح مستكشف الملفات الملف في onAction لا في onSelectedChange: التنقل بين الملفات يعاينها واحدًا واحدًا، وReturn أو النقرة المزدوجة تفتح الملف الذي يريده المستخدم.
<Tree
items={files}
onSelectedChange={({ selected }) => preview.set(selected)}
onAction={({ id }) => openFile(id)}
/>
#الأجزاء
| الجزء | داخل | يُنسَّق بـ | الحالات |
|---|---|---|---|
| الشجرة | class، style | ||
| القائمة | الشجرة | listClass، listStyle | |
| الصف | القائمة | itemClass، itemStyle | selected:، aria-expanded: (مجلد مفتوح)، focus: (عندما يكون تركيز لوحة المفاتيح على الشجرة: الصف الذي تعمل عليه المفاتيح، وهو الصف المحدد، أو العنصر المطوي الذي يقع تحته تحديد مخفي، أو الصف الأول إذا لم يكن هناك تحديد)، disabled:، hover:، active: |
| محتوى الصف | صف | renderItem | group-selected:، group-aria-expanded:، group-focus:، group-disabled: تحت group في itemClass |
أظهر الصف المحدد بـselected: ومكان لوحة المفاتيح بـfocus: (راجع أدلة التصميم). ارسم التركيز داخل الصف (focus:outline-2 focus:-outline-offset-2): تقصّ القائمة ما يتجاوز جانبيها.
#Ref
| الدالة | الوصف |
|---|---|
focus() | تأخذ تركيز لوحة المفاتيح، كما يفعل وصول Tab إلى الشجرة. |
revealItem(id) | توسّع العناصر التي فوق العنصر الذي معرّفه id (يُبلَّغ عنها بـonExpandedChange وonExpand، الأبعد أولًا) وتمرّر صفه إلى الظهور بعد أن يظهر: فورًا إذا تابع التطبيق التغيير؛ وإذا لم يتابعه ينتهي الطلب عند هذا الحد. |
let files: TreeRef | undefined;
<Tree ref={(r) => (files = r)} items={items} />;
files?.revealItem("src/ui/tree.rs");
#التعطيل
لا يقبل العنصر المعطل أي ضغطات، وتتجاوزه مفاتيح الأسهم، ويعلن قارئ الشاشة أنه باهت. disabled على الشجرة يجعلها للقراءة فقط: تعرض ما تقوله حالة التطبيق ولا تقبل أي إدخال.
<Tree items={items} disabled={locked()} itemClass="h-8 px-2 disabled:opacity-50" />
#إمكانية الوصول
- للشجرة الدور
"tree"، وتُسمّى بـaccessibilityLabel؛ وكل صف هو"treeitem"يُسمّى بتسمية عنصره، مع مستواه (عمقه + 1)، وما إذا كان موسَّعًا (للمجلدات)، ومحددًا، ومعطلًا. - عندما تأخذ الشجرة تركيز لوحة المفاتيح وعندما تحرّك المفاتيح التحديد، يأخذ الصف الذي تعمل عليه المفاتيح تركيز قارئ الشاشة.
راجع إمكانية الوصول للاطلاع على التسميات والأدوار والإعلانات بوجه عام.
#لوحة المفاتيح
على أنظمة سطح المكتب والويب، حيث يصل Tab إلى العناصر التي تقبل الضغطات، يتبع Tree نمط WAI-ARIA tree view:
| المفتاح | ما يفعله |
|---|---|
| Tab، Shift+Tab | يدخل الشجرة بخطوة واحدة، ويخرج منها بخطوة واحدة؛ وعند العودة تتابع المفاتيح من الصف المحدد. وإذا لم يكن هناك تحديد، يظهر التركيز على الصف الأول، ويحدده Down. |
| Down، Up | يحدد الصف التالي أو السابق، متجاوزًا العناصر المعطلة، ومن الأخير إلى الأول والعكس. |
| Right | يوسّع المجلد المطوي؛ وعلى المجلد الموسَّع يحدد أول ابن مفعّل له. |
| Left | يطوي المجلد الموسَّع؛ وعلى أي شيء آخر يحدد أباه. |
| Home، End | يحدد الصف الأول أو الأخير. |
| Return | يوسّع المجلد المحدد أو يطويه؛ وعلى عنصر بلا أبناء يتصرف عليه (onAction). |
| Space | يوسّع المجلد المحدد أو يطويه. |
| الحروف، الأرقام | يحدد الصف التالي الذي تبدأ تسميته بما كُتب. |
عندما يكون التحديد مخفيًا تحت عنصر مطوي (طواه التطبيق)، تعمل المفاتيح على صف ذلك العنصر: Right أو Return يوسّعه، وDown يحدد الصف الذي بعده.
في التخطيط من اليمين إلى اليسار تمتد الصفوف من اليمين، فيتبادل Left وRight موضعيهما، كما ينص النمط: Left يوسّع المجلد ويدخله، وRight يطويه ويحدد الأب. أما Up وDown وHome وEnd فتبقى كما هي.
#ملاحظات حول المنصات
- iOS وAndroid وMac Catalyst: تحدد اللمسة الصف وتوسّع المجلد أو تطويه؛ واللمسة المزدوجة تتصرف على العنصر (
onAction). - أنظمة سطح المكتب (macOS وWindows وLinux وGPUI) والويب: تفعل النقرة الشيء نفسه وتعطي الشجرة تركيز لوحة المفاتيح، وتتحرك المفاتيح المذكورة أعلاه داخلها؛ والنقرة المزدوجة تتصرف على العنصر.
- ترسم كل منصة ما ينسّقه التطبيق: الشجرة وصفوفها ومحتواها.