API

nsh/base

nsh/base 是应用界面的基础组件层:不带样式、负责行为、状态、焦点和无障碍的组件,各平台通用。它位于基础元素(nsh 和各个 nsh/<module> 模块)与应用自己的组件库之间,就像 GPUI Kit 中 gpui-base 位于 gpui 与带样式的组件库之间(见模块)。它提供控件(Button、Checkbox、放在 RadioGroup 里的 Radio、Switch、放在 Tabs 里的 Tab)、可展开和收起的区域(Collapsible)、进度条(Progress)、由用户自行安排的工作区(放在 ResizablePanelGroup 里的 ResizablePanel,以及 DockArea 的分栏、标签组和停靠区)、Markdown 或 HTML 文档(TextView)、代码编辑器(Editor)、文本之间的改动(DiffView)、跨文字的选区(SelectionArea)、显示在一切之上的弹出层(Popover,Menu 和 ContextMenu 及其 MenuItem、MenuSeparator 和 Submenu,Tooltip,Sheet)、toast(放在 ToastStack 中的 Toast,生命周期由 ToastManager 管理)、导航(由 createNavStack 创建的状态驱动、由多个 NavPage 组成的 NavStack,以及在所有内容之上展示内容的 Dialog)、安全区(SafeAreaView、safeArea() 和 safeAreaInsets())、系统警告对话框(AlertDialog)、软键盘(Keyboard、keyboardFrame() 和 KeyboardAvoidingView)。页面和对话框还决定状态栏(statusBar)。

ui-navigation.tsx
ui-navigation.tsx运行中
// Navigation: a stack of pages with header options, pushed and popped; in a
// wide window the inbox and the open message side by side (master–detail).
import { type JSX, mount, signal, Text, View } from "nsh";
import { createNavStack, NavPage, NavStack } from "nsh/base";
import { desktop, size, tint, ui, wide } from "./palette";

function Button(props: { title: string; variant?: "primary" | "secondary"; onPress: () => void }) {
  const primary = () => props.variant !== "secondary";
  return (
    <View
      role="button"
      onPress={props.onPress}
      style={desktop ? { ...(primary() ? ui().primary : ui().raised), height: size.control, alignSelf: "flex-start" } : primary() ? ui().primary : ui().raised}
      class={desktop ? "justify-center rounded-lg px-3 active:opacity-75" : "items-center rounded-xl py-3 active:opacity-75"}
    >
      <Text style={{ fontWeight: "600", fontSize: desktop ? 13 : undefined, color: primary() ? ui().onPrimary : ui().text }}>{props.title}</Text>
    </View>
  );
}

type Route = { name: "home" } | { name: "detail"; id: number };

const nav = createNavStack<Route>([{ name: "home" }]);

// The page's own header where NSH draws none (the web, the desktops); iOS and
// Android show the system's, from `title` and `headerRight`.
function Header(props: { title: string; right?: JSX.Element }) {
  return (
    <View style={{ backgroundColor: ui().page, borderBottomWidth: 1, borderBottomColor: ui().line }} class="h-12 flex-row items-center px-2">
      <View class="w-20 flex-row">
        <If when={nav.routes().length > 1}>
          <View role="button" accessibilityLabel="Back" onPress={() => nav.pop()} class="px-2 py-1 active:opacity-60">
            <Text style={{ fontSize: 17, color: ui().accent }}>‹ Back</Text>
          </View>
        </If>
      </View>
      <Text numberOfLines={1} style={{ fontSize: 17, fontWeight: "600", color: ui().text }} class="flex-1 text-center">
        {props.title}
      </Text>
      <View class="w-20 flex-row justify-end px-2">{props.right}</View>
    </View>
  );
}

// A page's content: a column as wide as a phone, centered in a wider window.
function Body(props: { children: JSX.Element }) {
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 16, paddingTop: 24 }}>
      <View style={{ width: "100%", maxWidth: 480, alignSelf: "center", gap: 12 }}>{props.children}</View>
    </View>
  );
}

function Home() {
  const opened = signal(0);
  return (
    <NavPage title="Inbox" header={<Header title="Inbox" />}>
      <Body>
        <Text style={{ color: ui().muted }}>{`Opened ${opened()} messages`}</Text>
        <Button
          title="Open message 1"
          onPress={() => {
            opened.set(opened() + 1);
            nav.push({ name: "detail", id: 1 });
          }}
        />
        <Button title="Open message 2" variant="secondary" onPress={() => nav.push({ name: "detail", id: 2 })} />
      </Body>
    </NavPage>
  );
}

