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

التوثيق

التخطيط

يرتّب NSH كل عنصر باستخدام Taffy، وهو تنفيذ بلغة Rust لتخطيطات CSS من نوع flexbox وgrid وblock، مضبوط بحيث يرصّ الأبناء عموديًا ولا ينكمشون افتراضيًا. يعمل التخطيط في النواة بعد كل دورة من شيفرتك، ولا تُرسل إلى العروض الأصلية إلا الإطارات التي تغيّرت. يقيس محرك النصوص في المنصة النصوصَ على الخيط نفسه، لذلك لا ينتظر التخطيط الخيط الرئيسي أبدًا.

guide-layout.tsx
guide-layout.tsxLive
import { mount, Scroll, signal, Text, View } from "nsh";
import { Toggle, ToggleGroup } from "nsh/base";
import { Inspector, Segmented, tint, toggleLabel, toggleLook, ui, wide } from "./palette";

type Direction = "column" | "row";
type Justify = "flex-start" | "center" | "flex-end" | "space-between" | "space-evenly";
type Align = "stretch" | "flex-start" | "center" | "flex-end";

/** A choice's toggles: small, each its value, on a field's edge while off. */
const chip = { size: "small", variant: "outline", text: true } as const;

/**
 * One of values too long for a segmented control's single row (five of
 * CSS's `justifyContent`): a toggle group that wraps, exactly one pressed.
 * Shorter choices are a `Segmented`.
 */
function Choice<T extends string>(props: { label: string; options: readonly T[]; value: T; onChange: (v: T) => void }) {
  return (
    <View style={{ gap: 6 }}>
      <Text style={{ fontSize: 12, color: ui().muted }}>{props.label}</Text>
      <ToggleGroup style={{ flexDirection: "row", flexWrap: "wrap", gap: 6 }}>
        <Each of={props.options}>
          {(o) => {
            const on = () => props.value === o();
            return (
              // Exactly one: the pressed value stays pressed.
              <Toggle pressed={on()} onChange={({ pressed }) => pressed && props.onChange(o())} {...toggleLook(chip)}>
                <Text style={toggleLabel(on, chip)}>{o()}</Text>
              </Toggle>
            );
          }}
        </Each>
      </ToggleGroup>
    </View>
  );
}

function App() {
  const direction = signal<Direction>("row");
  const justify = signal<Justify>("flex-start");
  const align = signal<Align>("stretch");
  const colors = () => [ui().down, ui().info, ui().up];

  const box = (height: number) => (
    <View
      style={{
        height,
        padding: 10,
        gap: 8,
        borderRadius: 12,
        ...ui().inset,
        flexDirection: direction(),
        justifyContent: justify(),
        alignItems: align(),
      }}
    >
      <Each of={colors()}>
        {(c, i) => (
          <View style={{ minWidth: 44, minHeight: 44, padding: 6, borderRadius: 8, backgroundColor: c() }}>
            <Text style={{ color: ui().page, fontWeight: "700" }}>{i() + 1}</Text>
          </View>
        )}
      </Each>
    </View>
  );
  const more = () => (
    <>
      <Text style={{ fontSize: 12, fontWeight: "600", color: ui().muted }}>flex: 1 / 2 / fixed, percentages, aspectRatio</Text>
      <View style={{ flexDirection: "row", gap: 8 }}>
        <View style={{ flex: 1, height: 36, borderRadius: 6, backgroundColor: tint(ui().info, 0.3) }} />
        <View style={{ flex: 2, height: 36, borderRadius: 6, backgroundColor: tint(ui().info, 0.6) }} />
        <View style={{ width: 36, height: 36, borderRadius: 6, backgroundColor: ui().info }} />
      </View>
      <View style={{ flexDirection: "row", gap: 8, alignItems: "flex-start" }}>
        <View style={{ width: "30%", aspectRatio: 1, borderRadius: 8, backgroundColor: tint(ui().warn, 0.35) }} />
        <View style={{ width: "40%", aspectRatio: 16 / 9, borderRadius: 8, backgroundColor: ui().warn }} />
      </View>

      <Text style={{ fontSize: 12, fontWeight: "600", color: ui().muted }}>grid</Text>
      <View style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 6 }}>
        <View style={{ gridColumn: "span 2", height: 40, borderRadius: 6, backgroundColor: tint(ui().violet, 0.3) }} />
        <View style={{ height: 40, borderRadius: 6, backgroundColor: tint(ui().violet, 0.6) }} />
        <View style={{ gridRow: "span 2", borderRadius: 6, backgroundColor: ui().violet }} />
        <View style={{ gridColumn: "1 / 4", height: 40, borderRadius: 6, backgroundColor: tint(ui().violet, 0.8) }} />
      </View>

      <Text style={{ fontSize: 12, fontWeight: "600", color: ui().muted }}>position: absolute</Text>
      <View style={{ ...ui().inset, height: 100, borderRadius: 12 }}>
        <View style={{ position: "absolute", top: 8, left: 8, width: 60, height: 40, borderRadius: 6, backgroundColor: ui().up }} />
        <View style={{ position: "absolute", right: 8, bottom: 8, width: "40%", height: 30, borderRadius: 6, backgroundColor: ui().down }} />
        <View style={{ position: "absolute", top: 0, bottom: 0, left: "50%", width: 2, backgroundColor: ui().border }} />
      </View>
    </>
  );
  const justifyChoice = () => (
    <Choice
      label="justifyContent"
      options={["flex-start", "center", "flex-end", "space-between", "space-evenly"] as const}
      value={justify()}
      onChange={(v) => justify.set(v)}
    />
  );
  const directionChoice = () => (
    <Segmented label="flexDirection" options={["row", "column"] as const} value={direction()} onChange={(v) => direction.set(v)} />
  );
  const alignChoice = () => (
    <Segmented label="alignItems" options={["stretch", "flex-start", "center", "flex-end"] as const} value={align()} onChange={(v) => align.set(v)} />
  );

  return (
    <>
      {() =>
        wide() ? (
          <View style={{ flex: 1, backgroundColor: ui().page, flexDirection: "row", padding: 20, paddingTop: 24, gap: 16 }}>
            <Scroll style={{ flex: 1, minWidth: 0 }}>
              <View style={{ gap: 14 }}>
                {box(260)}
                {more()}
              </View>
            </Scroll>
            <Inspector>
              {directionChoice()}
              {justifyChoice()}
              {alignChoice()}
            </Inspector>
          </View>
        ) : (
          <Scroll style={{ flex: 1, backgroundColor: ui().page }}>
            <View style={{ padding: 20, paddingTop: 24, gap: 14 }}>
              {directionChoice()}
              {justifyChoice()}
              {alignChoice()}
              {box(220)}
              {more()}
            </View>
          </Scroll>
        )
      }
    </>
  );
}

