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

العناصر

Command

لوحة الأوامر (command palette) هي حقل بحث فوق قائمة من الأوامر: يكتب المستخدم بضعة أحرف فتضيق القائمة إلى الأوامر المطابقة، وتحرّك مفاتيح الأسهم التمييز، ويشغّل Return الأمر المميَّز. Command يتولى سلوك لوحة كهذه: التصفية والأقسام والتمييز ولوحة المفاتيح وما تسمعه قارئات الشاشة. تعمل التصفية أصلاً (natively) والصفوف افتراضية (virtualized): لا يُنشأ إلا الصفوف الظاهرة، فتُصفّى لوحة تضم آلاف الأوامر أو الملفات أو الرموز التعبيرية عند كل ضغطة مفتاح. المظهر من شأن التطبيق: لا يرسم الأمر شيئًا بنفسه، بل ينسّق التطبيق كل جزء (itemClass، groupClass، searchClass، …) ويرسم محتوى الصفوف (renderItem: أيقونة والتسمية ومفاتيح الاختصار).

el-command.tsx
el-command.tsxLive
import { keymap, mount, signal, Text, View } from "nsh";
import { Command, type CommandItem, type CommandRef } from "nsh/base";
import {
  CommandEmpty,
  CommandHeading,
  CommandRow,
  commandClasses,
  desktop,
  Icon,
  type IconName,
  Log,
  Panel,
  Split,
  ui,
  wide,
} from "./palette";

// A command palette: sections with headings, shortcuts shown as the
// platform writes them, a disabled command, the appearance in effect
// checked, keywords that find a command by another word. The command owns
// the filtering, the highlight, the keys and the semantics; every pixel is
// `commandClasses` and `CommandRow`.
const icons: Record<string, IconName> = {
  new: "plus",
  open: "folder",
  save: "download",
  share: "share",
  find: "search",
  replace: "replace",
  terminal: "terminal",
  sidebar: "sidebar",
  dark: "moon",
  light: "sun",
  docs: "document",
  feedback: "chat",
};

const appearance = signal<"dark" | "light">("dark");
const highlighted = signal<string | null>(null);
const query = signal("");
const events = signal<string[]>([]);
const log = (line: string) => events.set([line, ...events()].slice(0, 4));
let palette: CommandRef | undefined;

const items = (): CommandItem[] => [
  { id: "new", label: "New File", group: "File", shortcut: "mod+n" },
  { id: "open", label: "Open Folder…", group: "File", shortcut: "mod+o" },
  { id: "save", label: "Save All", group: "File", shortcut: "mod+alt+s" },
  { id: "share", label: "Share Project", group: "File", disabled: true },
  { id: "find", label: "Find in Files", group: "Edit", shortcut: "mod+shift+f", keywords: ["search", "grep"] },
  { id: "replace", label: "Replace in Files", group: "Edit", shortcut: "mod+shift+h" },
  { id: "terminal", label: "Toggle Terminal", group: "View", shortcut: "ctrl+j", keywords: ["console", "shell"] },
  { id: "sidebar", label: "Toggle Sidebar", group: "View", shortcut: "mod+b" },
  { id: "dark", label: "Dark Appearance", group: "View", keywords: ["theme"], checked: appearance() === "dark" },
  { id: "light", label: "Light Appearance", group: "View", keywords: ["theme"], checked: appearance() === "light" },
  { id: "docs", label: "Open Documentation", group: "Help", keywords: ["guide", "manual"] },
  { id: "feedback", label: "Send Feedback", group: "Help" },
];