function Detail(props: { id: number }) {
  const starred = signal(false);
  const star = () => (
    <Text onPress={() => starred.set(!starred())} style={{ fontSize: 17, color: ui().accent }}>
      {starred() ? "★" : "☆"}
    </Text>
  );
  return (
    <NavPage title={`Message ${props.id}`} headerRight={star()} header={<Header title={`Message ${props.id}`} right={star()} />}>
      <Body>
        <Text style={{ color: ui().text }}>{`The body of message ${props.id}.`}</Text>
        <Button title="Next message" onPress={() => nav.push({ name: "detail", id: props.id + 1 })} />
        <Button title="Back to the inbox" variant="secondary" onPress={() => nav.popToRoot()} />
      </Body>
    </NavPage>
  );
}

const messages = [1, 2, 3, 4, 5, 6];

/** A wide window: the inbox on the start side, the selected message beside it. */
function Inbox() {
  const selected = signal(1);
  const starred = signal<number[]>([]);
  const star = (id: number) => (
    <Text
      onPress={() => starred.set(starred().includes(id) ? starred().filter((s) => s !== id) : [...starred(), id])}
      style={{ fontSize: 17, color: ui().accent }}
    >
      {starred().includes(id) ? "★" : "☆"}
    </Text>
  );
  return (
    <View style={{ flex: 1, flexDirection: "row", backgroundColor: ui().page }}>
      <View style={{ width: 320, borderRightWidth: 1, borderRightColor: ui().line }}>
        <Header title="Inbox" />
        <View style={{ padding: 8, gap: 2 }}>
          <Each of={messages}>
            {(id) => (
              <View
                role="button"
                onPress={() => selected.set(id())}
                hoverStyle={selected() === id() ? {} : { backgroundColor: tint(ui().text, 0.04) }}
                style={{
                  height: size.row + 12,
                  paddingHorizontal: 12,
                  borderRadius: 8,
                  justifyContent: "center",
                  gap: 2,
                  backgroundColor: selected() === id() ? tint(ui().accent, 0.14) : "transparent",
                }}
              >
                <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
                  <Text style={{ flex: 1, fontSize: size.label, fontWeight: "600", color: ui().text }}>{`Message ${id()}`}</Text>
                  <Text style={{ fontSize: 12, color: ui().accent }}>{starred().includes(id()) ? "★" : ""}</Text>
                </View>
                <Text numberOfLines={1} style={{ fontSize: 12, color: ui().muted }}>{`The body of message ${id()}.`}</Text>
              </View>
            )}
          </Each>
        </View>
      </View>
      <View style={{ flex: 1, minWidth: 0 }}>
        <Header title={`Message ${selected()}`} right={star(selected())} />
        <View style={{ padding: 24, gap: 12 }}>
          <Text style={{ color: ui().text }}>{`The body of message ${selected()}.`}</Text>
          <Text style={{ fontSize: 13, color: ui().muted }}>Click a message on the left; on a phone it opens as a page of a NavStack.</Text>
          <Button title="Next message" onPress={() => selected.set(selected() === messages.length ? 1 : selected() + 1)} />
        </View>
      </View>
    </View>
  );
}

function App() {
  return (
    <>
      {() =>
        wide() ? (
          <Inbox />
        ) : (
          <NavStack state={nav} style={{ flex: 1 }}>
            {(route) => (route.name === "home" ? <Home /> : <Detail id={route.id} />)}
          </NavStack>
        )
      }
    </>
  );
}

mount(App);

外观由应用决定:页面、对话框或标题栏里的内容都是你自己的 JSX,用基础元素配合样式、Tailwind 类和主题绘制;或者是你自己的 crate 中的 Rust 组件;也可以是社区基于 nsh/base 构建的组件库。每个宿主都提供 nsh/base;Rust 应用通过 nsh-base crate 使用同一套 API(nsh_base::{button, safe_area_view, …})。