mount(App);

خصائص التخطيط أدناه هي خصائص أنماط. في Rust هي دوال نمط مُنمَّطة بأنواع (.flex_row()، .gap_4()، .grid_cols(3)، .absolute().inset_0())؛ وفي TSX هي مفاتيح الخاصية style، ولكل منها فئة Tailwind (flex-row، gap-4، grid-cols-3، absolute inset-0، …). وكلاهما يُحوَّل إلى الأنماط الأصلية نفسها.

#Flexbox

يرتّب الـ View أبناءه باستخدام flexbox. الـ View الذي لا يضبط display يرصّ أبناءه في عمود؛ وdisplay: "flex" في نمط ما يجعله صفًّا ما لم تُضبط flexDirection، تمامًا كما في CSS وكما في فئة Tailwind ‏flex. عندما لا يضبط شيء هذه القيم، تُطبَّق القيم الافتراضية التالية:

الخاصية الافتراضي في NSH الافتراضي في الويب
flexDirection "column" (View لا يضبط display)؛ "row" بمجرد ضبط display: "flex" row
flexShrink 0 1
alignContent "flex-start" (الشبكات: "normal") normal
alignItems "stretch" stretch
minWidth / minHeight 0 auto
position "relative" static
view()
    .flex_row()
    .items_center()
    .gap_3()
    .p_4()
    .child(image(avatar).size(px(40.0)).rounded(px(20.0)))
    .child(
        view()
            .flex_1()
            .gap_0p5()
            .child(text(name).font_weight(FontWeight::SEMIBOLD))
            .child(text(message).text_color(rgb(0x6e7781)).number_of_lines(1)),
    )
    .child(text(time).text_color(rgb(0x6e7781)))