function App() {
  keymap.bind("mod+k", () => palette?.focus());
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>command</Text>
      <Text style={{ color: ui().muted }}>
        {desktop
          ? `Type to filter, the arrows to move, Return to run, Escape to clear; ${keymap.label("mod+k")} focuses it`
          : "Type to filter, tap a command to run it"}
      </Text>

      <Split
        gap={14}
        inspector={
          <>
            <Panel title="highlighted">
              <Text style={{ fontSize: 13, color: ui().text }}>{highlighted() ?? "none"}</Text>
            </Panel>
            <Panel title="query">
              <Text style={{ fontSize: 13, color: ui().text }}>{query() ? `“${query()}”` : "empty"}</Text>
            </Panel>
            <Log title="events" lines={events()} minHeight={92} />
          </>
        }
      >
        <View style={{ width: wide() ? 380 : undefined, alignSelf: wide() ? "center" : "stretch" }}>
          <Command
            ref={(r) => (palette = r)}
            items={items()}
            query={query()}
            onQueryChange={({ query: next }) => query.set(next)}
            onHighlightChange={({ highlighted: next }) => highlighted.set(next)}
            onCancel={() => log("onCancel()")}
            onSelect={({ id }) => {
              if (id === "dark" || id === "light") appearance.set(id);
              log(`onSelect({ id: "${id}" })`);
            }}
            placeholder="Type a command or search…"
            accessibilityLabel="Commands"
            searchAccessibilityLabel="Search commands"
            class={commandClasses.root}
            searchBarClass={commandClasses.searchBar}
            searchClass={commandClasses.search}
            listClass={commandClasses.list}
            groupClass={commandClasses.group}
            separatorClass={commandClasses.separator}
            itemClass={commandClasses.item}
            style={{ height: desktop ? 360 : 400 }}
            searchPrefix={<Icon name="search" size={16} tint={ui().muted} />}
            empty={<CommandEmpty label="No commands match" />}
            renderGroup={(group) => <CommandHeading group={group} />}
            renderItem={(entry) => <CommandRow entry={entry} icon={icons[entry.id]} note={entry.disabled ? "Pro" : undefined} />}
          />
        </View>
      </Split>
    </View>
  );
}

mount(App);
use nsh_base::{CommandEntry, CommandItem, command, theme};
use nsh_core::prelude::*;

fn palette() -> impl IntoElement {
    let items = vec![
        CommandItem::new("new", "New File").group("File").shortcut("mod+n"),
        CommandItem::new("open", "Open…").group("File").shortcut("mod+o"),
        CommandItem::new("theme", "Toggle Theme").group("View").keywords(["dark", "light"]),
        CommandItem::new("settings", "Settings").group("View").shortcut("mod+,"),
    ];
    let chosen = signal(SharedString::from("none"));
    command()
        .items(items)
        .placeholder("Type a command or search…")
        .accessibility_label("Commands")
        .auto_focus(true)
        .on_select(move |id: &SharedString| chosen.set(id.clone()))
        .empty(text("No results.").text_color(move || theme().muted_foreground))
        .h(px(360.0))
        .render_item(|entry: &CommandEntry| {
            view()
                .flex_row()
                .items_center()
                .h_8()
                .px_2()
                .child(text(entry.label.clone()).flex_1())
                .child(
                    text(entry.shortcut_label.clone().unwrap_or_default())
                        .text_color(move || theme().muted_foreground),
                )
        })
}

Command يأتي من nsh/base، حزمة nsh-base في Rust. يكون الاستعلام ملكًا للتطبيق حين يضبط query ويحدّثه في onQueryChange؛ وبدونه يحتفظ حقل البحث باستعلامه الخاص، بادئًا من defaultQuery. تُعرَّف العناصر بـ id الخاص بالتطبيق، لا بتسمياتها أبدًا: onSelect({ id }) يحدد أيها اختاره المستخدم. في Rust، command() ينشئه، مع دالة ضبط لكل خاصية (.items(..) و.query(..) و.render_item(..))، ودالة مستمع لكل حدث، ودوال التنسيق المنمطة.

#العناصر

items هي الأوامر، بالترتيب، وكل منها CommandItem:

