العناصر
View
View هو اللبنة الأساسية لكل شاشة: حاوية أصلية (UIView على iOS وMac Catalyst، وViewGroup على
Android) تضع عناصرها الفرعية باستخدام flexbox أو grid، وترسم الخلفية والحدود والظلال والتدرجات اللونية، وتعالج
الضغطات. الأزرار والبطاقات والصفوف والشاشات كلها views. في Rust يُنشأ باستخدام view()، وفي TSX باستخدام <View> من nsh.
import { mount, signal, Text, View } from "nsh";
import { button, buttonText, desktop, focusRing, Inspector, Panel, Stage, ui, wide } from "./palette";
function App() {
const presses = signal(0);
const size = signal("");
const views = () => (
<>
<View
onPress={() => presses.update((n) => n + 1)}
{...button("accent", { align: "center" })}
>
<Text style={buttonText("accent")}>activeStyle opacity · pressed {presses()}×</Text>
</View>
<View
onPress={() => presses.update((n) => n + 1)}
activeStyle={{ backgroundColor: ui().border }}
style={{ ...ui().card, backgroundImage: "none", borderRadius: 16, padding: 16 }}
hoverStyle={{ opacity: 0.88 }}
focusStyle={focusRing()}
>
<Text style={{ color: ui().text, fontSize: wide() ? 14 : 16 }}>activeStyle background ({desktop ? "click" : "tap"} and hold)</Text>
</View>
<View class="flex-row gap-3">
<View class="flex-1 h-20 rounded-xl bg-[#ff6170] hover:bg-[#ff7d89] active:bg-[#d94d5b] items-center justify-center" focusStyle={focusRing()} onPress={() => {}}>
<Text class="text-[#13160c] font-semibold">active:</Text>
</View>
<View class="flex-1 h-20 rounded-xl bg-[#45d49a] items-center justify-center shadow-lg">
<Text class="text-[#0c0d0f] font-semibold">shadow-lg</Text>
</View>
<View
class="flex-1 h-20 rounded-xl items-center justify-center"
style={{ backgroundImage: `linear-gradient(135deg, ${ui().violet}, ${ui().info})` }}
>
<Text class="font-semibold" style={{ color: ui().page }}>gradient</Text>
</View>
</View>
</>
);
const measured = (height?: number) => (
<View
onLayout={({ bounds }) => size.set(`${Math.round(bounds.size.width)} × ${Math.round(bounds.size.height)} pt`)}
style={{
flex: height === undefined ? 1 : undefined,
height,
borderRadius: 12,
borderWidth: 2,
borderStyle: "dashed",
borderColor: ui().faint,
alignItems: "center",
justifyContent: "center",
}}
>
<Text style={{ color: ui().muted }}>onLayout: {size()}</Text>
</View>
);
return (
<View style={{ flex: 1, backgroundColor: ui().page }}>
{() =>
wide() ? (
<View style={{ flex: 1, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>view</Text>
<Text style={{ color: ui().muted }}>Click and hold the views; resize the window to see onLayout</Text>
<View style={{ flex: 1, flexDirection: "row", gap: 16 }}>
<Stage width={480} gap={16}>
{views()}
{measured(120)}
</Stage>
<Inspector>
<Panel title="onPress">
<Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>pressed {presses()}×</Text>
</Panel>
<Panel title="onLayout" grow>
<Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>{size()}</Text>
</Panel>
</Inspector>
</View>
</View>
) : (
<View style={{ flex: 1, width: "100%", maxWidth: 640, alignSelf: "center", padding: 20, paddingTop: 24, gap: 16 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>view</Text>
{views()}
{measured()}
</View>
)
}
</View>
);
}
mount(App);use nsh_core::prelude::*;
fn app() -> impl IntoElement {
let liked = signal(false);
view()
.flex_1()
.p_6()
.gap_4()
.bg(rgb(0xf6f8fa))
.child(
view()
.flex_row()
.items_center()
.gap_3()
.p_4()
.rounded(px(16.0))
.bg(white())
.shadow_md()
.child(view().size(px(40.0)).rounded_full().bg(rgb(0x8250df)))
.child(text("A card").flex_1().text_base())
.child(
view()
.hit_slop(EdgeInsets::all(px(8.0)))
.active(|s| s.opacity(0.5))
.on_press(move || liked.update(|l| *l = !*l))
.child(text(move || if liked.get() { "♥" } else { "♡" }).text_size(px(22.0))),
),
)
}import { mount, signal, Text, View } from "nsh";
function App() {
const liked = signal(false);
return (
<View style={{ flex: 1, padding: 24, gap: 16, backgroundColor: "#f6f8fa" }}>
<View
style={{
backgroundColor: "#ffffff",
borderRadius: 16,
padding: 16,
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.08)",
flexDirection: "row",
alignItems: "center",
gap: 12,
}}
>
<View style={{ width: 40, height: 40, borderRadius: 20, backgroundColor: "#8250df" }} />
<Text style={{ flex: 1, fontSize: 16 }}>A card</Text>
<View onPress={() => liked.set(!liked())} activeStyle={{ opacity: 0.5 }} hitSlop={8}>
<Text style={{ fontSize: 22 }}>{liked() ? "♥" : "♡"}</Text>
</View>
</View>
</View>
);
}
mount(App);#التخطيط
يضع الـ view عناصره الفرعية باستخدام flexbox، بالقيم الافتراضية التالية: flexDirection: "column"، flexShrink: 0،
alignContent: "flex-start"، position: "relative". يحوّل display: "grid" (.grid() في Rust) التخطيط إلى CSS grid، ويحوّله
display: "block" إلى تخطيط الكتل. الأحجام بوحدة النقاط. راجع التخطيط و
مرجع الأنماط.
يتبع display: "flex" سلوك CSS: ففي النمط، وفي الفئة flex وفي .flex() في Rust، يكون الاتجاه صفًا ما لم يُحدَّد اتجاه
(flexDirection: "column"، flex-col، .flex_col()). أما الـ view الذي لا يحدد display فيرتب عناصره الفرعية في عمود.
#الخصائص
يملك View خاصية واحدة خاصة به، هي selectable (.selectable(true) في Rust): تُحدَّد النصوص داخل الـ view معًا كتحديد واحد (تحديد النص). أما بقية الخصائص فهي الخصائص المشتركة: الأنماط (style، class،
classList؛ وطرائق Styled في Rust)، وdisabled، وخصائص الضغط (onPress، onLongPress، onPressDown،
onPressUp، delayLongPress، hitSlop)، وأنماط الحالات (activeStyle، hoverStyle،
focusStyle، disabledStyle، checkedStyle)، وonMouseEnter / onMouseMove / onMouseLeave، وonContextMenu،
وonFileDragEnter / onFileDragLeave / onFileDrop، وfocusable، وonLayout، وref، وnativeID، وtestID، وخصائص
الإيماءات (pan، pinch، rotation وأحداثها)، وخصائص اللمس والاستجابة، وخصائص إمكانية الوصول،
وios / android / desktop.
#الـ views القابلة للتحديد
عند استخدام selectable، يمتد التحديد عبر عناصر Text وTextView داخل
الـ view بترتيب القراءة؛ أما النصوص التي تحمل selectable={false} فتبقى خارج التحديد، والـ view القابل للتحديد داخل آخر يُعدّ جزءًا من
الـ view الخارجي. راجع تحديد النص لمعرفة الإيماءات على كل منصة.
<View selectable class="gap-3 p-4">
<Text>One selection runs from here…</Text>
<Text>…to here.</Text>
</View>
#ردود الفعل عند الضغط
الـ view القابل للضغط هو أي view له معالج ضغط. وردّ الفعل عند الضغط يعود إلى التطبيق: نمط يُطبَّق أثناء الضغط ويُنفَّذ أصليًا
ما دام الإصبع مضغوطًا، دون الرجوع إلى شيفرة التطبيق. وتؤدي hoverStyle وfocusStyle وdisabledStyle الدور
نفسه للحالات الأخرى:
// Fade the whole view.
view().on_press(open).active(|s| s.opacity(0.6))
// Show a background; group_active styles the children.
view()
.group()
.on_press(open)
.active(|s| s.bg(rgb(0xe5e7eb)))
.child(text("Open").group_active(|s| s.opacity(0.7)))
// Change any style while pressed.
view().on_press(open).bg(rgb(0x2563eb)).active(|s| s.bg(rgb(0x1e40af)).scale(0.95))// Fade the whole view.
<View onPress={open} activeStyle={{ opacity: 0.6 }}>…</View>
// Show a background; group-active: styles the children.
<View onPress={open} class="group" activeStyle={{ backgroundColor: "#e5e7eb" }}>
<Text class="group-active:opacity-70">Open</Text>
</View>
// Change any style while pressed with active: classes.
<View onPress={open} class="bg-blue-600 active:bg-blue-800 active:scale-95">…</View>على Android، تضيف android={{ ripple: { color: "#0000001f" } }} (.ripple(Ripple { .. }) في Rust) تأثير
الموجة (ripple) الخاص بـ Material (وتعدّله borderless وradius وforeground).
يزيل disabled مستمعات الضغط من الـ view الأصلي (فلا ضغط ولا ردّ فعل) ويطبّق أنماط التعطيل:
view()
.on_press(submit)
.disabled(move || !valid.get())
.bg(rgb(0x16a34a))
.disabled_style(|s| s.opacity(0.4))<View onPress={submit} disabled={!valid()} class="bg-green-600 disabled:opacity-40">…</View>#الأحداث
| الحدث | Rust | البيانات المرسلة | متى |
|---|---|---|---|
onPress | .on_press | PressEvent (لا شيء في Rust) | انتهت نقرة داخل الـ view. |
onLongPress | .on_long_press | PressEvent (لا شيء في Rust) | بقي الإصبع مضغوطًا مدة delayLongPress (500 مللي ثانية). |
onPressDown / onPressUp | .on_press_down / .on_press_up | PressEvent (لا شيء في Rust) | نزل إصبع على الـ view / رُفع أو غادره. |
onMouseEnter / onMouseLeave | .on_mouse_enter / .on_mouse_leave | MouseEvent | دخل الماوس إلى الـ view (أو إلى view داخله) / غادره أو غادر النافذة (التمرير بالمؤشر)؛ وقيمة onMouseLeave هي آخر موضع كان فيه. |
onMouseMove | .on_mouse_move | MouseEvent | تحرك الماوس فوق الـ view (أو فوق view داخله)، مع زر مضغوط أو بدونه، أو ضُغط مفتاح تعديل أو رُفع بينما يستقر المؤشر هناك دون أي زر مضغوط: position، pagePosition (قيم Point)، modifiers وpressedButton ("left"، "right"، "middle"، "back"، "forward" أو null)؛ مرة واحدة كحد أقصى في كل إطار. |
onContextMenu | .on_context_menu | ContextMenuEvent | ضُغط الزر الثانوي فوق الـ view (القوائم السياقية). |
onFileDragEnter / onFileDragLeave | .on_file_drag_enter / .on_file_drag_leave | لا شيء | دخلت عملية سحب ملفات محلية إلى وجهة سطح المكتب النشطة / غادرتها. |
onFileDrop | .on_file_drop | { uris: string[] } (FileDropEvent) | أُسقطت ملفات أو مجلدات محلية على وجهة سطح المكتب. |
onLayout | .on_layout | { bounds } (Bounds<Points>) | تغيّر الإطار بالنسبة إلى الأصل. |
onPanStart / onPanEnd | .on_pan_start / .on_pan_end | PanEvent | بدأ / انتهى سحب مرتبط بـ pan؛ وتكون قيمة cancelled صحيحة (true) عندما يلغيه النظام. |
onPinchStart / onPinchEnd | .on_pinch_start / .on_pinch_end | { scale, velocity } | بدأت / انتهت إيماءة قرص مرتبطة بـ pinch. |
onRotationStart / onRotationEnd | .on_rotation_start / .on_rotation_end | { rotation, velocity } | بدأ / انتهى تدوير مرتبط بـ rotation. |
onAccessibilityAction | .on_accessibility_action | { actionName } | نُفّذ إجراء إمكانية وصول مخصص. |
أحداث اللمس والاستجابة: راجع الخصائص المشتركة.
#إسقاط الملفات على سطح المكتب
على AppKit وGTK وWin32 وGPUI، يستقبل الـ view الذي يملك onFileDrop الملفات والمجلدات المحلية المسحوبة من تطبيق
آخر. يتتبع onFileDragEnter وonFileDragLeave الوجهة النشطة؛ ويستقبل onFileDrop
{ uris: string[] } بترتيب قائمة الملفات الأصلية. كل عنصر هو عنوان file: مطلق تُهرَّب فيه المسافات
والمحارف غير ASCII. حلِّل العنوان للحصول على مسار نظام الملفات بدلًا من إزالة البادئة يدويًا.
let hovering = signal(false);
let files = signal(Vec::<String>::new());
view()
.min_h(px(160.0))
.p_6()
.on_file_drag_enter(move || hovering.set(true))
.on_file_drag_leave(move || hovering.set(false))
.on_file_drop(move |event: &FileDropEvent| {
files.set(event.uris.clone());
hovering.set(false);
})
.child(text(move || if hovering.get() { "Release files here" } else { "Drop files here" }))
.child(text(move || files.get().join("\n")))const hovering = signal(false);
const files = signal<string[]>([]);
<View
onFileDragEnter={() => hovering.set(true)}
onFileDragLeave={() => hovering.set(false)}
onFileDrop={(event) => {
files.set(event.uris);
hovering.set(false);
}}
style={{ minHeight: 160, padding: 24 }}
>
<Text>{hovering() ? "Release files here" : "Drop files here"}</Text>
<Text>{files().join("\n")}</Text>
</View>أقرب سلف مفعَّل يستمع إلى onFileDrop هو من يستقبل الإسقاط. ويحدّ الجذر المشروط (modal) هذا البحث. يزيل disabled
مستمعات سحب/إسقاط الملفات؛ ويؤدي الخروج والإلغاء والإكمال وإزالة الوجهة إلى مسح الحالة النشطة.
يعتمد القبول على عملية Copy وعلى الحالة الأصلية المركّبة. ويقرر التطبيق ما إذا كان سيقرأ الملفات المستلمة أو يفتحها أو ينقلها.
#طرائق المرجع (Ref)
مرجع الـ view العادي هو ViewRef: focus() وblur() وmeasure(). يعطيك ref (.element_ref(&handle) في Rust) عقدته، مثلًا لتمريرها إلى
setAccessibilityFocus (واجهة إمكانية الوصول الخاصة بـ nsh)، أو لقياسها
(handle.measure() في Rust).
#الـ views داخل النص
الـ view الموضوع داخل نص هو مرفق مضمَّن: يستقر على خط الأساس ويلتف مع الكلمات، ويستقبل ضغطات خاصة به. راجع النص.
#ملاحظات حول المنصات
- يرسم
boxShadowعلى كل المنصات؛ أماandroid={{ elevation }}فيعطي ظل Material الخاص بـ Android بدلًا منه. وعلى Android يحتاج الظل إلى لون خلفية (فالـ view ذو الخلفية الشفافة لا يلقي ظلًا). filterعلى الـ views: يدعم iOSopacity()وbrightness()حتى 1 وdrop-shadow()(وتقبل الصور كل مرشحات الألوان وblur())؛ ويدعمها Android كلها ابتداءً من API 31، ومرشحات الألوان فقط دون ذلك.backdropFilter: "blur(…)"مادة نظام على iOS (تقريبية) ويتطلب API 31 على Android.- يرسم Android تحويلات
translateوscaleوrotate؛ أما التحويلات المائلة (skew()،skewX()،skewY()) فتُطبَّق على المنصات الأخرى. - يرسم
borderCurve: "continuous"(.border_curve(BorderCurve::Continuous)) زوايا تنساب نحو الحواف (كما في أيقونات تطبيقات iOS)، على المنصتين. - يُطبَّق النمط
cursorحيثما يوجد مؤشر (Mac Catalyst، وiPad مع مؤشر، وأجهزة سطح المكتب).