الخاصية القيم Rust
flexDirection "column"، "row"، "column-reverse"، "row-reverse" .flex_col()، .flex_row()، .flex_col_reverse()، .flex_row_reverse()
justifyContent المحور الرئيسي: "flex-start"، "flex-end"، "start"، "end"، "center"، "space-between"، "space-around"، "space-evenly"، "stretch" .justify_start()، .justify_center()، .justify_end()، .justify_between()، .justify_around()، .justify_evenly()
alignItems المحور المتقاطع: "stretch"، "flex-start"، "flex-end"، "start"، "end"، "self-start"، "self-end"، "center"، "baseline" .items_stretch()، .items_start()، .items_center()، .items_end()، .items_baseline()
alignSelf محاذاة ابن واحد على المحور المتقاطع؛ "auto" تتبع alignItems الخاصة بالأب .self_start()، .self_center()، .self_end()، .self_stretch()
alignContent أسطر الحاوية الملتفّة
flexWrap "nowrap"، "wrap"، "wrap-reverse" .flex_wrap()
flex اختصار برقم واحد (أدناه) .flex_1()، .flex_auto()، .flex_none()
flexGrow، flexShrink، flexBasis كما في CSS .flex_grow(n)، .flex_shrink(n)، .flex_shrink_0()، .basis(length)
gap، rowGap، columnGap المسافة بين الأبناء .gap(length)، .gap_y(length)، .gap_x(length)، والخطوات .gap_2()، .gap_x_4()، …

flex-start وflex-end تتبعان اتجاه flex؛ أما start وend فتتبعان اتجاه الكتابة، كما في CSS (من اليمين إلى اليسار، start هو الجانب الأيمن)، وكذلك self-start / self-end الخاصتان بالابن نفسه.

flex هو اختصار CSS، مثل فئات Tailwind ‏flex-*: الرقم n يعني النمو بمقدار n، والانكماش بمقدار 1، والبدء من أساس قدره 0، وهكذا يتقاسم الأشقاء flex: 1 (.flex_1() في Rust، flex-1) المساحة بالتساوي مهما كان محتواهم؛ و"none" تعني 0 0 auto (يحتفظ الـ View بحجمه)، و"auto" تعني 1 1 auto، و"initial" تعني 0 1 auto، ويمكن كتابة النمو والانكماش والأساس صراحةً ("2 0 50%"، "1 1 120px"). ولأن الحدود الدنيا للأحجام تساوي 0 افتراضيًا، فإن flex: 0 (0 1 0) تُنقص حجم الـ View إلى لا شيء ما لم تُبقِ minWidth: "auto" على حجم محتواه.

view()
    .flex_row()
    .gap_2()
    // One share.
    .child(view().flex_1().h(px(36.0)).bg(rgb(0xc8e1ff)))
    // Two shares.
    .child(view().flex_grow(2.0).flex_shrink(1.0).basis(px(0.0)).h(px(36.0)).bg(rgb(0x79b8ff)))
    // Fixed.
    .child(view().size(px(36.0)).bg(rgb(0x0366d6)))

ولأن الحدود الدنيا للأحجام تساوي 0 افتراضيًا، فإن نص flex: 1 داخل صف ينكمش ويلتف بدلًا من دفع أشقائه إلى الخارج. أما minWidth: "auto" / minHeight: "auto" (min-w-auto / min-h-auto، .min_w(auto()) في Rust) فهي الحد الأدنى التلقائي في CSS: العنصر المنكمش يتوقف عند أصغر حجم لمحتواه، أي أطول كلمة في النص، كما في الويب.

#الأحجام

الخاصية القيم Rust
width، height نقاط، أو نسبة مئوية من الأب ("50%")، أو "auto" .w(px(120.0))، .h(relative(0.5))، .w_full()، .size(px(44.0))، والخطوات .w_10()، …
minWidth، maxWidth، minHeight، maxHeight نقاط أو نسب مئوية؛ وminWidth / minHeight تقبلان أيضًا "auto"، أي أصغر حجم للمحتوى .min_w(..)، .max_w(..)، .min_h(..)، .max_h(..)
aspectRatio العرض مقسومًا على الارتفاع: 1، 16 / 9 .aspect_ratio(16.0 / 9.0)، .aspect_square()
padding، paddingHorizontal، paddingVertical، paddingTop، … نقاط أو نسب مئوية .p(..)، .px(..)، .py(..)، .pt(..)، …، والخطوات .p_4()، .px_2()، …
margin، marginHorizontal، marginVertical، marginTop، … نقاط أو نسب مئوية أو "auto" .m(..)، .mx(..)، .my(..)، .mt(..)، …، .mx(auto())

في Rust يكون الطول مُنمَّطًا بنوع: px(16.0) تساوي 16 نقطة، وrelative(0.5) تساوي 50% من الأب، وauto() هي auto. تتبع دوال الخطوات مقياس المسافات في Tailwind بواقع 4 نقاط لكل خطوة (.p_4() تساوي 16 نقطة، و.gap_0p5() تساوي نقطتين).

بدون حجم، يكون الـ View بحجم محتواه (مضافًا إليه الحشو)، ويُمدَّد على المحور المتقاطع لأبيه بفعل alignItems: "stretch" الافتراضية. النسب المئوية نسبةً إلى حجم الأب (وفي الحشو والهامش، كما في CSS، نسبةً إلى عرضه).

