元素
Command
命令面板是一个搜索框加一列命令:用户输入几个字母,列表缩小到匹配的命令,方向键移动高亮,回车执行高亮的命令。Command 负责它的行为:过滤、分区、高亮、键盘,以及屏幕阅读器听到的内容。过滤在原生端进行,行是虚拟化的:只创建可见的行,所以成千上万条命令、文件或 emoji 的面板在每次按键时都能即时过滤。外观由应用决定:命令面板本身什么也不画,应用为每个部件设置样式(itemClass、groupClass、searchClass 等),并绘制行的内容(renderItem:图标、标签、快捷键)。
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),
)
})
}import { mount, signal, Text, View } from "nsh";
import { Command, type CommandItem } from "nsh/base";
const commands: CommandItem[] = [
{ id: "new", label: "New File", group: "File", shortcut: "mod+n" },
{ id: "open", label: "Open…", group: "File", shortcut: "mod+o" },
{ id: "theme", label: "Toggle Theme", group: "View", keywords: ["dark", "light"] },
{ id: "settings", label: "Settings", group: "View", shortcut: "mod+," },
];
function App() {
const chosen = signal("none");
return (
<Command
items={commands}
placeholder="Type a command or search…"
accessibilityLabel="Commands"
autoFocus
onSelect={({ id }) => chosen.set(id)}
class="w-96 overflow-hidden rounded-lg border border-border bg-popover"
searchBarClass="flex-row items-center gap-2 border-b border-border px-3"
searchClass="h-11 flex-1 text-sm text-foreground placeholder:text-muted-foreground"
listClass="max-h-80 p-1"
groupClass="px-2 py-1.5 text-xs font-medium text-muted-foreground"
separatorClass="-mx-1 my-1 h-px bg-border"
itemClass="group h-8 flex-row items-center rounded-md px-2 focus:bg-accent disabled:opacity-50"
empty={<Text class="py-6 text-center text-sm text-muted-foreground">No results.</Text>}
renderItem={(entry) => (
<View class="flex-1 flex-row items-center gap-2">
<Text class="flex-1 text-sm text-foreground group-focus:text-accent-foreground">{entry.label}</Text>
<Text class="text-xs text-muted-foreground">{entry.shortcutLabel ?? ""}</Text>
</View>
)}
/>
);
}
mount(App);Command 来自 nsh/base,在 Rust 中是 nsh-base crate。应用设置 query 时,查询词由应用持有,应用在 onQueryChange 里更新它;不设置时由搜索框自己保存,从 defaultQuery 开始。项目用应用自己的 id 标识,而不是标签:onSelect({ id }) 告诉应用用户选了哪一个。在 Rust 中由 command() 创建,每个 prop 有对应的 setter(.items(..)、.query(..)、.render_item(..)),每个事件有对应的监听方法,以及类型化的样式方法。
#项目
items 是按顺序排列的命令,每一项是一个 CommandItem:
| 字段 | 类型 | 说明 |
|---|---|---|
id | string | 在 onSelect 和 onHighlightChange 中标识它:在各项中唯一。 |
label | string | 不用 renderItem 时它的行显示的内容,也是屏幕阅读器读出的内容和查询词匹配的内容。 |
keywords | string[] | 查询词还会匹配的词:同义词、翻译后标签旁的英文名。 |
group | string | 它所在的分区:相邻且同组的项目显示在以此为标题的标题行下。 |
shortcut | string | 执行同一条命令的按键,写法与 keymap 相同("mod+shift+p"),供它的行显示。由应用用 keymap.bind 绑定。 |
disabled | boolean | 不能被选中:按键跳过它,按压无效,应用 disabled: 样式。 |
checked | boolean | 它是当前生效的一项(主题、语言):应用 checked: 样式,屏幕阅读器读作已勾选。 |
当项目的标签或某个关键词包含查询词时(不区分大小写,忽略前后空格),查询词就匹配这一项。各项保持原有顺序,没有匹配项的分区连同标题一起隐去。renderItem 会为每个可见的行拿到一个 CommandEntry:项目的各个字段,以及 shortcutLabel,即按当前平台提示方式显示的 shortcut(Apple 平台上是 ⇧⌘P,其他平台是 Ctrl+Shift+P)。它描述的是项目本身,高亮是行的 focus::每一项只绘制一次,高亮移动时视图保持不变。
#Props
| Prop | Rust | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
items | .items(..) | CommandItem[] | [] | 按顺序排列的命令。 |
query | .query(..) | string | 无 | 查询词(受控),由应用在 onQueryChange 里设置。 |
defaultQuery | .default_query(..) | string | 无 | 没有设置 query 时的初始查询词。 |
searchable | .searchable(..) | boolean | true | 行上方的搜索框。没有它时显示全部项目,命令面板在键盘焦点里只占一站,支持按首字母跳转。 |
filterable | .filterable(..) | boolean | true | 用查询词过滤项目。关闭后显示全部项目:用于由应用自己给出结果的搜索(服务器返回的结果设为 items)。新的查询词仍会高亮第一项。 |
loading | .loading(..) | boolean | false | 正在搜索:empty 暂不显示,列表对屏幕阅读器处于忙碌状态。在 searchSuffix 里显示你的加载指示。 |
placeholder | .placeholder(..) | string | 无 | 搜索框的占位文字。 |
autoFocus | .auto_focus(..) | boolean | false | 搜索框显示后获得键盘焦点:打开即可输入的面板。 |
separator | .separator(..) | boolean | true | 在两个显示出来的分区之间放一个分隔行,样式由 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、itemStyle | focus:(高亮)、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:轻点选中一行,键盘上的回车键选中高亮的项目,并保留搜索框以便输入下一个查询词。
- 每个平台都绘制应用设置的样式:命令面板、搜索栏、行及其内容。