use nsh_base::{
    AvoidBehavior, Edge, MarkdownExtension, MarkdownOptions, alert_dialog, button, checkbox, dialog, diff_view, dock_area, editor,
    h_resizable, keyboard_avoiding_view, nav_page, nav_stack, radio, radio_group, resizable_panel, safe_area_view,
    selection_area, switch, tab, tabs, text_view, theme, toast, toast_stack, v_resizable,
};

本页是模块的索引:每个组件都有自己的元素页面,包含示例、属性和平台说明。完整的上手讲解见导航指南和表单与键盘。

TSXRust是什么
Buttonbutton()带禁用和选中状态、面向屏幕阅读器有角色的按压目标。
Checkboxcheckbox()勾选 / 未勾选 / 半选控件,指示器及其中的标记由应用设置。
Radio、RadioGroupradio()、radio_group()一组只能选一个的选项中的一项,以及这一组;勾选哪一个由应用持有。
Switchswitch()开 / 关控件,轨道和滑块由应用设置样式。
Tab、Tabstab()、tabs()标签列表中的一个标签页,以及这个列表;选中哪一个由应用持有,内容也由应用显示。
Collapsiblecollapsible()内容可展开和收起的区域;open 和触发它的控件由应用持有。
Progressprogress()进度条;value(0 到 100)和 indeterminate 由应用持有。
ResizablePanelGroup、ResizablePanelh_resizable()、v_resizable()、resizable_panel()并排或上下排列的面板,用户拖动它们之间的手柄调整大小。
DockAreadock_area()由分栏、标签组和边缘停靠区组成、用户自行安排的工作区;它的布局是应用的数据。
NavStack、NavPage、createNavStacknav_stack()、nav_page()原生导航栈及其页面。
Dialogdialog()在所有内容之上呈现的内容:sheet、全屏流程、对话框。
SafeAreaViewsafe_area_view()避开系统界面和标题栏的视图。
safeArea()、safeAreaInsets()safe_area()、cx.insets()边距本身,响应式。
KeyboardAvoidingViewkeyboard_avoiding_view()让内容保持在软键盘上方的视图。
Keyboard.dismiss()、keyboardFrame()dismiss_keyboard()、keyboard_height()隐藏键盘;键盘在界面上的高度。
AlertDialogalert_dialog()平台的警告框或操作表。
TextViewtext_view()用文字和视图绘制的 Markdown、HTML 或文档块组成的文档,适用于流式输出的 AI 回复。
Editoreditor()代码编辑器:语法高亮、多光标、折叠、查找与替换、诊断。
DiffViewdiff_view()两段文本之间或一个补丁中的改动,并排或合并显示,带针对每处改动的操作。
SelectionAreaselection_area()让其中的文字共用一个选区。
Popoverpopover()锚定在触发元素上、显示在一切之上的内容。
Menu、ContextMenu、MenuItem、MenuSeparator、Submenumenu()、…菜单:桌面上是带级联子菜单的弹出层,手机上是 sheet。
Tooltiptooltip()显示在触发元素旁的提示。
Sheetsheet()位于屏幕一侧边缘、覆盖在背景层之上的面板。
Toast、ToastStack、ToastManagertoast()、toast_stack()、ToastManager无样式的 toast:语义根、层叠的栈、它们的生命周期。
页面和对话框的 statusBar.status_bar(..)页面或对话框显示期间的状态栏。见状态栏。

在 Rust 中,theme() 以设计 token 的形式读取应用的主题(theme().primary、theme().radius),与 TSX 的主题类使用同样的 token。

#控件

#Button

一个只负责行为、没有外观的按压目标:处理按压、禁用和选中状态,并向屏幕阅读器报告它的角色。它的外观是应用的 class 和 style 加上状态变体,内容是它的子元素。见 Button。

#Checkbox

一个只负责行为、没有外观的勾选 / 未勾选 / 半选控件:处理按压、键盘激活、禁用状态,并向屏幕阅读器报告复选框角色和它的值(半选时为部分选中);值由应用持有(checked、indeterminate、onChange)。应用为复选框和它的指示器设置样式,指示器上的 checked:、indeterminate: 和 disabled: 类跟随复选框,应用自己的标记放在指示器里(indicator)。见 Checkbox。

#Radio 与 RadioGroup