الحقلالنوعالوصف
idstringما يعرّفه في onSelect وonHighlightChange: فريد بين العناصر.
labelstringما يعرضه صفه ما لم يرسمه renderItem، وما تنطق به قارئات الشاشة، وما يطابقه الاستعلام.
keywordsstring[]كلمات إضافية يطابقها الاستعلام: مرادفات، أو اسم بالإنجليزية بجانب تسمية مترجمة.
groupstringقسمه: تظهر العناصر المتجاورة من المجموعة نفسها تحت ترويسة بهذا العنوان.
shortcutstringضغطة المفاتيح للأمر نفسه، كما يسميها keymap ("mod+shift+p")، ليعرضها صفه. يربطها التطبيق بـ keymap.bind.
disabledbooleanلا يمكن اختياره: تتخطاه المفاتيح، ولا تفعل الضغطات شيئًا، وتُطبَّق أنماط disabled:.
checkedbooleanهو العنصر الساري (سمة، لغة): تُطبَّق أنماط checked: وتذكر قارئات الشاشة أنه محدد.

يطابق الاستعلام عنصرًا حين تحتويه تسميته أو إحدى كلماته المفتاحية، بغض النظر عن حالة الأحرف والمسافات المحيطة به. تبقى العناصر على ترتيبها، ويختفي القسم الذي لا مطابقة فيه مع ترويسته. يتلقى renderItem قيمة CommandEntry لكل صف ظاهر: حقول العنصر، وshortcutLabel، أي shortcut كما تعرضه التلميحات هنا (⇧⌘P على منصات Apple، وCtrl+Shift+P في غيرها). هذا هو ما يكونه العنصر، أما التمييز فهو focus: الصف: يُنجز الرسم مرة واحدة لكل عنصر ويحتفظ بعروضه بينما ينتقل التمييز.

#الخصائص

الخاصيةRustالنوعالقيمة الافتراضيةالوصف
items.items(..)CommandItem[][]الأوامر، بالترتيب.
query.query(..)stringلا شيءالاستعلام (متحكَّم به)، ويضبطه التطبيق في onQueryChange.
defaultQuery.default_query(..)stringلا شيءالاستعلام في البداية، ما دام query غير مضبوط.
searchable.searchable(..)booleantrueحقل بحث فوق الصفوف. بدونه تظهر كل العناصر، ويكون الأمر محطة واحدة في تركيز لوحة المفاتيح مع الكتابة التنبؤية (typeahead).
filterable.filterable(..)booleantrueيصفّي الاستعلام العناصر. عند إيقافه تظهر كل العناصر: لبحث يجيب عنه التطبيق بنفسه (نتائج خادم تُضبط في items). ويظل الاستعلام الجديد يميّز العنصر الأول.
loading.loading(..)booleanfalseبحث قيد التنفيذ: empty ينتظر، والقائمة مشغولة بالنسبة لقارئات الشاشة. اعرض مؤشرك في searchSuffix.
placeholder.placeholder(..)stringلا شيءالنص البديل (placeholder) لحقل البحث.
autoFocus.auto_focus(..)booleanfalseيأخذ حقل البحث تركيز لوحة المفاتيح بمجرد ظهوره: لوحة تُفتح ليُكتب فيها.
separator.separator(..)booleantrueصف فاصل بين قسمين ظاهرين، ينسّقه separatorClass؛ لا يكون أول ولا أخيرًا ولا مكررًا.
accessibilityLabel.accessibility_label(..)stringلا شيءما تسمي به قارئات الشاشة القائمة، وحقل البحث ما دام searchAccessibilityLabel غير مضبوط (“Commands”).
searchAccessibilityLabel.search_accessibility_label(..)stringaccessibilityLabelما تسمي به قارئات الشاشة حقل البحث: ما يبحث فيه (“Search commands”).
header، footer.header(..)، .footer(..)JSX.Elementلا شيءما يظهر فوق حقل البحث (عنوان) وتحت الصفوف (تلميحات المفاتيح، عدد).
empty.empty(..)JSX.Elementلا شيءما يظهر حين لا يظهر أي عنصر: استعلام لا يطابق شيئًا، أو لا عناصر.
searchPrefix، searchSuffix.search_prefix(..)، .search_suffix(..)JSX.Elementلا شيءما يظهر قبل حقل البحث وبعده في شريطه: أيقونة بحث، ومؤشر دوران أثناء loading.
renderItem.render_item(..)(entry: CommandEntry) => JSX.Elementلا شيءمحتوى الصف، للصفوف الظاهرة؛ وبدونه يعرض الصف تسمية العنصر.
renderGroup.render_group(..)(group: CommandGroup) => JSX.Elementلا شيءترويسة القسم ({ heading })؛ وبدونها تعرض الترويسة عنوانه.
class، styleدوال التنسيقstring، StyleProp<ViewStyle>لا شيءالأمر نفسه: حجمه وإطاره وسطحه.
searchBarClass، searchBarStyle.search_bar_class(..)، .search_bar_style(..)string، StyleProp<ViewStyle>لا شيءصف searchPrefix وحقل البحث وsearchSuffix.
searchClass، searchStyle.search_class(..)، .search_style(..)string، StyleProp<TextStyle>لا شيءحقل البحث.
listClass، listStyle.list_class(..)، .list_style(..)string، StyleProp<ViewStyle>لا شيءعرض التمرير للصفوف. أعطه ارتفاعًا (max-h-80، أو أمرًا محدد الحجم): تتمرر الصفوف داخله ولا يُنشأ إلا الظاهر منها.
itemClass، itemStyle.item_class(..)، .item_style(..)string، StyleProp<TextStyle>لا شيءصف كل عنصر وحالاته (أدناه)؛ وتصل أنماط نصه إلى التسمية.
groupClass، groupStyle.group_class(..)، .group_style(..)string، StyleProp<TextStyle>لا شيءترويسة كل قسم.
separatorClass، separatorStyle.separator_class(..)، .separator_style(..)string، StyleProp<ViewStyle>لا شيءكل فاصل: ارتفاع الخط ولونه.

