元素

Command

命令面板是一个搜索框加一列命令:用户输入几个字母,列表缩小到匹配的命令,方向键移动高亮,回车执行高亮的命令。Command 负责它的行为:过滤、分区、高亮、键盘,以及屏幕阅读器听到的内容。过滤在原生端进行,行是虚拟化的:只创建可见的行,所以成千上万条命令、文件或 emoji 的面板在每次按键时都能即时过滤。外观由应用决定:命令面板本身什么也不画,应用为每个部件设置样式(itemClass、groupClass、searchClass 等),并绘制行的内容(renderItem:图标、标签、快捷键)。

el-command.tsx
el-command.tsx运行中
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"
            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,在 Rust 中是 nsh-base crate。应用设置 query 时,查询词由应用持有,应用在 onQueryChange 里更新它;不设置时由搜索框自己保存,从 defaultQuery 开始。项目用应用自己的 id 标识,而不是标签:onSelect({ id }) 告诉应用用户选了哪一个。在 Rust 中由 command() 创建,每个 prop 有对应的 setter(.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(Apple 平台上是 ⇧⌘P,其他平台是 Ctrl+Shift+P)。它描述的是项目本身,高亮是行的 focus::每一项只绘制一次,高亮移动时视图保持不变。

#Props

PropRust类型默认值说明
items.items(..)CommandItem[][]按顺序排列的命令。
query.query(..)string无查询词(受控),由应用在 onQueryChange 里设置。
defaultQuery.default_query(..)string无没有设置 query 时的初始查询词。
searchable.searchable(..)booleantrue行上方的搜索框。没有它时显示全部项目,命令面板在键盘焦点里只占一站,支持按首字母跳转。
filterable.filterable(..)booleantrue用查询词过滤项目。关闭后显示全部项目:用于由应用自己给出结果的搜索(服务器返回的结果设为 items)。新的查询词仍会高亮第一项。
loading.loading(..)booleanfalse正在搜索:empty 暂不显示,列表对屏幕阅读器处于忙碌状态。在 searchSuffix 里显示你的加载指示。
placeholder.placeholder(..)string无搜索框的占位文字。
autoFocus.auto_focus(..)booleanfalse搜索框显示后获得键盘焦点:打开即可输入的面板。
separator.separator(..)booleantrue在两个显示出来的分区之间放一个分隔行,样式由 separatorClass 设置;不会出现在开头、结尾,也不会连续出现。
accessibilityLabel.accessibility_label(..)string无屏幕阅读器对搜索框和列表的称呼(“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 }用户选中了一项:回车,或按压它的行。
onHighlightChange.on_highlight_change(..){ highlighted: string | null }高亮移动了(按键、指针、新的查询词或新的项目、highlight):用来预览回车会执行什么。
onQueryChange.on_query_change(..){ query: string }用户在搜索框里输入,或 Escape 清空了它。
onCancel.on_cancel(..)无查询词为空时按 Escape:关闭包住命令面板的元素(对话框的 open)。

#部件

部件所在位置样式状态
命令面板class、style
header命令面板应用的元素
搜索栏命令面板searchBarClass、searchBarStyle
searchPrefix、搜索框、searchSuffix搜索栏应用的元素;searchClass、searchStyle搜索框的 focus:
列表命令面板listClass、listStyle
分区标题行列表groupClass、groupStyle、renderGroup
分隔行列表separatorClass、separatorStyle
行列表itemClass、itemStylefocus:(高亮)、checked:、disabled:、hover:、active:
行的内容行renderItem在 itemClass 里加 group 后可用 group-focus:、group-checked:、group-disabled:
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",分隔行对屏幕阅读器隐藏。
  • 方向键移动高亮时键盘焦点留在搜索框里,屏幕阅读器读出高亮项的标签。没有搜索框时,高亮的行获得屏幕阅读器的焦点。
  • loading 期间列表处于忙碌状态。

标签、角色和播报的通用说明见无障碍。

#键盘

按键作用
输入过滤项目;高亮第一个可用的匹配项。
下、上高亮下一项或上一项,跳过被禁用的项目,从最后一项回到第一项,反之亦然。
Page Down、Page Up按可见的行数移动高亮。
回车选中高亮的项目(onSelect)。
Escape清空查询词;查询词为空时触发 onCancel。
Home、End没有搜索框时:第一项或最后一项(在搜索框里它们移动光标)。
空格、字母、数字没有搜索框时:空格选中,字母和数字按首字母跳转高亮。

#平台说明

  • 桌面(macOS、Windows、Linux、GPUI)和 Web:指针移到哪一行就高亮哪一行,点击在按下时就选中,所以搜索框保持键盘焦点。
  • iOS、Android 和 Mac Catalyst:轻点选中一行,键盘上的回车键选中高亮的项目,并保留搜索框以便输入下一个查询词。
  • 每个平台都绘制应用设置的样式:命令面板、搜索栏、行及其内容。