一组只能恰好选一个的选项中的一项,以及包住它们的分组。单选框只负责行为、没有外观:处理按压、键盘激活、禁用状态,并向屏幕阅读器报告单选框角色和勾选状态。分组是屏幕阅读器播报的单选框组,本身不持有值:应用告诉每个单选框是否勾选(checked),按下未勾选的单选框时它请求被勾选(onChange)。应用的指示器和标签是单选框的子元素,它们的 group-checked: 类跟随单选框。见 Radio。

#Switch

一个只负责行为、没有外观的开 / 关控件:处理按压、键盘激活、禁用状态,并向屏幕阅读器报告开关角色和它的值;值由应用持有(checked、onChange)。应用为开关、它的轨道和滑块设置样式,轨道和滑块上的 checked: 和 disabled: 类跟随开关。见 Switch。

#Tab 与 Tabs

标签列表中的标签页,以及包住它们的列表。标签页只负责行为、没有外观:处理按压、键盘激活、禁用状态,并向屏幕阅读器报告标签页角色和选中状态。列表是屏幕阅读器播报的标签列表,本身不持有值:应用告诉每个标签页是否选中(selected),接收按压(onPress),并自己显示选中部分的内容。应用的标签是标签页的子元素,它们的 group-selected: 类跟随标签页。见 Tabs。

#Collapsible

内容可展开和收起的区域,没有外观:子元素始终显示(摘要、触发控件),content 在 open 时显示在它们之后。open 和切换它的触发控件由应用持有,应用也在触发控件上向屏幕阅读器报告展开状态。内容只创建一次,收起时保留,所以里面的状态不会丢。见 Collapsible。

#Progress

线性进度条,没有外观:轨道里有一个指示条,指示条的宽度是轨道宽度的 value 百分比。value(0 到 100)和 indeterminate 由应用持有;屏幕阅读器得到进度条角色、范围和数值,不确定时没有数值,此时指示条的宽度和动效由应用决定。见 Progress。

#工作区

#ResizablePanelGroup 与 ResizablePanel

并排或上下排列的面板,每两个面板之间有一个手柄,用户拖动它来分配空间。分组负责拖动(以原生方式运行)、每个面板的限制(minSize、maxSize)和调整大小的光标;尺寸由应用持有(size,通过 onResize 报告),手柄的外观和面板的内容也属于应用。见 Resizable。

#DockArea

可停靠的工作区:面板放在标签组里,标签组并排或上下分栏,左、右、下三条边上各有一个停靠区。布局是应用持有的数据(layout,通过 onLayoutChange 报告);区域负责标签页的拖放、调整大小、停靠区、放大和锁定,面板移动时保留它的内容,在区域较窄时用抽屉和底部面板呈现同一份布局。标签页、手柄、放置指示和面板内容都属于应用。见 Dock。

#文档

#TextView

TextView 用 Text 元素和视图显示 Markdown、HTML 或应用自己构建的文档块:标题、列表、代码、表格、图片、公式、脚注以及应用自己的语法。它为流式输出的 AI 聊天回复而设计,自带一套采用主题颜色的阅读排版,应用可按部件重新设定样式,渲染函数则把应用自己的内容放进文档。见 TextView。

#代码

#Editor

Editor 是支持五十多种语言的代码编辑器:语法高亮、行号、多光标、折叠、软换行、查找与替换、诊断和装饰,以及代码编辑器的按键。文本属于应用(value 和 onChange),查找栏也属于应用,由它驱动编辑器的 search。它带有一套由主题令牌构成的外观,应用可按部件重新设定样式。见 Editor。

#DiffView

DiffView 像代码评审工具那样显示两段文本之间或补丁中各个文件的改动:并排或合并显示,高亮行内改动的单词,未改动的行折叠成可以展开的分隔行,使用 Editor 的语法颜色。应用的操作可以保留或撤销每处改动。见 DiffView。

#选择

#SelectionArea

SelectionArea 让其中的文字一起选择,就像网页上的文字一样:一个选区按阅读顺序跨越视图、Text 元素和 TextView。见 SelectionArea。

#导航

由 NavPage 组成的 NavStack 在 iOS 上是 UINavigationController,在 Android 上是带工具栏和预测性返回的页面栈;在桌面平台和 Web 上,每个页面显示应用自己的 header。见 NavStack,其中也介绍了 createNavStack 和离开页面。

#Dialog