aspectRatio يحدد البعد الآخر انطلاقًا من البعد المعروف:

view().w_full().aspect_ratio(16.0 / 9.0).bg(black())

للصور القادمة من أصول التطبيق حجم طبيعي، وتُرتَّب بهذا الحجم عندما لا يُحدَّد حجم (الصور والأصول). أما الصور البعيدة فلا حجم لها حتى تُحمَّل: حدّد لها حجمًا.

#Grid

display: "grid" (.grid() في Rust) يرتّب الأبناء في مسارات شبكة CSS:

view()
    .grid()
    .grid_template_columns("repeat(3, 1fr)")
    .gap_2()
    .child(view().col_span(2).h(px(60.0)).bg(rgb(0xd8b9ff)))
    .child(view().h(px(60.0)).bg(rgb(0xb083f0)))
    .child(view().row_span(2).bg(rgb(0x8250df)))
    .child(view().col_start(2).col_end(4).h(px(60.0)).bg(rgb(0x6639ba)))
الخاصية القيم Rust
gridTemplateColumns، gridTemplateRows قوائم مسارات CSS: "repeat(3, 1fr)"، "200px 1fr"، minmax()، auto-fill / auto-fit، وأسماء الخطوط .grid_cols(3)، .grid_rows(2)، .grid_template_columns("200px 1fr")، .grid_template_rows(..)
gridAutoColumns، gridAutoRows مسارات الأعمدة والصفوف المُنشأة ضمنيًا
gridAutoFlow "row"، "column"، "dense"، "row dense"، "column dense" .grid_flow_col()، .grid_flow_dense()
gridColumn، gridRow "1 / 3"، "span 2"، رقم خط أو اسمه: "content-start / content-end"، "2 col" (الخط الثاني المسمّى col)، "span col" (حتى الخط التالي المسمّى col) .col_span(2)، .row_span(2)، .col_span_full()
gridColumnStart، gridColumnEnd، gridRowStart، gridRowEnd أرقام الخطوط أو أسماؤها .col_start(1)، .col_end(3)، .row_start(..)، .row_end(..) (أرقام الخطوط)
justifyItems، justifySelf كيف تستقر العناصر في مناطقها على امتداد الصف: "start"، "end"، "center"، "stretch"، …
alignItems، alignSelf كيف تستقر العناصر في مناطقها على امتداد العمود .items_*()، .self_*()
justifyContent، alignContent كيف تستقر المسارات في الشبكة على امتداد الصف والعمود .justify_*()

تسمّي القوالب خطوطها بين أقواس مربعة، اسمًا واحدًا أو عدة أسماء لكل خط، وتوضع العناصر بحسب هذه الأسماء، كما في CSS (وفي Rust بحسب أرقام الخطوط):

view()
    .grid()
    .grid_template_columns("[full-start] 16px [content-start] 1fr [content-end] 16px [full-end]")
    // content-start / content-end: lines 2 to 3.
    .child(view().col_start(2).col_end(3).h(px(60.0)).bg(rgb(0xb083f0)))
    // full-start / full-end: lines 1 to 4.
    .child(view().col_start(1).col_end(4).h(px(60.0)).bg(rgb(0x8250df)))

في الشبكة، تكون القيمة الافتراضية لـjustifyContent وalignContent هي "normal" في CSS: المسارات ذات الحجم auto تتمدد لملء الحاوية، لذلك تتقاسم الشبكة التي لها ارتفاع وبلا قالب صفوف هذا الارتفاع بين صفوفها. أما القيمة المضبوطة صراحةً ("start"، "center"، "space-between"، …) فتضع المسارات كما تفعل في CSS. وتضبط فئات المحاذاة ما تضبطه Tailwind CSS بالضبط: content-start (align-content: flex-start) وplace-content-start تجمعان المسارات عند البداية، وcontent-normal وjustify-normal تمدّدانها، كما في المتصفح.

display: "block" يستخدم تخطيط block في CSS (الأبناء مرصوصون، وكلٌّ منهم بعرض الحاوية)، وdisplay: "none" (.hidden()) يزيل الـ View من التخطيط.

#التموضع المطلق

position: "absolute" (.absolute()) يُخرج الـ View من التدفق ويضعه بواسطة top وright وbottom وleft، نسبةً إلى أبيه:

view()
    .h(px(200.0))
    .child(image(cover).absolute().inset_0())
    .child(
        view()
            .absolute()
            .left_3()
            .right_3()
            .bottom_3()
            .child(text("Caption").text_color(white()).font_weight(FontWeight::BOLD)),
    )