#الأحداث

الحدثRustالحمولةمتى
onSelect.on_select(..){ id: string }اختار المستخدم عنصرًا: Return، أو ضغطة على صفه.
onHighlightChange.on_highlight_change(..){ highlighted: string | null }انتقل التمييز (المفاتيح، المؤشر، استعلام جديد أو عناصر جديدة، highlight): لمعاينة ما سيشغّله Return.
onQueryChange.on_query_change(..){ query: string }كتب المستخدم في حقل البحث، أو مسحه Escape.
onCancel.on_cancel(..)لا شيءEscape مع استعلام فارغ: أغلق ما يحتوي الأمر (open في مربع حوار).

#الأجزاء

الجزءداخليُنسَّق بـالحالات
الأمرclass، style
headerالأمرعنصر التطبيق
شريط البحثالأمرsearchBarClass، searchBarStyle
searchPrefix، حقل البحث، searchSuffixشريط البحثعناصر التطبيق؛ searchClass، searchStylefocus: الحقل
القائمةالأمرlistClass، listStyle
ترويسة القسمالقائمةgroupClass، groupStyle، renderGroup
الفاصلالقائمةseparatorClass، separatorStyle
الصفالقائمةitemClass، itemStylefocus: (مميَّز)، checked:، disabled:، hover:، active:
محتوى الصفالصفrenderItemgroup-focus:، group-checked:، group-disabled: تحت group في itemClass
emptyالأمرعنصر التطبيق
footerالأمرعنصر التطبيق

أظهر الصف المميَّز بـ focus: (انظر إرشادات التصميم).

#Ref