Dialog 在所有内容之上(包括栈的标题栏)、在它自己的原生窗口层中呈现内容。它的弹出层和背景层由应用设定样式。见 Dialog。

#安全区

#SafeAreaView

SafeAreaView 按自己渲染位置的边距为内容添加内边距:最近的页面的(标题栏下方)、最近的对话框的,否则为界面的安全区。见 SafeAreaView。

#safeArea 与 safeAreaInsets

safeArea() 是界面的安全区;safeAreaInsets() 是组件渲染位置的边距。两者都是响应式的 Edges({ top, right, bottom, left },单位为点)。见 safeArea 与 safeAreaInsets。

#警告对话框

#AlertDialog

AlertDialog.show(options) 显示平台自己的警告框或操作表,并以用户选择的操作作为结果。见 AlertDialog。

#键盘

Keyboard.dismiss() 隐藏软键盘,keyboardFrame() 告诉你它覆盖了屏幕的多少。见隐藏键盘和键盘高度。

#KeyboardAvoidingView

KeyboardAvoidingView 让它的内容保持在软键盘上方,并随键盘移动。见 KeyboardAvoidingView。

#状态栏

状态栏属于屏幕上正在显示的内容,和 UIKit 一样:在 UIKit 中,每个视图控制器声明自己的 preferredStatusBarStyle,由最上层的那个决定。在 NSH 中,页面和对话框就是这些视图控制器:NavPage 或 Dialog 用 statusBar 声明它需要的状态栏,应用则在 nsh.json 中声明默认值。

use nsh_base::nav_page;
use nsh_core::ui::prelude::*;

fn photo_viewer() -> Screen {
    nav_page().header_visible(false).status_bar(
        StatusBarSetting::default()
            .foreground(StatusBarStyle::Light)
            .hidden(true)
            .animated(true),
    )
}

statusBar 接受以下字段,每个都可省略(在 Rust 中可以只传一个 StatusBarStyle,或用 StatusBarSetting 的同名构建方法):

字段类型说明
foreground"auto" | "light" | "dark"(Rust:StatusBarStyle::Default、Light、Dark)内容(时间、电量、信号)的颜色:"light" 用于深色背景,"dark" 用于浅色背景,"auto" 为平台针对当前外观的颜色。
hiddenboolean隐藏状态栏。
animatedboolean页面或对话框出现和离开时,以动画呈现变化。

这个值是响应式的:statusBar={{ foreground: dark() ? "light" : "dark" }} 会跟随信号变化,读取信号的 Rust 闭包也一样。

#哪个生效

最近显示的页面或对话框决定状态栏,并且逐个字段生效:推入栈的页面位于它下面的页面之上,打开的对话框位于页面之上。它省略的字段取自下面那个,最终取自应用的默认值。页面被弹出或对话框关闭时,下面那个重新生效。

nav_page()
    .title("Inbox")
    .status_bar(StatusBarStyle::Dark);

// Pushed over it: hidden, with the inbox's dark content when it shows again.
nav_page()
    .title("Photo")
    .status_bar(StatusBarSetting::default().hidden(true));

#应用的默认值

nsh.json 的 "statusBar" 是所有页面和对话框之下的设置,就像 UIKit 应用中 Info.plist 的 UIStatusBarStyle 和 UIStatusBarHidden:

{
  "statusBar": { "foreground": "light", "hidden": false }
}

两个字段都可省略。不设置时,内容为 "auto",状态栏显示。打包的 iOS 应用在启动屏上也会使用它。

#各平台上的状态栏

  • iOS:隐藏和显示以淡入淡出呈现。在嵌入原生应用的场景下,状态栏的外观由宿主应用的视图控制器决定。宿主可以在其视图控制器中返回 NshAppView.preferredStatusBarStyle、NshAppView.prefersStatusBarHidden 和 NshAppView.preferredStatusBarUpdateAnimation,以应用 NSH 的设置。
  • Android:应用以 edge-to-edge 方式绘制到状态栏之下(Android 15,即 API 35):应用自己的背景就是状态栏的背景,foreground 让状态栏的内容在其上保持清晰。
  • 桌面平台与 Web:窗口标题栏和浏览器的栏属于系统和浏览器;statusBar 和 nsh.json 的 "statusBar" 用于手机。
{
  "capabilities": {
    "statusBar": true
  }
}

#相关内容