تقبل الإزاحات نقاطًا ونسبًا مئوية (.top(..)، .right(..)، .bottom(..)، .left(..)، .inset(..) في Rust). الـ View الذي له left وright (أو top وbottom) وبلا حجم يتمدد بينهما. تُرسم الأشقاء اللاحقة فوق السابقة؛ وzIndex (.z_index(n)) يغيّر الترتيب.

#القياس

onLayout (.on_layout(..)) يُبلغ عن إطار الـ View، نسبةً إلى أبيه، بعد كل تخطيط تغيّر فيه:

let width = signal(0.0_f32);

view()
    .on_layout(move |frame: &Bounds<Points>| width.set(frame.size.width.0))
    .child(text(move || format!("{} points wide", width.get())))

الإطار بالنقاط: Bounds<Points> في Rust، و{ bounds } في TSX، وهو Bounds ({ origin: { x, y }, size: { width, height } }). لقراءة النافذة كاملةً، اقرأ window() في Rust أو windowSize() من nsh في TSX؛ وكلاهما يتبع التدوير وتغيير حجم النافذة، والنمط الذي يقرأهما يتغير مع النافذة:

view().style_when(|| window().width > px(600.0), |s| s.flex_row())

نقاط التوقف في Tailwind (sm:، md:، …) وportrait: / landscape: تعطي النتيجة نفسها بدون شيفرة.

#المناطق الآمنة

يغطي الـ View الجذر الشاشة كلها، تحت شريط الحالة ومؤشر الصفحة الرئيسية. أضف حشوًا للشاشات بمقدار حواف المنطقة الآمنة باستخدام env() في CSS (paddingTop: "env(safe-area-inset-top)"، وفي Tailwind ‏pt-[env(safe-area-inset-top)]، و.p_safe_area() في Rust؛ راجع المنطقة الآمنة)، أو اقرأ الحواف بواسطة safe_area() في Rust أو safeArea() / safeAreaInsets() في TSX وطبّقها بنفسك. أما صفحات مكدّس التنقل فتقع أصلًا تحت ترويستها.

#من اليمين إلى اليسار

يتبع اتجاه التخطيط في التطبيق لغة النظام، ويحدد النمط direction للـ View ("ltr" أو "rtl" أو "inherit"؛ و.direction(LayoutDirection::Rtl) في Rust) اتجاه شجرته الفرعية. عندما يُرتَّب التطبيق من اليمين إلى اليسار:

  • تمتد الصفوف من اليمين؛
  • تحتفظ left وright بجانبيهما، كما في المتصفح، في مفاتيح الأنماط والفئات على حد سواء: paddingLeft، marginLeft، left، borderLeftWidth، borderTopLeftRadius، textAlign: "left" وpl-4، ml-2، left-0، border-l، rounded-tl-lg، text-left تبقى على اليسار؛
  • تتبع الخصائص المنطقية الاتجاه، حيث تكون بداية السطر هي الجانب الأيمن: marginStart، marginEnd، paddingStart، paddingEnd، start، end، borderStartWidth، borderEndColor، borderTopStartRadius، …، textAlign: "start" / "end" (ps-4، ms-2، start-0، border-s، rounded-ss-lg، text-start كفئات؛ و.ms(..)، .me(..)، .ps(..)، .pe(..)، .border_s(..)، .rounded_ss(..)، … في Rust)؛ وmarginHorizontal وpaddingHorizontal تضبطان الجانبين معًا؛
  • يُحاذى النص ذو textAlign: "auto" إلى اليمين، وتنعكس عناصر التحكم الأصلية.
view()
    .flex_row()
    .gap_2()
    .child(view().ms_4().rounded_ss(px(12.0)).size(px(44.0)).bg(rgb(0x54aeff)))

let rtl = signal(false);
view()
    .direction(move || if rtl.get() { LayoutDirection::Rtl } else { LayoutDirection::Ltr })
    .on_press(move |_: &()| rtl.set(!rtl.get()))
    .child(text(move || if rtl.get() { "Right to left" } else { "Left to right" }))

تُطبَّق التغييرات في الاتجاه، سواء في اللغة أو في View معيّن، فورًا ودون إعادة تشغيل. كما يُبقي النمط direction View واحدًا وأبناءه في اتجاه خاص بهم، مثلًا رقم هاتف من اليسار إلى اليمين. على Android، تحتاج التطبيقات التي تضمّن NSH إلى android:supportsRtl="true" في ملف manifest الخاص بها كي تنعكس عناصر التحكم الأصلية.