الدالةالوصف
focus()تعطي حقل البحث تركيز لوحة المفاتيح (وبدونه، الأمر).
highlight(id)تميّز عنصر id وتمرر صفه إلى الظهور؛ أما العنصر غير الظاهر أو المعطّل فيبقى كما هو.
matchedCount()Promise<number>: عدد العناصر التي يظهرها الاستعلام، بما فيها المعطّلة.
let palette: CommandRef | undefined;

<Command ref={(r) => (palette = r)} items={items} />;
palette?.focus();

#لوحة أوامر داخل مربع حوار

ضع الأمر في Dialog وافتحه باختصار التطبيق. يمسح Escape الاستعلام أولًا؛ ومع استعلام فارغ يصبح onCancel، حيث يغلق التطبيق مربع الحوار.

import { keymap } from "nsh";

const open = signal(false);
keymap.bind("mod+k", () => open.set(!open()));

<Dialog open={open()} onOpenChange={({ open: next }) => open.set(next)}>
  <Command
    items={commands}
    autoFocus
    onCancel={() => open.set(false)}
    onSelect={({ id }) => {
      open.set(false);
      run(id);
    }}
  />
</Dialog>;

#بحث يجيب عنه التطبيق

مع filterable={false} تظهر كل العناصر التي يعطيها التطبيق: يبحث التطبيق في onQueryChange (خادم، قاعدة بيانات) ويضبط items بالنتائج، وloading أثناء انتظاره.

<Command
  items={results()}
  filterable={false}
  loading={searching()}
  onQueryChange={({ query }) => search(query)}
  searchSuffix={searching() ? <Spinner /> : undefined}
  empty={<Text>No matches</Text>}
/>

#إمكانية الوصول

  • للقائمة الدور "listbox"، وتسميها accessibilityLabel؛ وكل صف هو "option" تسميه تسمية عنصره، ويكون محددًا ومعطّلًا كما يكون عنصره؛ وترويسة القسم هي "header"، والفاصل مخفي.
  • حقل البحث حقل نصي تسميه searchAccessibilityLabel (وإلا accessibilityLabel)؛ ونصه البديل تلميح يظهر ما دام فارغًا.
  • يحتفظ حقل البحث بتركيز لوحة المفاتيح بينما تحرّك مفاتيح الأسهم التمييز، وتسمع قارئات الشاشة تسمية الصف المميَّز. وبدون حقل بحث، يأخذ الصف المميَّز تركيز قارئ الشاشة.
  • أثناء loading، تكون القائمة مشغولة.

انظر إمكانية الوصول للتسميات والأدوار والإعلانات عمومًا.

#لوحة المفاتيح

المفتاحما يفعله
الكتابةتصفّي العناصر؛ ويُميَّز أول عنصر مطابق ممكَّن.
Down، Upتميّز العنصر التالي أو السابق، متخطية المعطّلة، من الأخير إلى الأول والعكس.
Page Down، Page Upتنقل التمييز بمقدار الصفوف الظاهرة.
Returnيختار العنصر المميَّز (onSelect).
Escapeيمسح الاستعلام؛ ومع استعلام فارغ، يبلّغ onCancel.
Home، Endبدون حقل بحث: العنصر الأول أو الأخير (وفي الحقل تنقلان مؤشر الإدخال).
Space، الأحرف، الأرقامبدون حقل بحث: يختار Space، وتميّز الأحرف والأرقام بالكتابة التنبؤية (typeahead).

#ملاحظات المنصات

  • أنظمة سطح المكتب (macOS وWindows وLinux وGPUI) والويب: يميّز المؤشر الصف الذي يمر فوقه، وينتقي النقر العنصر لحظة الضغط، فيحتفظ حقل البحث بتركيز لوحة المفاتيح.
  • iOS وAndroid وMac Catalyst: تختار النقرة صفًا، ويختار مفتاح الإدخال في لوحة المفاتيح العنصر المميَّز ويُبقي الحقل للاستعلام التالي.
  • تعرض كل منصة ما ينسّقه التطبيق: الأمر وشريط بحثه وصفوفه ومحتواها.