---
url: https://nsh.dev/zh-HK/modules/nsh
description: "核心模組:涵蓋 signals、effects、context、mount、design tokens、動畫、環境、手勢、剪貼簿、儲存、host channel、鍵盤快速鍵、無障礙功能,以及 element 和 style 型別。"
---

# nsh

`nsh` 是核心模組,包含:響應式 API(signal、computed、effect、context)、`mount`、`token`、宿主元素 `View`、`Text`、`Scroll` 和 `Overlay`、[可選取的視圖](#selectable-views)、[安全區域與鍵盤](#safe-area-and-keyboard)、[動畫](#animation)、[裝置及其環境](#device)、[`PanResponder`](#panresponder)、[剪貼簿](#clipboard)、[相片選取器](#picker)、[語音辨識](#speech)、[儲存](#storage)、[宿主通道](#host)、[鍵盤快速鍵](#keymap)、[原生元件與模組](#nativecomponent-and-nativemodule)、[無障礙功能](#accessibility),以及元素、樣式和事件的 TypeScript 型別。響應式圖本身在 Rust 中原生運行;讀取 signal 是對執行環境發出的一次呼叫,只需數奈秒,只有你自己的閉包才會在 JavaScript 直譯器中運行。

```ts
import { batch, cleanup, computed, context, effect, ErrorBoundary, mount, Overlay, Scroll, signal, split, Text, token, untracked, View } from "nsh";
import { Input } from "nsh/base";
```

NSH 的響應式機制與 SolidJS 採用相同的模型:元件函式只會執行**一次**,UI 中讀取了 signal 的部分會在這些 signal 改變時就地更新。元件不會重新渲染,也沒有虛擬 DOM。指南請參閱[響應式](https://nsh.dev/translations/zh-HK/docs/reactivity.md)。

## signal

```ts
function signal<T>(value: T, options?: SignalOptions<T>): Signal<T>;
function signal<T = undefined>(): Signal<T | undefined>;

interface Signal<T> {
  (): T;                              // read (tracked)
  set(value: T): void;                // write
  update(fn: (value: T) => T): void;  // write from the current value
}

interface SignalOptions<T> {
  /** When a new value counts as a change (default `===`); `false`: always. */
  equals?: false | ((previous: T, next: T) => boolean);
}
```

signal 保存一個值。呼叫它即可讀取該值,而在追蹤範圍內(JSX 表達式、`computed`、`effect`)讀取時,會讓該範圍訂閱它。`set` 和 `update` 會寫入新值,所有讀取過它的地方都會隨之更新。

```tsx
import { mount, signal, Text, View } from "nsh";

function Counter() {
  const count = signal(0);
  return (
    <View style={{ padding: 24, gap: 12 }}>
      <Text>Count: {count()}</Text>
      <Text onPress={() => count.update((n) => n + 1)}>Add one</Text>
    </View>
  );
}

mount(Counter);
```

預設情況下,寫入與目前值 `===` 的值時不會有任何作用。傳入 `equals: false` 可使每次寫入都發出通知(適用於就地修改物件後再次設定同一個物件的情況),也可以傳入你自己的比較函式:

```ts
const items = signal<string[]>([], { equals: false });
const point = signal({ x: 0, y: 0 }, { equals: (a, b) => a.x === b.x && a.y === b.y });
```

## computed

```ts
function computed<T>(fn: (previous: T | undefined) => T, options?: SignalOptions<T>): Accessor<T>;

interface Accessor<T> {
  (): T;
}
```

由 signal 衍生出來的值。當 `fn` 讀取過的 signal 改變時,它會再次執行;computed 值的讀取者只有在其結果改變時才會更新(以 `equals` 比較,預設為 `===`)。`fn` 會接收上一次的結果。

```ts
const first = signal("Ada");
const last = signal("Lovelace");
const full = computed(() => `${first()} ${last()}`);
full(); // "Ada Lovelace"
```

當衍生計算開銷較大,或需要在多處讀取時,請使用 `computed`。對於簡單的表達式,使用普通函式(`const full = () => first() + " " + last()`)同樣具有響應性。

## effect

```ts
function effect(fn: () => void): void;
```

在渲染後執行 `fn`,之後每當它讀取過的 signal 改變時再次執行。effect 用於處理副作用:記錄日誌、計時器、與原生模組通訊。effect 屬於建立它的元件(或 effect),並會隨之一併被銷毀。

```ts
const query = signal("");
effect(() => {
  console.log("query is now", query());
});
```

若要在 effect 再次執行之前(或其元件移除時)還原某些操作,請在 effect 內呼叫 [`cleanup`](#cleanup)。

## batch

```ts
function batch<T>(fn: () => T): T;
```

執行 `fn`,並在結束時一次過套用其中所有寫入,讓讀取者只更新一次,而不是每次寫入都更新。回傳 `fn` 的回傳值。

```ts
batch(() => {
  first.set("Grace");
  last.set("Hopper");
}); // `full` recomputes once
```

## untracked

```ts
function untracked<T>(fn: () => T): T;
```

執行 `fn`,但不會讓目前範圍訂閱其中讀取的內容。當你想在 effect 或 JSX 表達式內讀取某個 signal 而不依賴它時,請使用此函式。

```ts
effect(() => {
  // Runs again when `query` changes, not when `page` changes.
  search(query(), untracked(() => page()));
});
```

## cleanup

```ts
function cleanup(fn: () => void): void;
```

註冊 `fn`,使其在目前的 effect 再次執行之前,或目前的元件或 effect 移除時執行。

```tsx
function Clock() {
  const now = signal(new Date());
  const timer = setInterval(() => now.set(new Date()), 1000);
  cleanup(() => clearInterval(timer));
  return <Text>{now().toLocaleTimeString()}</Text>;
}
```

## context

```ts
function context<T>(defaultValue: T): Context<T>;

interface Context<T> extends Accessor<T> {
  Provider: (props: { value: T; children?: JSX.Element }) => JSX.Element;
}
```

元件向其渲染的所有內容提供的值,無論層級多深。呼叫 context(`Theme()`)會讀取最近的提供者所提供的值;若沒有提供者,則得到 `defaultValue`。

```tsx
import { context, mount, signal, Text, View } from "nsh";

const Theme = context<"light" | "dark">("light");

function Label(props: { text: string }) {
  return <Text style={{ color: Theme() === "dark" ? "#fff" : "#000" }}>{props.text}</Text>;
}

function App() {
  const dark = signal(false);
  return (
    <Theme.Provider value={dark() ? "dark" : "light"}>
      <View style={{ flex: 1, padding: 24, backgroundColor: dark() ? "#000" : "#fff" }}>
        <Label text="Hello" />
        <Text onPress={() => dark.update((d) => !d)}>Toggle</Text>
      </View>
    </Theme.Provider>
  );
}

mount(App);
```

## mount

```ts
function mount<P extends object>(app: Component<P>, props?: P): () => void;
```

將 `app` 渲染到畫面(視窗,或應用程式所嵌入的 `NshAppView` / `NshView`),並回傳一個用來移除它的函式。每個應用程式的入口檔案都以 `mount(App)` 結尾。

```tsx
mount(App);
```

## split

```ts
function split<T extends object, K extends readonly (keyof T)[]>(
  props: T,
  keys: K,
): [Pick<T, K[number]>, Omit<T, K[number]>];
```

將 props 物件拆分為 `keys` 中列出的 props 和其餘 props。兩部分都保持響應性:值會改變的 props 是 getter,而 `split` 會將它們保留為 getter。當元件自行處理部分 props 並把其餘的傳遞下去時,請使用它。若解構 props(`const { label } = props`),只會讀取一次,並會失去後續的更新。

```tsx
import { split, Text, View } from "nsh";
import { Input, type InputProps } from "nsh/base";

function LabeledInput(props: InputProps & { label: string }) {
  const [own, rest] = split(props, ["label"]);
  return (
    <View style={{ gap: 4 }}>
      <Text>{own.label}</Text>
      <Input {...rest} />
    </View>
  );
}
```

## ErrorBoundary

```ts
function ErrorBoundary(props: {
  fallback: JSX.Element | ((error: unknown, reset: () => void) => JSX.Element);
  children?: JSX.Element;
}): JSX.Element;
```

渲染其子元素;當其下任何內容拋出錯誤(元件主體、綁定、effect 或事件處理函式)時,則渲染 `fallback`。失敗的部分會被移除,並由 fallback 取代顯示。函式 `fallback` 會以 `fallback(error, reset)` 的形式被呼叫,傳入拋出的值和 `reset`(用於再次渲染子元素),無論它宣告了哪些參數;`fallback` 元素則按原樣顯示。錯誤會交給其上最近的 boundary 處理,而 fallback 中的錯誤則交給再上一層的 boundary。Rust 元件使用 `catch_error`([錯誤邊界](https://nsh.dev/translations/zh-HK/docs/reactivity.md#error-boundaries))。

```tsx
import { ErrorBoundary, Text, View } from "nsh";

function Feed() {
  return (
    <ErrorBoundary
      fallback={(error, reset) => (
        <View>
          <Text>{error instanceof Error ? error.message : String(error)}</Text>
          <Text onPress={reset}>Try again</Text>
        </View>
      )}
    >
      <Posts />
    </ErrorBoundary>
  );
}
```

## token

```ts
function token<K extends keyof Tokens>(name: K): Tokens[K];
```

取得應用程式主題(在 `nsh.json` 中為 `"theme"`,基於 shadcn/ui 的 token)的設計 token `name`,並依目前顯示的外觀返回對應值:
顏色以 `#rrggbbaa` 表示,長度以點數表示,字型則以其字體系列名稱表示。名稱即主題的 CSS 變數,
不含 `--`(`primary`、`muted-foreground`、`radius`、`font-mono`,以及應用程式自訂的,例如 `up`)。讀取 token 具有
響應性:讀取了 token 的元件、memo 或 effect,會在主題或外觀改變時再次執行。
`Tokens` 會列出應用程式的 token 及其型別;NSH 會根據主題將它寫入 `.nsh/types/nsh/tokens.d.ts`。至於
class,請直接寫出 token 名稱(`bg-primary`、`rounded-(--radius)`):[主題 token](https://nsh.dev/translations/zh-HK/docs/tailwind.md#theme-tokens)。

```tsx
import { effect, token } from "nsh";

effect(() => console.log("primary is", token("primary"), "radius", token("radius")));
```

## View, Text, Scroll, Overlay

```ts
function View(props: ViewProps): JSX.Element;
function Text(props: TextProps): JSX.Element;
function Scroll(props: ScrollProps): JSX.Element;
function Overlay(props: OverlayProps): JSX.Element;
```

`nsh` 匯出的宿主元素,以標籤形式書寫:`<View>`、`<Text>`、`<Scroll>`、`<Overlay>`(將其
子元素顯示在視窗最上層,並相對於錨點定位:[Overlay](https://nsh.dev/translations/zh-HK/elements/overlay.md))。對於透過 import 指向其中之一的標籤(別名亦然),編譯器會
就地建立原生視圖,因此匯入它們在執行時不會產生
任何開銷。它們與其他元件一樣都是元件,因此也可以被傳遞(`const Tag = inline ? Text : View`)。
`nsh` 以相同方式匯出其他基本元件:[`List`](https://nsh.dev/translations/zh-HK/modules/nsh-list.md)、[`Image`](https://nsh.dev/translations/zh-HK/modules/nsh-image.md)、
[`Canvas`](https://nsh.dev/translations/zh-HK/modules/nsh-canvas.md)、[`WebView`](https://nsh.dev/translations/zh-HK/modules/nsh-webview.md)和 [`Video`](https://nsh.dev/translations/zh-HK/modules/nsh-video.md),各自有專屬的參考
頁面。它們的 props 記載於[元素](https://nsh.dev/translations/zh-HK/elements.md)之下。文字輸入框 `Input` 來自
[`nsh/base`](https://nsh.dev/translations/zh-HK/modules/nsh-base.md#input)。

<a id="safe-area-and-keyboard"></a>

## 安全區域與鍵盤

CSS 的環境變數可讓內容避開系統 UI,可用於任何視圖的 padding 和 margin
(`padding`、`paddingTop` 等,即相同的 `margin` 鍵;Tailwind 的 `p*` / `m*` 任意值;Rust 中的 `Styled` 方法
`.p_safe_area()`、`.pt_safe_area_plus(..)`、`.pb_keyboard()` 等)。它們的值是 `EnvValue`:
`env(name)`、`env(name, <length>)`、`calc(env(name) + <length>)` 和 `calc(<length> + env(name))`。

| 變數 | 值 |
|---|---|
| `safe-area-inset-top`、`-right`、`-bottom`、`-left` | 節點所渲染的根視圖的內縮距離:最近的頁面(在其標題列之下)、最近的對話框,否則為畫面的安全區域。請參閱[安全區域](https://nsh.dev/translations/zh-HK/elements/safe-area.md)。 |
| `keyboard-inset-height` | 軟鍵盤在畫面上的高度,隱藏時為 0;變化會與鍵盤一同以原生方式播放動畫。請參閱[鍵盤](https://nsh.dev/translations/zh-HK/elements/keyboard.md)。 |

```tsx
<View style={{ flex: 1, paddingTop: "env(safe-area-inset-top)", paddingBottom: "env(keyboard-inset-height)" }}>…</View>
```

<a id="safearea-and-safeareainsets"></a>

### safeArea 與 safeAreaInsets

`safeArea()` 是畫面的安全區域(Rust 中為 `safe_area()`);`safeAreaInsets()` 是元件所在位置的內縮距離
(Rust 中為 `cx.insets()`),即 `safe-area-inset-*` 變數的值。兩者都是具響應性的 `Edges`
(`{ top, right, bottom, left }`,單位為點)。請參閱
[safeArea 與 safeAreaInsets](https://nsh.dev/translations/zh-HK/elements/safe-area.md#safearea-and-safeareainsets)。

<a id="keyboard-and-keyboardframe"></a>

### Keyboard 與 keyboardFrame

`Keyboard.dismiss()` 會隱藏軟鍵盤(Rust 中為 `dismiss_keyboard()`),`keyboardFrame()` 則告訴你鍵盤遮蓋了
多少畫面(Rust 中為 `keyboard_height()` 和 `keyboard_duration()`)。請參閱
[隱藏鍵盤](https://nsh.dev/translations/zh-HK/elements/keyboard.md#hiding-the-keyboard)和
[鍵盤的高度](https://nsh.dev/translations/zh-HK/elements/keyboard.md#the-keyboards-height)。

<a id="selectable-views"></a>

## 可選取的視圖

`<View selectable>`(Rust 中為 `view().selectable(true)`)會讓其內的文字可以一同被選取,就像網頁一樣:
一次選取可橫跨多個視圖、`Text` 元素和 `TextView`,並依閱讀順序排列。請參閱
[文字選取](https://nsh.dev/translations/zh-HK/elements/text-selection.md)。

<a id="animation"></a>

## 動畫

動畫 API 在 UI 執行緒上播放動畫。你建立一個**動畫值**,將它(或由它衍生的值)綁定到樣式屬性,然後啟動動畫;此後每一幀都在原生層計算,
無需執行應用程式的程式碼。即使引擎或 JS 執行緒繁忙,也不會令動畫卡頓,而且動畫不會為應用程式帶來每幀的運算負擔。Rust 程式碼使用 `nsh_core::prelude` 中的 `animated_value()`、`motion()`、`Animation`、`Spring`、`Timing`、`KeyframeTiming`、`MotionPreset` 和 `Easing`,並透過元素的 `.bind_*` 方法綁定值;TSX 則從 `nsh` 匯入它們。

```rust
use nsh_core::{element::LayoutAnimation, prelude::*}; // animated_value, motion, Animation, Spring, Timing, Keyframe, KeyframeTiming, MotionPreset, MotionSpec, Easing, animate_layout
```

```tsx
import { Animated, createAnimatedValue, createMotion, motions, Easing, LayoutAnimation, AnimatedNode, AnimatedValue } from "nsh";
```

指南請參閱[動畫](https://nsh.dev/translations/zh-HK/docs/animation.md)。

<a id="what-an-animated-value-can-drive"></a>

### 動畫值可以驅動什麼

| 位置 | Rust | TSX |
|---|---|---|
| `opacity` | `.bind_opacity(&value)` | `style={{ opacity: value }}` |
| `backgroundColor` | `.bind_background(&value.interpolate_colors(&[0.0, 1.0], &[white(), black()]))` | `style={{ backgroundColor: value.interpolate({ inputRange: [0, 1], outputRange: ["#fff", "#000"] }) }}` |
| `borderColor`、`color` | `.bind_border_color(&node)`、`.bind_color(&node)` | 相同:顏色節點。`borderColor` 會為每一邊著色;`Text` 的 `color` 會為整段文字著色 |
| `transform` | `.bind_translate_x(&value)`、`.bind_translate_y`、`.bind_scale`、`.bind_rotate`(度) | `translateX`、`translateY`、`scale`、`scaleX`、`scaleY`、`rotate`、`rotateX`、`rotateY`、`rotateZ` 步進:`transform: [{ translateX: value }]` |
| 捲動位置 | 捲動視圖或列表上的 `.scroll_value(&value)` | `<Scroll scrollValue={value}>` 和 `<List scrollValue={value}>` 會在每一幀將捲動偏移量寫入其中 |
| 手勢 | `.pan_values(Some(&x), Some(&y))`、`.pinch_value(&scale)`、`.rotation_value(&angle)` | 任何元素上的 `pan={{ x, y }}`、`pinch={{ scale }}`、`rotation={{ angle }}`:手勢會直接寫入這些值(請參閱[觸控與手勢](https://nsh.dev/translations/zh-HK/docs/touch-and-gestures.md)) |
| 視圖黏附的位置 | `.sticky().bind_sticky_top(&value)`(在水平捲動視圖中為 `.bind_sticky_left`)、列表上的 `.bind_sticky_header_offset(&value)` | `style={{ position: "sticky", top: value }}`、`<List stickyHeaderOffset={value}>`:黏附視圖和標題會黏附在距離視口起點該數值的位置,並在每一幀跟隨該值 |
| Canvas | painter 呼叫的動畫參數 | [canvas](https://nsh.dev/translations/zh-HK/modules/nsh-canvas.md) 繪圖呼叫的任何數字或顏色參數 |
| Shader | `.uniform(name, &value)` | [shader](https://nsh.dev/translations/zh-HK/elements/shader.md) 的 `struct Uniforms` 的成員:`uniforms={{ name: value }}`(數字,或用於 `vec3f` / `vec4f` 的顏色節點) |

其他樣式屬性只接受普通值;若要為版面本身(尺寸、在流中的位置)加入動畫,請使用 [`LayoutAnimation`](#layoutanimation)。

### createAnimatedValue

```ts
function createAnimatedValue(value?: number): AnimatedValue;
```

建立一個動畫值,起始值為 `value`(預設為 `0`)。Rust:`animated_value(value)`,即由建立它的元件持有的 `Copy` 控制代碼。

```rust
use nsh_core::prelude::*;

#[component]
pub fn App() -> impl IntoElement {
    let x = animated_value(0.0);
    let right = stored(false);
    let toggle = move || {
        let to_right = !right.get();
        right.replace(to_right);
        let _ = x.spring(if to_right { 200.0 } else { 0.0 }, Spring::default());
    };
    view()
        .flex_1()
        .p_6()
        .pt_12()
        .gap_4()
        .child(view().size(px(60.0)).rounded(px(12.0)).bg(rgb(0x6366f1)).bind_translate_x(&x))
        .child(text("Move").on_press(toggle))
}
```

```tsx
import { mount, Text, View } from "nsh";
import { createAnimatedValue } from "nsh";

function App() {
  const x = createAnimatedValue(0);
  let right = false;
  const toggle = () => {
    right = !right;
    x.spring(right ? 200 : 0);
  };
  return (
    <View style={{ flex: 1, padding: 24, paddingTop: 48, gap: 16 }}>
      <View style={{ width: 60, height: 60, borderRadius: 12, backgroundColor: "#6366f1", transform: [{ translateX: x }] }} />
      <Text onPress={toggle}>Move</Text>
    </View>
  );
}

mount(App);
```

> [!NOTE]
> 動畫值存在於 UI 執行緒上,並在該處每一幀改變,因此應用程式以非同步方式讀取它:`await value.getValue()`(或 `stop()` 所解析出的 `{ value }`)會給出當下的值,在 Rust 中則於任務內使用 `value.value().await`。若你的程式碼需要立即使用目標值,請如上所示將它保存在變數或 signal 中。手勢事件會報告它們結束時的值(`PanEvent.value`)。

### AnimatedValue

```ts
class AnimatedValue extends AnimatedNode {
  constructor(value: number);
  set(value: number): void;
  stop(): Promise<{ value: number }>;
  getValue(): Promise<number>;
  setOffset(offset: number): void;
  flattenOffset(): void;
  extractOffset(): void;
  timing(to: number, options?: { duration?: number; easing?: Easing; delay?: number }): Promise<{ finished: boolean }>;
  spring(to: number, options?: { stiffness?: number; damping?: number; mass?: number; velocity?: number }): Promise<{ finished: boolean }>;
  decay(options?: { velocity?: number; deceleration?: number; clamp?: [number, number]; bounce?: number }): Promise<{ finished: boolean }>;
  keyframes(keyframes: KeyframeValue[], options?: KeyframeTiming): Promise<{ finished: boolean }>;
}
```

| 方法 | Rust | 說明 |
|---|---|---|
| `set(value)` | `set(value)` | 直接跳到 `value`,並停止任何正在執行的動畫。 |
| `stop()` | `stop()`,然後 `value().await` | 將正在執行的動畫停在目前位置;以 `{ value }`(即該處的值)解析。 |
| `getValue()` | `value().await` | 以目前的值(含其偏移量)解析,於 UI 執行緒上讀取。 |
| `setOffset(offset)` | `set_offset(offset)` | 加在值上的偏移量:綁定看到的是 value + offset;`set`、動畫和拖曳移動的是值本身。 |
| `flattenOffset()` | `flatten_offset()` | 將偏移量加到值上,並將偏移量歸零。 |
| `extractOffset()` | `extract_offset()` | 將值移入偏移量,並將值歸零(之後的拖曳會從上一次結束的位置開始)。 |
| `timing(to, options)` | `timing(to, ms, easing)`、`timing_with(to, Timing { … })` | 沿緩動曲線動畫至 `to`。`duration` 以毫秒計(預設 300),`easing`(預設 `"easeInOut"`),`delay` 以毫秒計(預設 0)。 |
| `spring(to, options)` | `spring(to, Spring { … })` | 以彈簧效果移向 `to`。`stiffness`(預設 170)、`damping`(預設 26)、`mass`(預設 1)、`velocity`(初始速度,單位為每秒)。 |
| `decay(options)` | `decay(velocity, deceleration)`、`decay_within(velocity, deceleration, (min, max), bounce)` | 以 `velocity`(單位為每秒)的速度滑行,並每毫秒減速 `deceleration`(預設 0.998)。適用於快速滑動(fling)之後,速度取自 `onPanEnd`。配合 `clamp: [min, max]` 時,它會在邊界處停止,或從邊界彈回並保留 `bounce`(0..1,預設 0)的速度。 |
| `keyframes(frames, options)` | `keyframes(frames, KeyframeTiming)` | 像 CSS 關鍵影格一樣播放[關鍵影格](#keyframes):每個區段各有一個緩動,`iterations` 次,時間為 `direction`。 |

每個動畫方法都會回傳一個 promise,以 `{ finished }` 解析:動畫完成時為 `true`,被停止時(由 `stop`、`set` 或同一個值的另一個動畫停止)為 `false`。在 Rust 中它回傳 `AnimationEnd`,任務可 await 它以取得相同的 `bool`。

```rust
let opacity = animated_value(1.0);
spawn(async move {
    if opacity.timing(0.0, 200.0, Easing::EaseInOut).await {
        visible.set(false);
    }
});
```

```tsx
const opacity = createAnimatedValue(1);
const { finished } = await opacity.timing(0, { duration: 200 });
if (finished) visible.set(false);
```

### AnimatedNode

```ts
class AnimatedNode {
  interpolate(config: {
    inputRange: number[];
    outputRange: number[] | string[];
    extrapolate?: Extrapolation;
    extrapolateLeft?: Extrapolation;
    extrapolateRight?: Extrapolation;
    easing?: Easing;
  }): AnimatedNode;
  diffClamp(min: number, max: number, options?: { snap?: boolean }): AnimatedNode;
  add(b: AnimatedNode | number): AnimatedNode;
  sub(b: AnimatedNode | number): AnimatedNode;
  mul(b: AnimatedNode | number): AnimatedNode;
  div(b: AnimatedNode | number): AnimatedNode;
  mod(b: AnimatedNode | number): AnimatedNode;
}

type Extrapolation = "extend" | "clamp" | "identity";
```

原生動畫圖中的一個節點:可以是 `AnimatedValue`,也可以是由其他節點衍生出來的值。衍生節點會在其輸入改變時於 UI 執行緒上求值。

| 方法 | Rust | 說明 |
|---|---|---|
| `interpolate(config)` | `interpolate(&input, &output)`、`interpolate_colors(&input, &colors)`、`interpolate_with(&input, output, Interpolation { … })` | 透過分段範圍對映輸入。`outputRange` 可包含數字、顏色(`"#ff0000"`、`"rgba(…)"`)或角度(`"45deg"`、`"1rad"`)。超出 `inputRange` 時預設會被限制在範圍內;`extrapolate`(或 `extrapolateLeft` / `extrapolateRight`)可以是 `"extend"`(延伸該直線)、`"clamp"` 或 `"identity"`(原樣傳遞輸入)。`easing` 為每個區段塑形。Rust 接受數字、主題顏色(會跟隨外觀)以及以度數表示的角度,用於 `bind_rotate`。 |
| `diffClamp(min, max, options?)` | `diff_clamp(min, max)`、`diff_clamp_with(min, max, DiffClamp::snapping())` | 跟隨輸入的變化,但將結果保持在 `min..max` 之內:例如標題列在內容向上捲動時隱藏,一旦向下捲動便立即重新出現。`snap: true` 會在其捲動視圖靜止時,於 200 毫秒內將它定位到較近的邊界,而 `max` 僅在輸入達到 `max` 之後才會這樣做。 |
| `add`、`sub`、`mul`、`div` | `add`、`sub`、`mul`、`div` | 與另一個節點或常數進行算術運算。 |
| `mod(b)` | `modulo(b)` | 取模,對於正的 `b` 結果永不為負(使角度保持在 0..360)。 |

```rust
let scroll_y = animated_value(0.0);
let header = scroll_y.diff_clamp(0.0, 44.0);

view()
    .flex_1()
    .child(scroll().scroll_value(&scroll_y).flex_1() /* rows */)
    .child(view().h(px(44.0)).bind_translate_y(&header.mul(-1.0)))
```

```tsx
const scroll = createAnimatedValue(0);
const header = scroll.diffClamp(0, 44);

<Scroll scrollValue={scroll} style={{ flex: 1 }}>{/* rows */}</Scroll>
<View style={{ height: 44, transform: [{ translateY: header.mul(-1) }] }} />
```

```rust
let progress = animated_value(0.0);
let rotate = progress.interpolate(&[0.0, 1.0], &[0.0, 360.0]); // degrees, for bind_rotate
let color = progress.interpolate_colors(&[0.0, 0.5, 1.0], &[rgb(0x22c55e), rgb(0xeab308), rgb(0xef4444)]);
```

```tsx
const progress = createAnimatedValue(0);
const rotate = progress.interpolate({ inputRange: [0, 1], outputRange: ["0deg", "360deg"] });
const color = progress.interpolate({ inputRange: [0, 0.5, 1], outputRange: ["#22c55e", "#eab308", "#ef4444"] });
```
### Keyframes

```ts
interface Keyframe {
  offset?: number;          // 0..1 of an iteration; missing offsets spread evenly
  value: number | string;   // a number, or an angle ("45deg", "0.5turn") in degrees
  easing?: Easing;          // the segment that starts here
}
type KeyframeValue = number | string | Keyframe;

interface KeyframeTiming {
  duration?: number;        // one iteration, ms (300)
  delay?: number;           // before the first iteration, ms (0)
  easing?: Easing;          // segments without their own (CSS "ease")
  iterations?: number | "infinite";   // 1
  direction?: "normal" | "reverse" | "alternate" | "alternate-reverse";
}
```

`value.keyframes(frames, options)` 透過關鍵影格播放一個值，並以 `{ finished }` 完成，表示是否播放完畢；`Animated.keyframes(value, { keyframes, ...options })` 等同組合動畫中的一個步驟。Rust：`value.keyframes(frames, KeyframeTiming)` 和 `Animation::keyframes(&value, frames, KeyframeTiming)`，附有 `Keyframe::new(value)`（平均分佈）、`Keyframe::at(offset, value)` 和 `.ease(easing)`，以及 `KeyframeTiming::new(ms)`，附有 `.delay(ms)`、`.easing(e)`、`.iterations(Some(n))`、`.infinite()` 和 `.direction(PlaybackDirection::…)`。

它們的播放方式與 CSS 關鍵影格相同：每條緩動只作用於一個區段（關鍵影格的緩動作用於由它開始的區段，其餘區段則使用時序的緩動）；關鍵影格中缺少的偏移量會平均分佈，第一個位於 0，最後一個位於 1；每次迭代都先跳到其第一個關鍵影格，若沒有位於 0 的關鍵影格，數值則由當前位置開始；在 `delay` 期間，數值維持在第一個關鍵影格（CSS `animation-fill-mode: backwards`）；反向迭代會倒序播放各區段，並將其曲線反轉；數值停留在最後一次迭代結束的位置。

```rust
let y = animated_value(0.0);
y.keyframes(
    [Keyframe::new(0.0), Keyframe::at(0.3, -20.0).ease(Easing::EaseIn), Keyframe::new(0.0)],
    KeyframeTiming::new(600.0).iterations(Some(3)),
);
```

```tsx
const y = createAnimatedValue(0);
y.keyframes([0, { offset: 0.3, value: -20, easing: "easeIn" }, 0], { duration: 600, iterations: 3 });
```

### createMotion

```ts
function createMotion(options?: MotionOptions): Motion;

interface MotionOptions extends MotionPlayOptions {
  initial?: { [C in MotionChannel]?: number };   // where channels start (the others at rest)
  enter?: MotionName | MotionSpec;   // start where it starts and play it at once
}

interface MotionPlayOptions extends KeyframeTiming {
  ignoreReduceMotion?: boolean;      // play even when the user asks for less motion
}

interface Motion {
  readonly opacity: AnimatedValue;
  readonly translateX: AnimatedValue;
  readonly translateY: AnimatedValue;
  readonly rotate: AnimatedValue;    // degrees
  readonly rotateX: AnimatedValue;
  readonly rotateY: AnimatedValue;
  readonly scale: AnimatedValue;
  readonly scaleX: AnimatedValue;
  readonly scaleY: AnimatedValue;
  readonly style: { opacity: AnimatedValue; transform: MotionTransform };
  readonly transform: MotionTransform;
  play(motion: MotionName | MotionSpec, options?: MotionPlayOptions): Promise<{ finished: boolean }>;
  animation(motion: MotionName | MotionSpec, options?: MotionPlayOptions): CompositeAnimation;
  stop(): void;
  reset(): void;
}
```

建立一個 motion：為視圖的不透明度和變換的每個通道各建立一個動畫值，初始處於靜止狀態（不透明度和縮放為 1，其餘為 0），並提供可在這些通道上以原生方式播放的 motion。使用 `style={motion.style}` 綁定，或將 `motion.style` 或 `motion.transform` 展開到樣式中；變換會先套用平移，再套用旋轉，最後套用縮放，其中 `scale` 會併入 `scaleX` 和 `scaleY`：這佔用了視圖 8 個變換步驟中的 7 個，餘下一個留給你自己使用（`[{ perspective: 600 }, ...motion.transform]` 用於 3D 翻轉）。`initial` 設定各通道的起始位置（`{ opacity: 0 }`；其餘通道由靜止狀態開始）。使用 `enter` 時，它所移動的通道會由該 motion 的起點開始（經過 `initial`），並立即播放，其餘選項則作為它的時序（`delay`、`duration` 等）。motion 只會移動不透明度和變換：展開 `motion.style` 會設定視圖的 `opacity`，因此若要保留自己設定的不透明度，請只綁定 `motion.transform`；顏色則應使用動畫值和 `interpolate` 來移動。Rust：`motion()`，這是由建立它的元件保管的 `Copy` 控制代碼，以 `.bind_motion(&m)` 綁定；`motion().entering(spec)` 負責進場。

| 成員 | Rust（`Motion`） | 說明 |
|---|---|---|
| `opacity`、`translateX`、... `scaleY` | `value(MotionChannel::Opacity)`、... | 各通道的動畫值。 |
| `style`、`transform` | 元素上的 `.bind_motion(&m)` | 綁定。 |
| `play(motion, options?)` | `play(spec)`、`play_ignoring_reduce_motion(spec)` | 按名稱播放預設，或在各通道上播放 [`MotionSpec`](#motions)；新的 motion 會中斷正在播放的 motion。`options` 會更改其時序。以 `{ finished }` 完成：播放完畢時為 `true`，被其他操作中止時為 `false`（Rust 中為 `AnimationEnd`）。 |
| `animation(motion, options?)` | `animation(spec)` | 同一個 motion，但以 [`CompositeAnimation`](#animated) 形式提供，尚未開始，可用於組合：`Animated.stagger(60, cards.map((m) => m.animation("fadeInUp")), { from: "center" })`（Rust：`Animation::stagger_from(60.0, StaggerFrom::Center, cards.iter().map(\|m\| m.animation(MotionPreset::FadeInUp)))`）。是否減少動態效果，於建立時決定。 |
| `stop()` | `stop()` | 在當前位置停止。 |
| `reset()` | `reset()` | 停止並將每個通道還原到靜止狀態。 |

當使用者要求減少動態效果（`reduceMotion()`）時，`play`、`animation` 和 `enter` 會立即跳到 motion 的終點，不產生移動，除非已設定 `ignoreReduceMotion`（在選項或 motion 上設定）。

```rust
let card = motion().entering(MotionPreset::FadeInUp.spec().timing(|t| t.delay(80.0)));
view().size(px(120.0)).rounded(px(16.0)).bg(rgb(0x0969da)).bind_motion(&card).on_press(move || {
    card.play(MotionPreset::Pulse);
})
```

```tsx
const card = createMotion({ enter: "fadeInUp", delay: 80 });
<View onPress={() => card.play("pulse")} style={{ width: 120, height: 120, borderRadius: 16, backgroundColor: "#0969da", ...card.style }} />
```

### motions

```ts
const motions: Readonly<Record<MotionName, MotionPreset>>;

type MotionChannel = "opacity" | "translateX" | "translateY" | "rotate" | "rotateX" | "rotateY" | "scale" | "scaleX" | "scaleY";

interface MotionSpec extends KeyframeTiming {
  keyframes: { [C in MotionChannel]?: KeyframeValue[] } | MotionFrame[];
  ignoreReduceMotion?: boolean;
}
type MotionFrame = { [C in MotionChannel]?: number | string } & { offset?: number; easing?: Easing };

interface MotionPreset extends MotionSpec {
  kind: "enter" | "exit" | "attention" | "loop";
}
```

以資料形式提供的現成 motion：`motion.play("shake")` 會播放 `motions.shake`。`MotionName` 是以下其中之一：`fadeIn`、`fadeInUp`、`fadeInDown`、`fadeInLeft`、`fadeInRight`、`zoomIn`、`popIn`、`bounceIn`、`slideInUp`、`slideInDown`、`flipInX`、`flipInY`（進場）；`fadeOut`、`fadeOutUp`、`fadeOutDown`、`fadeOutLeft`、`fadeOutRight`、`zoomOut`、`popOut`、`slideOutDown`、`slideOutUp`（退場）；`shake`、`headShake`、`wiggle`、`swing`、`pulse`、`heartbeat`、`rubberBand`、`jello`、`tada`、`jump`、`flash`（引起注意）；`spin`、`float`、`breathe`、`sway`、`bob`、`glow`（循環）。[Animation](https://nsh.dev/translations/zh-HK/docs/animation.md#presets) 說明每一個 motion。展開預設即可修改它：`motion.play({ ...motions.fadeInUp, duration: 600 })`。

`MotionSpec` 是你自訂的 motion：可以是每個通道的關鍵影格（`{ opacity: [0, 1], translateY: [24, 0] }`），也可以是如 Web Animations API 所寫的影格清單（`[{ opacity: 0, translateY: 24 }, { opacity: 1, translateY: 0 }]`，沒有偏移量的影格會按其在清單中的位置決定位置），並附上[關鍵影格時序](#keyframes)。Rust：`MotionSpec::new(KeyframeTiming)`，附有 `.values(channel, values)`（平均分佈）、`.track(channel, keyframes)` 和 `.timing(|t| …)`；`MotionPreset::FadeInUp`（或 `.spec()`）是預設，`MotionChannel` 則為各通道命名。

```rust
let rise = MotionSpec::new(KeyframeTiming::new(400.0))
    .values(MotionChannel::Opacity, [0.0, 1.0])
    .track(MotionChannel::TranslateY, [Keyframe::at(0.0, 24.0).ease(Easing::EaseOut), Keyframe::at(1.0, 0.0)]);
card.play(rise);
```

```tsx
card.play({ duration: 400, keyframes: [{ opacity: 0, translateY: 24, easing: "easeOut" }, { opacity: 1, translateY: 0 }] });
```

### Animated

```ts
const Animated: {
  timing(value: AnimatedValue, config: { toValue: number; duration?: number; easing?: Easing; delay?: number }): CompositeAnimation;
  spring(value: AnimatedValue, config: { toValue: number; stiffness?: number; damping?: number; mass?: number; velocity?: number }): CompositeAnimation;
  decay(value: AnimatedValue, config?: { velocity?: number; deceleration?: number; clamp?: [number, number]; bounce?: number }): CompositeAnimation;
  sequence(list: CompositeAnimation[]): CompositeAnimation;
  parallel(list: CompositeAnimation[], config?: { stopTogether?: boolean }): CompositeAnimation;
  stagger(ms: number, list: CompositeAnimation[], config?: { from?: "first" | "last" | "center" | number }): CompositeAnimation;
  delay(ms: number): CompositeAnimation;
  loop(animation: CompositeAnimation, config?: { iterations?: number; resetBeforeIteration?: boolean }): CompositeAnimation;
  keyframes(value: AnimatedValue, config: KeyframeTiming & { keyframes: KeyframeValue[] }): CompositeAnimation;
  add(a: AnimatedNode, b: AnimatedNode | number): AnimatedNode;
  subtract(a: AnimatedNode, b: AnimatedNode | number): AnimatedNode;
  multiply(a: AnimatedNode, b: AnimatedNode | number): AnimatedNode;
  divide(a: AnimatedNode, b: AnimatedNode | number): AnimatedNode;
  modulo(a: AnimatedNode, b: AnimatedNode | number): AnimatedNode;
  diffClamp(a: AnimatedNode, min: number, max: number, options?: { snap?: boolean }): AnimatedNode;
  sampleSpring(config: SpringConfig & { from?: number }, fps?: number): number[];
  sampleDecay(config: DecayConfig & { from?: number }, fps?: number): number[];
};

interface CompositeAnimation {
  start(callback?: (result: { finished: boolean }) => void): Promise<{ finished: boolean }>;
  stop(): void;
  reset(): void;
}
```

`Animated` 以多個步驟（`timing`、`spring`、`decay`、`sequence`、`parallel` 等）組成動畫。在呼叫 `start()` 之前不會執行任何動作；呼叫後，整個樹會以原生方式執行，每個步驟都會在上一個步驟結束的一刻於 UI 執行緒上開始（沒有影格間隙，步驟之間也不需要回到應用程式的程式碼）。Rust 以 `Animation` 建立相同的樹。

| 函式 | Rust | 說明 |
|---|---|---|
| `timing`、`spring`、`decay` | `Animation::timing(&value, to, Timing)`、`Animation::spring(&value, to, Spring)`、`Animation::decay(&value, velocity, deceleration)` | 對單一數值的單一動畫；選項和預設值與 [`AnimatedValue`](#animatedvalue) 方法相同，目標則以 `toValue` 指定。 |
| `sequence(list)` | `Animation::sequence(list)` | 逐個依序執行各動畫。 |
| `parallel(list, { stopTogether })` | `Animation::parallel(list).stop_together(…)` | 同時執行各動畫。設定 `stopTogether`（預設為 `true`）後，停止其中一個即會停止全部。 |
| `stagger(ms, list, { from })` | `Animation::stagger(ms, list)`、`Animation::stagger_from(ms, StaggerFrom::Center, list)` | 以並行方式執行各動畫，每個動畫在上一個動畫開始 `ms` 後開始，並由 `from` 起計：`"first"`（預設）、`"last"`、`"center"`（中間的動畫立即開始，其餘由它向外依次展開；數量為偶數時，兩個中間項目都會立即開始）或索引（`StaggerFrom::First`、`Last`、`Center`、`Index(i)`）。 |
| `keyframes(value, { keyframes, ...timing })` | `Animation::keyframes(&value, frames, KeyframeTiming)` | 透過[關鍵影格](#keyframes)播放一個值。 |
| `delay(ms)` | `Animation::delay(ms)` | 等待；在 `sequence` 內很有用。 |
| `loop(animation, { iterations, resetBeforeIteration })` | `animation.looped(iterations).reset_before_iteration(…)` | 將 `animation` 重複 `iterations` 次（預設為無限次），每次執行前會將其數值還原，除非 `resetBeforeIteration` 為 `false`。 |
| `add`、`subtract`、`multiply`、`divide`、`modulo`、`diffClamp` | 節點方法 | 與 [`AnimatedNode`](#animatednode) 的方法相同，以函式形式提供。 |
| `sampleSpring`、`sampleDecay` | `nsh_core::element::sample_spring`、`sample_decay` | 彈簧或衰減動畫每影格一個的數值（見 [Easing](#easing)）。 |

`CompositeAnimation`：

| 方法 | Rust（`Animation`） | 說明 |
|---|---|---|
| `start(callback?)` | `start()`，一個可供 await 的 `AnimationEnd` | （再次）執行。整個樹只會完成一次並呼叫一次 `callback`，帶有 `{ finished }`：表示是否已播放完畢而非被中止。 |
| `stop()` | `stop()` | 在當前位置停止。 |
| `reset()` | `reset()` | 停止並將其數值還原到開始時的狀態。 |

```rust
let ys: Vec<AnimatedValue> = (0..4).map(|_| animated_value(0.0)).collect();
let hop = Animation::stagger(
    80.0,
    ys.iter().map(|y| {
        Animation::sequence([
            Animation::timing(y, -36.0, Timing::new(220.0, Easing::Curve(Curve::Quad, EaseMode::Out))),
            Animation::spring(y, 0.0, Spring { stiffness: 300.0, damping: 12.0, ..Spring::default() }),
        ])
    }),
);
let run = hop.start();
spawn(async move { log::info!("{}", if run.await { "done" } else { "stopped" }) });
```

```tsx
const ys = [0, 1, 2, 3].map(() => createAnimatedValue(0));
const hop = Animated.stagger(
  80,
  ys.map((y) =>
    Animated.sequence([
      Animated.timing(y, { toValue: -36, duration: 220, easing: Easing.out(Easing.quad) }),
      Animated.spring(y, { toValue: 0, stiffness: 300, damping: 12 }),
    ]),
  ),
);
hop.start(({ finished }) => console.log(finished ? "done" : "stopped"));
```

### Easing

```ts
type Easing =
  | "linear" | "ease" | "easeIn" | "easeOut" | "easeInOut"
  | [number, number, number, number]   // cubic Bézier x1, y1, x2, y2
  | EasingCurve
  | EasingSteps;                     // Easing.steps(…)

const Easing: {
  linear: Easing;
  ease: Easing;
  bezier(x1: number, y1: number, x2: number, y2: number): Easing;
  steps(count: number, position?: "jump-start" | "jump-end" | "jump-none" | "jump-both" | "start" | "end"): EasingSteps;
  in(e: Easing): Easing;
  out(e: Easing): Easing;
  inOut(e: Easing): Easing;
  sample(easing: Easing, count: number): number[];
  quad: EasingCurve;
  cubic: EasingCurve;
  poly(n?: number): EasingCurve;
  sin: EasingCurve;
  circle: EasingCurve;
  exp: EasingCurve;
  elastic(bounciness?: number): EasingCurve;
  back(s?: number): EasingCurve;
  bounce: EasingCurve;
};
```

緩動是資料，而不是應用程式的函式：執行階段會以原生方式計算它。在 Rust 中，它是列舉 `Easing`：`Linear`、`EaseIn`、`EaseOut`、`EaseInOut`、`Bezier(x1, y1, x2, y2)`、`Steps(count, StepPosition)` 和 `Curve(curve, mode)`，另有 `Curve`（`Quad`、`Cubic`、`Poly(n)`、`Sin`、`Circle`、`Exp`、`Elastic(bounciness)`、`Back(s)`、`Bounce`）和 `EaseMode`（`In`、`Out`、`InOut`）。若要繪製緩動曲線，`Easing.sample(easing, count)`（Rust：`nsh_core::element::sample_easing`）會給出由 0 至 1 之間 `count` 個等距點上的數值；`Animated.sampleSpring` 和 `Animated.sampleDecay`（`sample_spring`、`sample_decay`）會給出彈簧或衰減動畫每影格一個的數值（按 `fps`，預設為 60），由 `from`（0）起直至結束。這三者都來自動畫所執行的原生程式碼。在 TSX 中凡接受 `Easing` 的地方，你都可以傳入名稱（`"easeOut"`）、以四個數字表示的三次貝茲曲線，或 `Easing` 物件中的曲線。

| 曲線 | Rust | 形狀 |
|---|---|---|
| `quad` | `Curve::Quad` | `t²` |
| `cubic` | `Curve::Cubic` | `t³` |
| `poly(n)` | `Curve::Poly(n)` | `tⁿ`（`n` 預設為 2） |
| `sin` | `Curve::Sin` | 正弦 |
| `circle` | `Curve::Circle` | 圓形 |
| `exp` | `Curve::Exp` | 指數 |
| `elastic(bounciness)` | `Curve::Elastic(bounciness)` | 彈性過衝（`bounciness` 預設為 1） |
| `back(s)` | `Curve::Back(s)` | 先向後拉再前進；`s` 為過衝量（預設 1.70158） |
| `bounce` | `Curve::Bounce` | 在終點彈跳 |

`Easing.steps(count, position)` 即 CSS 的 `steps()`：數值會在 `count` 個層級之間跳躍，而不是連續移動（例如逐格播放的精靈圖、滴答轉動的指針）。`position` 指定跳躍發生的位置：`"jump-end"`（預設，亦可寫作 `"end"`）、`"jump-start"`（`"start"`）、`"jump-none"` 或 `"jump-both"`；Rust：`Easing::Steps(count, StepPosition::JumpEnd)`、`JumpStart`、`JumpNone`、`JumpBoth`。

這些曲線都是「in」形式（起步較慢）。`Easing.out(curve)` 會將曲線反轉，`Easing.inOut(curve)` 會令曲線對稱，`Easing.in(curve)` 則保持原樣；在 Rust 中，這是 `Easing::Curve` 的 `EaseMode`。

```rust
let _ = value.timing(1.0, 600.0, Easing::Curve(Curve::Back(1.70158), EaseMode::Out));
let _ = value.timing_with(1.0, Timing { easing: Easing::Bezier(0.2, 0.0, 0.0, 1.0), ..Timing::default() });
let _ = value.timing_with(1.0, Timing { easing: Easing::EaseOut, ..Timing::default() });
```

```tsx
value.timing(1, { duration: 600, easing: Easing.out(Easing.back()) });
value.timing(1, { easing: Easing.bezier(0.2, 0, 0, 1) });
value.timing(1, { easing: "easeOut" });
```

### LayoutAnimation

```ts
const LayoutAnimation: {
  configureNext(config: LayoutAnimationConfig, onEnd?: () => void): void;
  create(duration: number, type?: LayoutAnimationType, property?: LayoutAnimationProperty): LayoutAnimationConfig;
  Types: { easeInEaseOut; spring; linear; easeIn; easeOut; keyboard };
  Properties: { opacity; scaleXY };
  Presets: { easeInEaseOut: LayoutAnimationConfig; linear: LayoutAnimationConfig; spring: LayoutAnimationConfig };
  easeInEaseOut(onEnd?: () => void): void;
  linear(onEnd?: () => void): void;
  spring(onEnd?: () => void): void;
};

interface LayoutAnimationConfig {
  duration: number;                // ms
  create?: LayoutAnimationAnimation;    // views inserted
  update?: LayoutAnimationAnimation;    // views moved or resized
  delete?: LayoutAnimationAnimation;    // views removed
}

interface LayoutAnimationAnimation {
  type?: "easeInEaseOut" | "spring" | "linear" | "easeIn" | "easeOut" | "keyboard";
  property?: "opacity" | "scaleXY" | "scale";
  duration?: number;
  delay?: number;
  springDamping?: number;          // 0..1, default 0.7
}
```

`LayoutAnimation.configureNext(config)` 會以原生方式為下一次提交中的版面變更加上動畫：移動或改變大小的視圖會平滑過渡到新的框架，新視圖會出現（按 `property` 淡入或縮放進場），被移除的視圖則會消失。請在改變版面的狀態變更之前立即呼叫它。Rust：`animate_layout(LayoutAnimation)`，可使用 `nsh_core::element::LayoutAnimation` 的預設，或其 `create`、`update` 和 `delete` 轉場。

| 成員 | Rust | 說明 |
|---|---|---|
| `configureNext(config, onEnd?)` | `animate_layout(config)` | 為下一次提交的變更加上動畫；完成後會執行 `onEnd`。 |
| `create(duration, type?, property?)` | `LayoutAnimation::all(LayoutTransition::new(curve, duration))` | 三個階段使用同一條曲線的設定。 |
| `Presets.easeInEaseOut` | `LayoutAnimation::EASE_IN_EASE_OUT` | 300 毫秒，視圖淡入淡出。 |
| `Presets.linear` | `LayoutAnimation::LINEAR` | 500 毫秒，速度恆定。 |
| `Presets.spring` | `LayoutAnimation::SPRING` | 700 毫秒；框架以彈簧效果移動（阻尼 0.4），視圖以線性方式淡入淡出。 |
| `easeInEaseOut(onEnd?)`、`linear(onEnd?)`、`spring(onEnd?)` | `animate_layout(LayoutAnimation::…)` | `configureNext(Presets.…)` 的簡寫。 |
| `Types`、`Properties` | `LayoutCurve`、`LayoutProperty` | 以常數形式提供的名稱。 |

```rust
let open = signal(false);
let toggle = move || {
    animate_layout(LayoutAnimation::SPRING);
    open.update(|o| *o = !*o);
};

view()
    .child(text("Details").on_press(toggle))
    .child(when(move || open.get(), || text("Shown with a spring.")))
```

```tsx
const open = signal(false);
const toggle = () => {
  LayoutAnimation.configureNext(LayoutAnimation.Presets.spring);
  open.update((o) => !o);
};

<View>
  <Text onPress={toggle}>Details</Text>
  <If when={open()}>
    <Text>Shown with a spring.</Text>
  </If>
</View>
```

## Device

Device API 讓應用程式得知裝置及其環境的資訊。Rust 程式碼呼叫 `nsh_core::prelude` 的函式（`window()`、`dark()`、`font_scale()`、`locale()`、`lifecycle()`、`os()`、`idiom()`、`pixel_ratio()`、`set_color_scheme()` 等）；TSX 則從 `nsh` 匯入。視窗大小、色彩配置、字體縮放、像素比例、語言地區和應用程式狀態都是響應式的：在綁定（Rust 中傳給元素的閉包，TSX 中的 JSX）、`computed` 或 `effect` 中讀取它們，當平台通報變更（旋轉、調整視窗大小、切換到深色模式、更改文字大小或語言、移到另一種密度的螢幕、離開應用程式後再返回）時，讀取它們的內容就會隨之更新。

```rust
use nsh_core::{
    platform::{Appearance, Idiom, Lifecycle, Os},
    prelude::*, // window, dark, font_scale, locale, lifecycle, os, idiom, pixel_ratio, set_color_scheme, ...
};
```

```tsx
import { colorScheme, fontScale, lifecycle, locale, openUrl, pixelRatio, platform, setColorScheme, windowSize } from "nsh";
```

指南請參閱[裝置與環境](https://nsh.dev/translations/zh-HK/docs/device-and-environment.md)。

### windowSize

```ts
const windowSize: Accessor<Size>;
```

介面的大小，以點（point）為單位：即視窗，或嵌入的 `NshAppView` / `NshView`。響應式。Rust：`window() -> Size<Points>`。

```rust
let wide = move || window().width >= px(600.0);

view().style_when(wide, |s| s.flex_row())
```

```tsx
const wide = () => windowSize().width >= 600;

<View style={{ flexDirection: wide() ? "row" : "column" }}>{/* ... */}</View>
```

### colorScheme

```ts
const colorScheme: Accessor<"light" | "dark">;
```

所顯示的外觀：除非應用程式自行設定（`nsh.json` `"appearance"`、[`setColorScheme`](#setcolorscheme)），否則為系統的外觀。響應式。Rust：`dark() -> bool`；主題顏色（`AdaptiveColor`）和 `.dark(…)` 樣式變體無需讀取它也會跟隨。Tailwind 的 `dark:` 變體也會跟隨它（見 [Tailwind](https://nsh.dev/translations/zh-HK/docs/tailwind.md)）。

```rust
view().flex_1().bg(move || if dark() { black() } else { white() })
```

```tsx
const dark = () => colorScheme() === "dark";

<View style={{ flex: 1, backgroundColor: dark() ? "#000" : "#fff" }} />
```

### fontScale

```ts
const fontScale: Accessor<number>;
```

使用者偏好的文字大小相對於預設值的比例（`1` 為預設大小；iOS 上為 Dynamic Type，Android 上為字體大小），或由嵌入的應用程式改為設定的文字大小。響應式。Rust：`font_scale() -> f32`。文字已會隨之縮放，除非設定了 `allowFontScaling={false}`；當其他尺寸也應跟隨時（例如文字旁的圖示），請讀取它。

### locale

```ts
const locale: Accessor<string>;
```

使用者的語言地區，以 BCP 47 標籤表示（`"en-US"`、`"zh-Hans-CN"`），或由嵌入的應用程式改為設定的語言地區。響應式。Rust：`locale() -> Locale`（`locale().as_str()` 為標籤）。`Intl` 預設會按它格式化；請讀取它來選擇你自己翻譯的字串（[裝置與環境](https://nsh.dev/translations/zh-HK/docs/device-and-environment.md#language-and-region)）。

### lifecycle

```ts
const lifecycle: Accessor<"active" | "inactive" | "background">;
```

應用程式是否正在使用，以 React Native 的 `AppState` 術語表示：`"active"` 表示位於前景並接受輸入，`"inactive"` 表示可見但沒有焦點（其他應用程式或視窗取得了焦點，或系統在其上方顯示內容，例如應用程式切換器或來電），`"background"` 表示不可見（在背景、已最小化、隱藏的瀏覽器分頁）。顯示在原生應用程式內的應用程式，會跟隨該原生應用程式的狀態。響應式。Rust：`lifecycle() -> Lifecycle`（`Lifecycle::Active`、`Inactive`、`Background`）。

在 effect 中讀取它，即可暫停無人看見的工作，並在應用程式返回時恢復：

```rust
effect(move || {
    if lifecycle() == Lifecycle::Background {
        feed.pause();
    } else {
        feed.resume();
    }
});
```

```tsx
effect(() => {
  if (lifecycle() === "background") feed.pause();
  else feed.resume();
});
```

### setColorScheme

```ts
function setColorScheme(scheme: "light" | "dark" | "system"): void;
```

無論系統的外觀為何，立即以 `scheme` 顯示應用程式：`colorScheme()`、`dark:` 類別、元件的主題和平台的控制項（警示、狀態列的預設樣式）都會跟隨它；`"system"` 則會再次跟隨系統。`nsh.json` `"appearance"` 會在一開始就設定它。Rust：`set_color_scheme(Appearance::Dark)`（`Appearance::Light`、`Appearance::System`）。
### platform

```ts
const platform: Platform;

interface Platform {
  readonly os: "ios" | "android" | "macos" | "windows" | "linux" | "web";
  readonly version: string;
  readonly idiom: "phone" | "pad" | "mac" | "tv" | "desktop";
  readonly desktop: boolean;
  readonly catalyst: boolean;
  select<T>(spec: PlatformSelectSpec<T>): T | undefined;
}
```

應用程式執行所在的平台,執行期間保持不變。

| 成員 | Rust | 說明 |
|---|---|---|
| `os` | `os() -> Os`(`os().name()` 是 TS 字串) | 作業系統。iOS、iOS 模擬器及 Mac Catalyst 回報 `"ios"`,瀏覽器回報 `"web"`。 |
| `version` | `os_version() -> (u16, u16)` | iOS 和桌面平台:作業系統版本(`"17.5"`);Android:API level(`"35"`);網頁:`"0.0"`。Rust 提供主版本和次版本,在 Android 上則為 API level 和 0。 |
| `idiom` | `idiom() -> Idiom` | 裝置類型:只有在 `desktop` 為 `true` 時才是 `"desktop"`;其他情況為 `"phone"`、`"pad"`(iPad、Android 平板電腦、iPad idiom 的 Mac Catalyst)、`"mac"`(UIKit Mac idiom 的 Mac Catalyst,屬行動 idiom)或 `"tv"`(Apple TV、Android TV)。 |
| `desktop` | `is_desktop() -> bool` | 套用桌面行為:macOS、Windows、Linux,以及配備滑鼠和寬視窗的瀏覽器。`desktop={{…}}` props、`select({ desktop })` 和 `idiom` `"desktop"` 都以此為準。Mac Catalyst 執行 iOS backend,因此屬於行動平台:`ios={{…}}` props 在該處適用。[`nsh/desktop`](https://nsh.dev/translations/zh-HK/modules/nsh-desktop.md#isavailable) 的 `isAvailable` 是範圍較窄的檢查,針對其視窗和選單 API,而這些 API 只有 macOS、Windows 和 Linux 才有。 |
| `catalyst` | `is_catalyst() -> bool` | 應用程式在 Mac Catalyst 下執行,屬 Mac 或 iPad idiom(`os` 為 `"ios"`)。 |
| `select(spec)` | `match os() { … }` | 此平台對應的值(見下文)。 |

```rust
let columns = if is_desktop() { 3 } else { 1 };
```

```tsx
const columns = platform.desktop ? 3 : 1;
```

#### platform.select

```ts
interface PlatformSelectSpec<T> {
  ios?: T;
  android?: T;
  macos?: T;
  windows?: T;
  linux?: T;
  web?: T;
  mobile?: T;   // platform.desktop false: iOS (Mac Catalyst included), Android, phone and tablet browsers
  desktop?: T;  // platform.desktop true: macOS, Windows, Linux, desktop browsers
  native?: T;   // every platform but the web
  default?: T;
}
```

傳回目前作業系統(`ios`、`android`、`web` 等)對應的值,否則依 `platform.desktop` 傳回 `desktop` 或 `mobile`,再否則傳回 `native`(除網頁外的所有平台),再否則傳回 `default`,最後傳回 `undefined`。Rust 程式碼則對 `os()` 進行比對(對 `mobile` / `desktop` 則使用 `is_desktop()`)。

```rust
use nsh_core::platform::Os;

let card = view().p_4();
let card = match os() {
    Os::Ios => card.shadow([BoxShadow {
        offset_y: px(2.0),
        blur: px(8.0),
        color: rgba(0x00000026).into(),
        ..BoxShadow::default()
    }]),
    Os::Android => card.elevation(px(4.0)),
    _ => card,
};
```

```tsx
const shadow = platform.select({
  ios: { boxShadow: "0px 2px 8px rgb(0 0 0 / 0.15)" },
  android: { elevation: 4 },
});
```

> [!TIP]
> 對於只有單一平台才有的 props,建議使用 TSX 元素和元件的 `ios={{…}}` / `android={{…}}` props,而不是以 `platform` 作分支(見[特定平台的程式碼](https://nsh.dev/translations/zh-HK/docs/platform-specific.md))。

### pixelRatio

```ts
const pixelRatio: Accessor<number>;
```

每個點(point)對應的裝置像素數(例如大多數 iPhone 為 `3`)。具反應性:當視窗移到另一個密度不同的螢幕(在 Mac Catalyst、桌面平台或外接顯示器上),以及 Android 的顯示大小設定改變密度時,它都會隨之改變。Rust:`pixel_ratio() -> f32`,以及用於以整數裝置像素表示長度的 `round_to_nearest_pixel(points)`;在 TSX 中該長度為 `Math.round(x * pixelRatio()) / pixelRatio()`。

```rust
let hairline = move || px(1.0 / pixel_ratio());
```

```tsx
const hairline = () => 1 / pixelRatio();
```

### openUrl

```ts
function openUrl(url: string): Promise<OpenUrlResult>;

interface OpenUrlResult {
  opened: boolean;
}
```

在應用程式以外開啟 `url`:網頁會在瀏覽器開啟,`mailto:` 連結會在郵件應用程式開啟,`tel:` 連結會在電話應用程式開啟,其他應用程式登記的任何 scheme 也一樣(`maps:`、宿主應用程式自己的 `lb://` 連結)。它會以 `{ opened }` resolve,表示是否有東西開啟了它:當沒有應用程式處理該 scheme 時為 `false`。Rust:`nsh_core::prelude` 的 `open_url(url)`,即在 task 中 await(或直接丟棄)的結果,同樣是 `bool`。

```rust
button()
    .child(text("Contact us"))
    .on_press(|| {
        spawn(async {
            if !open_url("mailto:help@example.com").await {
                log::warn!("no mail app");
            }
        });
    })
```

```tsx
<Button
  onPress={async () => {
    const { opened } = await openUrl("mailto:help@example.com");
    if (!opened) {
      console.warn("no mail app");
    }
  }}
>
  <Text>Contact us</Text>
</Button>
```

- URL 必須是絕對 URL(帶有 scheme)。會執行程式碼或存取應用程式自身資料的 URL,即 `javascript:`、`data:`、`blob:`、`file:`、`about:` 和 `asset:`,以及相對位址,都會令 promise 以 `TypeError` reject。`openUrl` 從不丟出例外:所有失敗都會 reject。
- 聲明了 capabilities 的應用程式需要 `openUrl`:`"openUrl": true` 表示所有 scheme,`"openUrl": { "schemes": ["https", "mailto"] }` 表示所列出的 scheme;其他 URL 會以 `Error` reject,其 `code` 為 `"ERR_ACCESS_DENIED"`,`permission` 為 `"openUrl"`,`resource` 為該 URL(見[沙盒與 capabilities](https://nsh.dev/translations/zh-HK/docs/sandbox-and-capabilities.md))。在 Rust 中,`open_url` 會對這些 URL 回應 `false` 並記錄原因。
- 嵌入式應用程式會先看到該 URL,並可改為透過自己的 router 處理(Swift `NshAppView.onOpenURL`、Java `NshView.setOpenUrlHandler`、Rust `AppView::host().handle_open_url`;見 [iOS](https://nsh.dev/translations/zh-HK/docs/ios.md#links-the-app-opens) 和 [Android](https://nsh.dev/translations/zh-HK/docs/android.md#links-the-app-opens))。此時 `opened` 為 `true`。
- 各平台使用自己的開啟方式:iOS 使用 `UIApplication.open`(嵌入時使用宿主應用程式的 application),Android 使用 `ACTION_VIEW` intent,macOS 使用 `NSWorkspace`,Windows 使用 `ShellExecuteW`,Linux 使用 `GtkUriLauncher`,GPUI 使用 `open_url`,網頁則開啟新分頁。`nsh test` 只會記錄 URL 而不會開啟。
- [`TextView`](https://nsh.dev/translations/zh-HK/elements/text-view.md) 以 `links="open"` 用這種方式開啟其連結。

<a id="layout-direction"></a>

### 版面方向

版面方向跟隨使用者的語言:使用從右至左的語言(阿拉伯語、希伯來語等)時,應用程式會由右至左排版,列(row)從右邊開始排列,而 `left` / `right` 樣式屬性(`marginLeft`、`paddingRight` 等)則表示起始和結束。變更會立即生效,無需重新啟動。

檢視的 `direction` 樣式(`"ltr"`、`"rtl"` 或預設的 `"inherit"`)設定其自身子樹的方向;在 Rust 中為 `.direction(LayoutDirection::..)`。

```rust
view().direction(LayoutDirection::Rtl).flex_row().gap_2().child(text("First")).child(text("Second"))
```

```tsx
<View style={{ direction: "rtl", flexDirection: "row", gap: 8 }}>
  <Text>First</Text>
  <Text>Second</Text>
</View>
```

<a id="related"></a>

### 相關內容

- 安全區域 insets(Rust `safe_area()`;TSX `safeArea()`、`safeAreaInsets()`)和軟鍵盤(Rust `keyboard_height()`;TSX `keyboardFrame()`)請見[安全區域與鍵盤](#safe-area-and-keyboard)。
- 螢幕閱讀器和減少動態效果的設定請見[無障礙](#accessibility)。

## PanResponder

Pan responder 是建基於 gesture responder 系統的拖曳手勢:元素會認領一個手勢的觸控,然後獨自接收這些觸控及手勢狀態,手勢狀態會為你追蹤觸控點的質心、移動距離和速度。Rust 程式碼以 `.claim_touches(TouchClaim::…)` 認領觸控,並以 `.on_responder_*` 監聽器接收,其 `ResponderEvent` 帶有 `PanGesture`,全部位於 `nsh_core::prelude`;TSX 則從 `nsh` 匯入 `PanResponder`,其 `onPanResponder…` 回呼會接收觸控事件和手勢狀態。

```rust
use nsh_core::prelude::*; // TouchClaim, ClaimRequest, ResponderEvent, PanGesture
```

```tsx
import { PanResponder, type PanResponderGestureState } from "nsh";
```

> [!TIP]
> Pan responder 會把每一次移動都傳送到應用程式的程式碼,因此適合會改變應用程式狀態的手勢(重新排序、滑動刪除的判斷)。若要讓檢視跟隨手指移動,原生手勢值更快:Rust 中的 `.pan_values(x, y)`、`.pinch_value(scale)` 和 `.rotation_value(angle)`,TSX 中的 `pan={{ x, y }}`、`pinch` 和 `rotation` props。拖曳會在 UI 執行緒寫入[動畫值](#animation),應用程式只會在拖曳開始和結束時收到通知(`on_pan_start` / `on_pan_end`、`onPanStart` / `onPanEnd`)。請見[觸控與手勢](https://nsh.dev/translations/zh-HK/docs/touch-and-gestures.md)。

### PanResponder.create

```ts
const PanResponder: { create(config: PanResponderCallbacks): PanResponderInstance };

interface PanResponderInstance {
  panHandlers: TouchProps;
}
```

建立一個 pan responder。把它的 `panHandlers` 展開到應接收手勢的檢視上。在 Rust 中,由檢視自行認領觸控並監聽:

```rust
use nsh_core::prelude::*;

#[component]
pub fn App() -> impl IntoElement {
    let pos = signal((0.0_f32, 0.0_f32));
    let start = stored((0.0_f32, 0.0_f32));
    view().flex_1().child(
        view()
            .size(px(64.0))
            .rounded(px(32.0))
            .bg(rgb(0x6366f1))
            .style_with(move |s| {
                let (x, y) = pos.get();
                s.translate_x(px(x)).translate_y(px(y))
            })
            .claim_touches(TouchClaim::on_start())
            .on_responder_grant(move |_: &ResponderEvent| {
                start.replace(pos.get_untracked());
            })
            .on_responder_move(move |e: &ResponderEvent| {
                let (x, y) = start.get();
                pos.set((x + e.gesture.delta.x.0, y + e.gesture.delta.y.0));
            })
            .on_responder_release(move |e: &ResponderEvent| {
                log::info!("released at {:.0} pt/s", e.gesture.vx);
            }),
    )
}
```

```tsx
import { mount, signal, View } from "nsh";
import { PanResponder } from "nsh";

function App() {
  const pos = signal({ x: 0, y: 0 });
  let start = { x: 0, y: 0 };
  const pan = PanResponder.create({
    onStartShouldSetPanResponder: () => true,
    onPanResponderGrant: () => (start = pos()),
    onPanResponderMove: (_, g) => pos.set({ x: start.x + g.dx, y: start.y + g.dy }),
    onPanResponderRelease: (_, g) => console.log(`released at ${g.vx.toFixed(0)} pt/s`),
  });
  return (
    <View style={{ flex: 1 }}>
      <View
        {...pan.panHandlers}
        style={{
          width: 64,
          height: 64,
          borderRadius: 32,
          backgroundColor: "#6366f1",
          transform: [{ translateX: pos().x }, { translateY: pos().y }],
        }}
      />
    </View>
  );
}

mount(App);
```

<a id="callbacks"></a>

### 回呼

每個回呼都會接收觸控事件(`GestureResponderEvent`,來自 [`nsh`](#types))和手勢狀態。在 Rust 中,認領以 `.ask(|request: &ClaimRequest| …)` 作決定,呼叫 `request.claim()` 以取得觸控(沒有 `ask` 時則一律取得),而每個監聽器都會收到一個 `ResponderEvent`,其 `gesture` 就是該狀態。

```ts
type PanHandler<R = void> = (e: GestureResponderEvent, gestureState: PanResponderGestureState) => R;
```

| 回呼 | Rust | 傳回值 | 呼叫時機 |
|---|---|---|---|
| `onStartShouldSetPanResponder` | `.claim_touches(TouchClaim::on_start())` | `boolean` | 手指按下:傳回 `true` 即成為 responder。由被觸碰的檢視向外詢問。 |
| `onStartShouldSetPanResponderCapture` | `TouchClaim::on_start().capture()` | `boolean` | 同上,但在冒泡階段的詢問之前由外向內詢問(父檢視從子檢視手中取得手勢)。 |
| `onMoveShouldSetPanResponder` | `TouchClaim::on_move().ask(…)` | `boolean` | 手指移動:傳回 `true` 即成為 responder(例如只在 `Math.abs(g.dx) > 10` 之後)。 |
| `onMoveShouldSetPanResponderCapture` | `TouchClaim::on_move().capture()` | `boolean` | 同上,屬捕捉階段。 |
| `onPanResponderGrant` | `.on_responder_grant(…)` | | 此檢視現在成為 responder:手勢開始。 |
| `onPanResponderReject` | `.on_responder_reject(…)` | | 另一個檢視是 responder,且不肯放手。 |
| `onPanResponderStart` | `.on_responder_start(…)` | | 在回應期間有手指按下。 |
| `onPanResponderMove` | `.on_responder_move(…)` | | 手指移動了。 |
| `onPanResponderEnd` | `.on_responder_end(…)` | | 在回應期間有手指離開。 |
| `onPanResponderRelease` | `.on_responder_release(…)` | | 最後一隻手指離開:手勢結束。 |
| `onPanResponderTerminate` | `.on_responder_terminate(…)` | | 另一個檢視(或原生手勢,例如捲動檢視)取得了觸控。 |
| `onPanResponderTerminationRequest` | `.keep_touches(true)` 保留觸控 | `boolean` | 另一個檢視想取得觸控:傳回 `false` 以保留(預設為 `true`)。 |
| `onShouldBlockNativeResponder` | `TouchClaim::block_native(…)` | `boolean` | 回應期間是否阻止原生捲動檢視取得觸控(預設為 `true`)。 |

### PanResponderGestureState

```ts
interface PanResponderGestureState {
  stateID: number;
  moveX: number;               // latest centroid of the touches (surface points)
  moveY: number;
  x0: number;                  // the centroid when the responder was granted
  y0: number;
  dx: number;                  // distance moved since the grant
  dy: number;
  vx: number;                  // velocity, points per second
  vy: number;
  numberActiveTouches: number;
}
```

Rust:`PanGesture`,座標點為 `Point<Points>`。

| 欄位 | Rust | 說明 |
|---|---|---|
| `stateID` |  | 識別該手勢。 |
| `moveX`、`moveY` | `position` | 觸控點質心的最新位置,以 surface 的點(point)為單位。 |
| `x0`、`y0` | `origin` | 取得 responder 時質心所在的位置。 |
| `dx`、`dy` | `delta` | 自取得 responder 以來移動的距離。 |
| `vx`、`vy` | `vx`、`vy` | 速度,以每秒點數(points per second)為單位。 |
| `numberActiveTouches` | `active_touches` | 按下的手指數目。 |

<a id="inside-scroll-views"></a>

### 在捲動檢視內

當 pan responder 是 responder 時,其周圍的原生捲動檢視不會捲動(`onShouldBlockNativeResponder` 預設為 `true`)。若要讓水平滑動與垂直捲動並存,請只在水平移動時才成為 responder:

```rust
view()
    .claim_touches(TouchClaim::on_move().ask(|request: &ClaimRequest| {
        let d = request.event.gesture.delta;
        if d.x.0.abs() > 10.0 && d.x.0.abs() > d.y.0.abs() {
            request.claim();
        }
    }))
    .on_responder_move(move |e: &ResponderEvent| offset.set(e.gesture.delta.x.0))
    .on_responder_release(move |e: &ResponderEvent| {
        if e.gesture.delta.x.0.abs() > 120.0 { remove() } else { offset.set(0.0) }
    })
    .on_responder_terminate(move |_: &ResponderEvent| offset.set(0.0))
```

```tsx
const swipe = PanResponder.create({
  onMoveShouldSetPanResponder: (_, g) => Math.abs(g.dx) > 10 && Math.abs(g.dx) > Math.abs(g.dy),
  onPanResponderMove: (_, g) => offset.set(g.dx),
  onPanResponderRelease: (_, g) => (Math.abs(g.dx) > 120 ? remove() : offset.set(0)),
  onPanResponderTerminate: () => offset.set(0),
});
```

對於平台本身可識別的拖曳,`TouchClaim::on_pan(PanActivation::horizontal(10.0, 10.0))` 會在手指移動時於主執行緒上作決定。底層的 responder props(`onStartShouldSetResponder`、`onResponderGrant`、`onTouchStart` 等)也可以直接用在任何元素上;請見[元素](https://nsh.dev/translations/zh-HK/elements.md)。

## Clipboard

Clipboard API 可讀寫系統剪貼簿:所有平台均支援文字(iOS 為 `UIPasteboard`,Android 為 `ClipboardManager`,桌面平台為 `NSPasteboard`、GDK 和 Win32 剪貼簿,網頁為 `navigator.clipboard`);iOS、桌面平台和網頁支援圖片;桌面平台和 Mac Catalyst 則支援在檔案管理器中複製的檔案。Rust 程式碼呼叫 `nsh_core::prelude` 的函式(`set_clipboard`、`clipboard_string` 等);TSX 則從 `nsh` 匯入 `Clipboard`。

```rust
use nsh_core::prelude::*; // set_clipboard, clipboard_string, clipboard_image, ...
use nsh_core::element::ClipboardImage;
```

```tsx
import { Clipboard } from "nsh";
```

```ts
const Clipboard: {
  writeText(text: string): Promise<void>;
  readText(): Promise<string>;
  hasText(): Promise<boolean>;
  writeImage(src: string): Promise<void>;
  readImage(): Promise<ClipboardImage | null>;
  hasImage(): Promise<boolean>;
  readFiles(): Promise<string[]>;
};

interface ClipboardImage {
  url: string; // a URL that Image shows and fetch reads
  size: PixelSize; // in pixels
}
```

| TS | Rust(`nsh_core::prelude`) |
|---|---|
| `await Clipboard.writeText(text)` | `set_clipboard(text)` |
| `await Clipboard.readText()` | `clipboard_string().await -> String` |
| `await Clipboard.hasText()` | `clipboard_has_string().await -> bool` |
| `await Clipboard.writeImage(src)` | `set_clipboard_image(src)` |
| `await Clipboard.readImage()` | `clipboard_image().await -> Option<ClipboardImage>` |
| `await Clipboard.hasImage()` | `clipboard_has_image().await -> bool` |
| `await Clipboard.readFiles()` | `clipboard_files().await -> Vec<String>` |

Rust 的讀取函式會傳回要在 task 中 await 的結果(`spawn`,或 `async` 模組函式);`ClipboardImage` 為 `nsh_core::element::ClipboardImage`,包含 `url` 和 `size`(一個 `Size<DevicePixels>`)。

iOS、桌面平台和網頁可透過剪貼簿處理圖片。在 Android 上,剪貼簿只承載文字:`writeImage` 和
`readImage` 會以 `Error` reject,其 `code` 為 `"ERR_FEATURE_UNAVAILABLE_ON_PLATFORM"`,而 `hasImage` 會 resolve
`false`。在 Rust 中,`set_clipboard_image` 會令該處的剪貼簿維持原樣,而 `clipboard_image` 會回應 `None`。

使用者也可以自行從[可選取文字](https://nsh.dev/translations/zh-HK/elements/text.md)複製;Clipboard API 則用於從程式碼進行複製,例如「複製」按鈕。

### Clipboard.writeText

```ts
writeText(text: string): Promise<void>;
```

把 `text` 放到剪貼簿,取代原有內容。Rust:`set_clipboard(text)`。

```rust
use nsh_core::prelude::*;

fn copy_button(code: Signal<String>) -> impl IntoElement {
    let copied = signal(false);
    view()
        .role(AccessibilityRole::Button)
        .on_press(move || {
            set_clipboard(code.get());
            copied.set(true);
        })
        .child(text(move || if copied.get() { "Copied" } else { "Copy" }))
}
```

```tsx
import { signal, Text, View } from "nsh";
import { Clipboard } from "nsh";

const copied = signal(false);

<View
  onPress={async () => {
    await Clipboard.writeText(code());
    copied.set(true);
  }}
>
  <Text>{copied() ? "Copied" : "Copy"}</Text>
</View>;
```

### Clipboard.readText

```ts
readText(): Promise<string>;
```

以剪貼簿的文字 resolve,如果剪貼簿沒有文字則為 `""`。Rust:`clipboard_string()`。在 iOS 上,讀取剪貼簿可能會顯示系統的貼上通知(「App pasted from …」),而在 Android 10 及更新版本上,只有位於前景的應用程式才能讀取。請在使用者要求貼上時才讀取,不要在每個畫面都讀取。

```rust
spawn(async move {
    let pasted = clipboard_string().await;
    if !pasted.is_empty() {
        code.set(pasted.trim().to_string());
    }
});
```

```tsx
const pasted = await Clipboard.readText();
if (pasted) code.set(pasted.trim());
```

### Clipboard.hasText

```ts
hasText(): Promise<boolean>;
```

以剪貼簿是否有文字 resolve,而不會讀取內容:在 iOS 上不會出現貼上通知。Rust:`clipboard_has_string()`。可用它來決定是否顯示「貼上」按鈕。在網頁上,瀏覽器只能透過讀取來判斷,因此可能會向使用者要求權限。

```rust
let can_paste = signal(false);
spawn(async move { can_paste.set(clipboard_has_string().await) });
```

```tsx
const canPaste = signal(false);
Clipboard.hasText().then((has) => canPaste.set(has));
```

### Clipboard.readImage

```ts
readImage(): Promise<ClipboardImage | null>;
```

以剪貼簿的圖片 resolve,如果剪貼簿沒有圖片則為 `null`:例如貼上的螢幕截圖,或在瀏覽器或圖片編輯器中複製的圖片。Rust:`clipboard_image()`,沒有圖片時為 `None`。`url` 是該圖片的 URL(PNG,或剪貼簿本身的 JPEG、GIF、WebP 或 BMP),可由 [`Image`](https://nsh.dev/translations/zh-HK/elements/image.md) 以其 `src` 顯示,亦可由 `fetch` 讀取:`fetch(url)` 會提供其位元組,用於上傳。`size`(`width` 和 `height`)是解碼後以像素為單位的尺寸。與 `readText` 一樣,請在使用者要求貼上時才讀取。最大 48 MB(編碼後)的圖片會被讀取,以確保圖片能放進應用程式的記憶體;更大的圖片會以 `Error` reject,其 `code` 為 `"ERR_NSH_QUOTA_EXCEEDED"`(其訊息會說明圖片大小和上限),而 `hasImage` 會 resolve `true`。Rust:`clipboard_image()` 會對此回應 `None`。

```rust
use base64::Engine as _;
use nsh_core::{element::ClipboardImage, prelude::*};

fn attachments() -> impl IntoElement {
    let attachments = signal(Vec::<ClipboardImage>::new());
    let paste = move || {
        spawn(async move {
            if let Some(image) = clipboard_image().await {
                attachments.update(|list| list.push(image));
            }
        });
    };
    view()
        .flex_row()
        .gap_2()
        .child(text("Paste").on_press(paste))
        .child(each(
            attachments,
            |shot: &ClipboardImage| shot.url.clone(),
            |shot, _| image(move || shot.with(|s| s.url.clone())).size(px(64.0)).rounded(px(8.0)),
        ))
}

async fn upload(image: &ClipboardImage) -> Result<HttpResponse, String> {
    let (_, data) = image.url.split_once(',').ok_or("not a data: URL")?;
    let bytes = base64::engine::general_purpose::STANDARD
        .decode(data)
        .map_err(|e| e.to_string())?;
    let request = HttpRequest::post("https://example.com/upload", bytes).header("Content-Type", "image/png");
    fetch(request).await
}
```

```tsx
import { Image, signal, View } from "nsh";
import { Clipboard, type ClipboardImage } from "nsh";

const attachments = signal<ClipboardImage[]>([]);

async function paste() {
  const image = await Clipboard.readImage();
  if (image) attachments.set([...attachments(), image]);
}

async function upload(image: ClipboardImage) {
  const body = new FormData();
  body.append("file", await (await fetch(image.url)).blob(), "screenshot.png");
  await fetch("https://example.com/upload", { method: "POST", body });
}

<View class="flex-row gap-2">
  <Each of={attachments()}>
    {(image) => <Image src={image().url} style={{ width: 64, height: 64, borderRadius: 8 }} />}
  </Each>
</View>;
```

在聊天類應用程式中,使用者會把螢幕截圖貼到輸入框,這類應用程式會在輸入框取得焦點時綁定貼上快速鍵,並在有圖片時取用:

```rust
use nsh_core::keymap::When;

on_key_with("mod+v", KeyOptions { when: When::Always, ..KeyOptions::default() }, move |_| {
    spawn(async move {
        if clipboard_has_image().await {
            paste().await;
        } else {
            insert_text(&clipboard_string().await);
        }
    });
});
```

```tsx
import { keymap } from "nsh";

keymap.bind("mod+v", async () => {
  if (await Clipboard.hasImage()) return paste();
  insertText(await Clipboard.readText());
}, { when: "always" });
```

### Clipboard.hasImage

```ts
hasImage(): Promise<boolean>;
```

以剪貼簿是否有圖片 resolve,而不會讀取內容:在 iOS 上不會出現貼上通知。Rust:
`clipboard_has_image()`。在 Android 上會 resolve `false`。

### Clipboard.writeImage

```ts
writeImage(src: string): Promise<void>;
```

把 `src` 所指的圖片放到剪貼簿,取代原有內容;可以是 base64 `data:` URL,或 `readImage` 提供的 `url`。
Rust:`set_clipboard_image(src)`。在 Windows 上它接受 PNG,即瀏覽器、Office 和「剪取工具」讀取的格式。在
Android 上會以 `"ERR_FEATURE_UNAVAILABLE_ON_PLATFORM"` reject。

```rust
set_clipboard_image(image.url.clone());
```

```tsx
await Clipboard.writeImage(image.url);
```

### Clipboard.readFiles

```ts
readFiles(): Promise<string[]>;
```

以在檔案管理器(Finder、Explorer、Files)中複製的檔案和資料夾的 `file:` URL resolve,按其
順序排列,或為 `[]`:與[檔案拖放](https://nsh.dev/translations/zh-HK/elements/view.md#desktop-file-drops)提供的 URL 相同。Rust:`clipboard_files()`。
解析 URL 即可取得其路徑。

```rust
let paths: Vec<std::path::PathBuf> = clipboard_files()
    .await
    .iter()
    .filter_map(|file| url::Url::parse(file).ok()?.to_file_path().ok())
    .collect();
```

```tsx
const files = await Clipboard.readFiles();
const names = files.map((url) => decodeURIComponent(new URL(url).pathname.split("/").pop()!));
```

> [!NOTE]
> 當你的應用程式在 `nsh.json` 中聲明 `capabilities` 時,必須以 `"clipboard": true` 授予剪貼簿權限。否則 `Clipboard` 的每個函式都會以 `Error` reject,其 `code` 為 `"ERR_ACCESS_DENIED"`,`permission` 為 `"clipboard"`,`resource` 為 `""`,而且不會有任何內容到達系統剪貼簿。在 Rust 中,寫入會令剪貼簿維持原樣,讀取則回應空剪貼簿時的結果(`""`、`None`、空清單、`false`),並會記錄該次拒絕。請見[沙盒與 capabilities](https://nsh.dev/translations/zh-HK/docs/sandbox-and-capabilities.md)。
>
> ```tsx
> try {
>   await Clipboard.writeText(code());
> } catch (error) {
>   if (error.code === "ERR_ACCESS_DENIED") showHint("Copying is turned off for this app");
>   else throw error;
> }
> ```
## Picker

Picker API 會顯示系統的相片選擇器，並把使用者所選的圖片交給應用程式：iOS 上是 Photos picker（`PHPickerViewController`），Android 上是 Photo Picker，桌面平台上是開啟面板或檔案對話框，瀏覽器中則是 file input。在 iOS 和 Android 上，選擇器在應用程式以外運行，只交出使用者所選的內容，因此不需要相片庫權限。每張選取的圖片會以 `File`（TSX）或 `PickedFile`（Rust）的形式送達，其位元組保留在原生端：以 `URL.createObjectURL(file)` 在 [`Image`](https://nsh.dev/translations/zh-HK/elements/image.md) 中顯示，以 `fetch` 和 `FormData` 上傳。Rust 程式碼呼叫 `pick_images`（位於 `nsh_core::prelude`）；TSX 從 `nsh` 匯入 `Picker`。

```rust
use nsh_core::picker::{PickRequest, PickedFile};
use nsh_core::prelude::*; // pick_images
```

```tsx
import { Picker, type PickedImage } from "nsh";
```

```ts
const Picker: {
  pickImages(options?: PickImagesOptions): Promise<PickedImage[]>;
};

interface PickImagesOptions {
  multiple?: boolean; // let the user pick several (false: one)
  limit?: number; // at most this many when multiple (up to 100)
  maxDimension?: number; // scale images whose longer side is longer, in pixels
  quality?: number; // JPEG quality from 0 to 1 (0.9)
  format?: "compatible" | "current"; // "compatible": HEIC and the like as JPEG
}

interface PickedImage {
  file: File; // name, type, size in bytes and the bytes
  size: PixelSize; // upright
}
```

| TS | Rust |
|---|---|
| `await Picker.pickImages(options)` | `pick_images(request).await -> Vec<PickedFile>` |
| `multiple`, `limit` | `PickRequest::new(multiple, limit)`（`limit`：0 代表最多 100 張） |
| `maxDimension` | `request.max_dimension = Some(DevicePixels(2048))` |
| `quality` | `request.quality = Some(Percentage(0.8))` |
| `format` | `request.format = PickFormat::Current` |
| `image.file.name`, `.type`, `.size` | `file.name`, `file.mime`, `file.data.len()` |
| `image.size` | `file.size`（`Size<DevicePixels>`） |

### Picker.pickImages

```ts
pickImages(options?: PickImagesOptions): Promise<PickedImage[]>;
```

顯示選擇器，並在使用者選取後，按選取順序以所選圖片 resolve；若使用者取消，則以 `[]` resolve。Rust：`pick_images(request)`，是在任務中 await 的結果（`spawn`，或 `async` 模組函式）。`pickImages` 永不拋出例外：超出範圍的選項（`limit: 0`，即不在 0 至 1 之間的 `quality`）會以 `RangeError` reject promise，而應用程式的 capabilities 未授權的選取會以 `ERR_ACCESS_DENIED` reject（見下文）。取消是使用者的回應，並非失敗：會以 `[]` resolve。

圖片送達時已可直接傳送：

- **所有瀏覽器和伺服器都支援的格式。** JPEG、PNG、GIF 和 WebP 會保持原樣；以其他格式儲存的相片（iPhone 上的 HEIC、HEIF、TIFF）會轉為 JPEG。`format: "current"` 則保留儲存時的格式。
- **在原生端縮小。** 使用 `maxDimension` 時，較長邊超出該值的圖片會縮小至符合該值、轉正方向，然後重新編碼：PNG 仍為 PNG，其餘轉為品質 `quality` 的 JPEG。手機相片有 12 至 48 百萬像素；上傳前縮小至 2048 像素，可使檔案只有數百 KB。`quality` 同樣會重新編碼 JPEG。
- **顯示的內容。** `size` 是圖片顯示時的像素尺寸，已套用相機方向。NSH 重新編碼的圖片不帶任何中繼資料（包括位置）；保持原樣的圖片則保留其自身的中繼資料。

在 Windows、Linux 和 GPUI 視窗中，對話框會提供請求所接受的格式（使用 `"compatible"` 時：JPEG、PNG、GIF 和 WebP），檔案以其儲存時的原樣和原始尺寸送達。

聊天輸入框附加相片、在撰寫訊息期間顯示相片，並在訊息送出時上傳：

```rust
use nsh_core::geometry::{DevicePixels, Percentage};
use nsh_core::picker::{PickRequest, PickedFile};
use nsh_core::prelude::*;

fn attach_button(attached: Signal<Option<PickedFile>>) -> impl IntoElement {
    view()
        .role(AccessibilityRole::Button)
        .accessibility_label("Choose a photo")
        .on_press(move || {
            let mut request = PickRequest::new(false, 1);
            request.max_dimension = Some(DevicePixels(2048));
            request.quality = Some(Percentage(0.85));
            let picked = pick_images(request);
            spawn(async move {
                if let Some(file) = picked.await.into_iter().next() {
                    attached.set(Some(file));
                }
            });
        })
        .child(text("Photo"))
}

async fn upload(file: &PickedFile) -> Result<HttpResponse, String> {
    let request = HttpRequest::post("https://example.com/upload", file.data.to_vec())
        .header("Content-Type", file.mime.as_str());
    fetch(request).await
}
```

```tsx
import { Image, signal, Text, View } from "nsh";
import { Picker } from "nsh";

const attached = signal<{ file: File; src: string } | null>(null);

async function choosePhoto() {
  const [picked] = await Picker.pickImages({ maxDimension: 2048, quality: 0.85 });
  if (picked) attached.set({ file: picked.file, src: URL.createObjectURL(picked.file) });
}

async function send(text: string) {
  const body = new FormData();
  body.append("text", text);
  if (attached()) body.append("photo", attached()!.file);
  await fetch("https://example.com/messages", { method: "POST", body });
  if (attached()) URL.revokeObjectURL(attached()!.src);
  attached.set(null);
}

<View class="flex-row items-center gap-2">
  <View role="button" accessibilityLabel="Choose a photo" onPress={choosePhoto}>
    <Text>Photo</Text>
  </View>
  <If when={attached()}>
    <Image src={attached()!.src} style={{ width: 48, height: 48, borderRadius: 8 }} />
  </If>
</View>;
```

一次選取多張相片：

```rust
let picked = pick_images(PickRequest::new(true, 9));
spawn(async move {
    let files = picked.await;
    photos.update(|list| list.extend(files));
});
```

```tsx
const images = await Picker.pickImages({ multiple: true, limit: 9, maxDimension: 2048 });
photos.set([...photos(), ...images.map((image) => URL.createObjectURL(image.file))]);
```

<a id="embedding-apps"></a>

### 嵌入應用程式

嵌入 NSH 並自備選擇器（例如附有應用程式本身相簿的選擇器）的應用程式，可以自行回應選取：在 Rust 中，把 `PickerBackend`（`nsh_core::picker`）交給引擎（`EngineConfig::picker`，在 GPUI 和 GPUI Kit 應用程式中則為 `AppView::set_picker`），它會接收每次選取的 `PickRequest` 並回傳檔案，系統的選擇器則不會顯示。它回傳的檔案會與平台提供的一樣經過處理：按請求的上限裁剪，並從位元組中讀取其大小、類型和副檔名。

<a id="tests"></a>

### 測試

`nsh test` 會在 headless 執行中回應選取：`app.onPick(handler)` 回傳使用者所選的檔案（`{ name, type, data }`），沒有 handler 時，選取會被取消。參閱 [測試](https://nsh.dev/translations/zh-HK/docs/testing-and-trace.md)。

> [!NOTE]
> 當應用程式在 `nsh.json` 中宣告 `capabilities` 時，必須以 `"picker": true` 授予選擇器權限。否則 `pickImages` 不會顯示任何內容，並以 `Error` reject，其 `code` 為 `"ERR_ACCESS_DENIED"`、`permission` `"picker"` 及 `resource` `""`。在 Rust 中，`pick_images` 回應沒有檔案，並會記錄該次拒絕。參閱 [沙盒與權限](https://nsh.dev/translations/zh-HK/docs/sandbox-and-capabilities.md)。

## Speech

Speech API 把使用者所說的話轉為文字（語音輸入）：應用程式開始一個 session，平台聆聽麥克風，而逐字稿會隨內容增加而陸續送達。macOS 和 iOS 使用系統的 Speech framework 進行辨識；系統會向使用者詢問一次麥克風和語音辨識的權限。在其他平台上，session 會立即以錯誤 `"unavailable"` 結束，因此在顯示麥克風按鈕前，請先檢查 `Speech.available()`。Rust 程式碼呼叫 `recognize_speech`（位於 `nsh_core::prelude`）；TSX 從 `nsh` 匯入 `Speech`。

```rust
use nsh_core::speech::{SpeechEvent, SpeechRequest};
```

```tsx
import { Speech, type SpeechSession } from "nsh";
```

```ts
const Speech: {
  available(): Promise<boolean>;
  start(options?: SpeechOptions): SpeechSession;
};

interface SpeechOptions {
  /** The language spoken, a BCP 47 tag ("en-US", "zh-CN"); the user's when left out. */
  lang?: string;
  /** Results while the user speaks (true, the default), or only the final one. */
  interim?: boolean;
}

interface SpeechSession {
  on(event: "start", listener: () => void): () => void;
  on(event: "result", listener: (result: SpeechResult) => void): () => void;
  on(event: "end", listener: (end: SpeechEnd) => void): () => void;
  /** The whole transcript so far. */
  readonly text: string;
  readonly ended: boolean;
  stop(): void;
  abort(): void;
}

interface SpeechResult { text: string; isFinal: boolean }
interface SpeechEnd { text: string; error?: SpeechErrorCode }

type SpeechErrorCode =
  | "not-allowed" | "no-speech" | "audio-capture" | "network"
  | "language-not-supported" | "unavailable" | "failed";
```

| TSX | Rust |
|---|---|
| `await Speech.available()` | `speech_available().await -> bool` |
| `Speech.start(options)` | `recognize_speech(request) -> SpeechRecognition` |
| `session.on("result", ...)` | `recognition.next().await -> Option<SpeechEvent>` |
| `session.stop()` / `abort()` | `recognition.stop()` / `abort()`（drop 時會中止） |

### Speech.start

開始一個 session 並立即回傳。session 會按以下順序回報：麥克風開啟且辨識器開始聆聽時回報 `start`；逐字稿每次變動時回報 `result`（`text` 永遠是到目前為止的完整逐字稿，因此應用程式可直接原樣顯示）；最後回報一次 `end`，之後不會再有任何事件。`stop()` 會停止聆聽，並讓辨識器完成已聽到的內容：先回報帶有 `isFinal: true` 的最後結果，然後是 `end`。`abort()` 會立即結束 session，不產生錯誤，並丟棄尚未回報的內容。同一時間只會運行一個 session：開始另一個 session 會中止正在運行的那個。

`end` 帶有最後的逐字稿，而當 session 失敗時帶有 `error`，其命名與 Web Speech API 一致：

| `error` | 情況 |
|---|---|
| `"not-allowed"` | 使用者或系統不允許使用麥克風或語音辨識。 |
| `"no-speech"` | 沒有說話。 |
| `"audio-capture"` | 無法使用麥克風。 |
| `"network"` | 辨識器需要網絡但無法連線。 |
| `"language-not-supported"` | 辨識器不認識 `lang`。 |
| `"unavailable"` | 此平台不辨識語音，或目前無法辨識。 |
| `"failed"` | 辨識器因其他原因失敗。 |

選項類型錯誤時，`start` 會拋出 `TypeError`；`lang` 不是語言標籤時，則會拋出 `RangeError`。

```tsx
import { signal, Speech, type SpeechSession } from "nsh";

const transcript = signal("");
let session: SpeechSession | null = null;

function toggle() {
  if (session && !session.ended) {
    session.stop();
    return;
  }
  session = Speech.start({ lang: "en-US" });
  session.on("result", ({ text }) => transcript.set(text));
  session.on("end", ({ text, error }) => {
    if (error) console.warn("dictation ended:", error);
    else transcript.set(text);
  });
}
```

```rust
use nsh_core::prelude::*;
use nsh_core::speech::{SpeechEvent, SpeechRequest};

async fn dictate() -> String {
    let mut recognition = recognize_speech(SpeechRequest::default());
    let mut text = String::new();
    while let Some(event) = recognition.next().await {
        match event {
            SpeechEvent::Result { text: heard, .. } => text = heard,
            SpeechEvent::End { .. } => break,
            _ => {}
        }
    }
    text
}
```

### Speech.available

在 session 可以運行的地方 resolve 為 `true`：即 macOS 和 iOS，以及嵌入應用程式自行辨識語音的任何地方。

### Info.plist

在 macOS 和 iOS 上，系統會顯示應用程式需要麥克風和語音辨識的原因。當應用程式可使用語音時（其 `capabilities` 授予 `speech`，或沒有宣告任何權限），`nsh package` 和 `nsh run` 會把 `NSMicrophoneUsageDescription` 和 `NSSpeechRecognitionUsageDescription` 寫入應用程式的 Info.plist。

<a id="embedding-apps-1"></a>

### 嵌入應用程式

嵌入 NSH 並以自己的方式辨識語音（自家引擎或服務）的應用程式，可以自行回應 session：在 Rust 中，把 `SpeechBackend`（`nsh_core::speech`）交給引擎（`EngineConfig::speech`），它會接收每個 session 的 `SpeechRequest`，以及可從任何執行緒回報的 `SpeechSink`，並回傳核心用來停止和中止該 session 的 `SpeechControl`。此時不會使用平台的辨識器。

<a id="tests-1"></a>

### 測試

`nsh test` 會在 headless 執行中以模擬辨識器運行 session：session 會立即開始，`app.hear(text)` 把 `text` 回報為到目前為止的逐字稿，`app.endSpeech(error?)` 會結束 session，而 `app.speechRequests()` 會列出應用程式已開始的 session。參閱 [測試](https://nsh.dev/translations/zh-HK/docs/testing-and-trace.md)。

> [!NOTE]
> 當應用程式在 `nsh.json` 中宣告 `capabilities` 時，必須以 `"speech": true` 授予語音權限。否則 `Speech.start` 會拋出 `Error`，其 `code` 為 `"ERR_ACCESS_DENIED"`、`permission` `"speech"` 及 `resource` `""`。在 Rust 中，session 會以 `not-allowed` 結束，並會記錄該次拒絕。參閱 [沙盒與權限](https://nsh.dev/translations/zh-HK/docs/sandbox-and-capabilities.md)。

## Storage

Storage API 把應用程式的資料保存在裝置上：以字串鍵儲存字串，下次啟動應用程式時仍然存在。可用於設定、草稿、使用者最後開啟的分頁、伺服器所傳資料的快取，以及任何應在應用程式行程結束後仍須保留的狀態。Rust 程式碼使用 `nsh_core::storage::Storage`，TSX 從 `nsh` 匯入 `Storage`；兩者讀寫相同的項目，因此同時有 Rust 入口和 TSX 入口的應用程式看到的是同一組資料。

```rust
use nsh_core::storage::Storage;
```

```tsx
import { Storage } from "nsh";
```

```ts
const Storage: {
  getItem(key: string): Promise<string | null>;
  setItem(key: string, value: string): Promise<void>;
  removeItem(key: string): Promise<void>;
  keys(): Promise<string[]>;
  clear(): Promise<void>;
};
```

| TS | Rust（`nsh_core::storage::Storage`） |
|---|---|
| `await Storage.getItem(key)` | `Storage::get_item(key) -> Option<String>` |
| `await Storage.setItem(key, value)` | `Storage::set_item(key, value) -> Result<(), StorageFull>` |
| `await Storage.removeItem(key)` | `Storage::remove_item(key)` |
| `await Storage.keys()` | `Storage::get_all_keys() -> Vec<String>` |
| `await Storage.clear()` | `Storage::clear()` |

Rust 函式是同步的，因為各項目都在記憶體中：請在元件運行的地方呼叫（元件主體、handler 和任務）。

每個應用程式都有自己的儲存空間，鍵和值合共最多 5 MB。呼叫會按應用程式發出的順序執行，因此 `setItem` 之後的 `getItem` 無須等待寫入就能看到新值。讀取來自記憶體；寫入會在背景分批寫入磁碟。

| 平台 | 資料存放位置 |
|---|---|
| iOS | 應用程式容器中的 `Library/Application Support/nsh/<app id>/storage.json` |
| Android | 應用程式私有目錄中的 `files/nsh/<app id>/storage.json` |
| macOS | `~/Library/Application Support/<app id>/storage.json` |
| Windows | `%APPDATA%\<app id>\storage.json` |
| Linux | `$XDG_DATA_HOME/<app id>/storage.json`（`~/.local/share/…`） |
| Web | 瀏覽器的 `localStorage`，鍵以應用程式 id 命名（`nsh:com.example.todo:…`） |

已損毀的 `storage.json` 會在其旁邊保留為 `storage.json.corrupt`，而應用程式會以空的儲存空間啟動；無法讀取的檔案則會讓應用程式的變更只留在記憶體中，因此該檔案永遠不會被取代。

應用程式 id 是 `nsh.json` 中的 `id`，並隨應用程式一起傳遞：`nsh build` 和 `nsh package` 會把它寫在 bundle 旁邊，開發伺服器會傳送它，已簽署的套件則帶有其發佈金鑰的憑證所保證的 id。因此，無論在何處運行，每個應用程式都無須任何設定就能保存自己的資料；沒有 id 的應用程式會把資料保存在 `default` 之下。嵌入應用程式可以選擇其他位置（參閱 [在 iOS 上嵌入](https://nsh.dev/translations/zh-HK/docs/ios.md) 和 [在 Android 上嵌入](https://nsh.dev/translations/zh-HK/docs/android.md)）。

### Storage.getItem

```ts
getItem(key: string): Promise<string | null>;
```

以 `key` 的值 resolve，沒有值時則為 `null`。Rust：`Storage::get_item(key)`，沒有值時為 `None`。

```rust
let theme = Storage::get_item("theme").unwrap_or_else(|| "system".into());
```

```tsx
const theme = (await Storage.getItem("theme")) ?? "system";
```

### Storage.setItem

```ts
setItem(key: string, value: string): Promise<void>;
```

把 `key` 設為 `value`，取代原有的內容。當儲存空間會超過 5 MB 時，以 `Error` reject，其 `code` 為 `"ERR_NSH_QUOTA_EXCEEDED"`；此時該值不會被保存。Rust：`Storage::set_item(key, value)`，此時會回傳 `Err(StorageFull)`。值是字串：結構化資料請以 JSON 保存（Rust 用 `serde_json::to_string`，TSX 用 `JSON.stringify`），再讀回來（`serde_json::from_str`、`JSON.parse`）。

```rust
let draft = serde_json::json!({ "title": title.get(), "body": body.get() });
if let Err(full) = Storage::set_item("draft", draft.to_string()) {
    log::warn!("{full}");
}
```

```tsx
Storage.setItem("draft", JSON.stringify({ title: title(), body: body() }));
```

### Storage.removeItem

```ts
removeItem(key: string): Promise<void>;
```

移除 `key` 及其值。移除沒有值的鍵不會有任何作用。Rust：`Storage::remove_item(key)`。

### Storage.keys

```ts
keys(): Promise<string[]>;
```

按順序以所有鍵 resolve。Rust：`Storage::get_all_keys()`。

### Storage.clear

```ts
clear(): Promise<void>;
```

移除應用程式的所有鍵，例如在使用者登出時。Rust：`Storage::clear()`。

<a id="keeping-a-signal-on-the-device"></a>

### 在裝置上保存 signal

應用程式啟動時讀取已儲存的值，之後每當 signal 改變就寫回去：

```rust
use nsh_core::{storage::Storage, prelude::*};

#[component]
pub fn App() -> impl IntoElement {
    let saved = Storage::get_item("count").and_then(|n| n.parse().ok());
    let count = signal(saved.unwrap_or(0_u32));
    effect(move || {
        if let Err(full) = Storage::set_item("count", count.get().to_string()) {
            log::warn!("{full}");
        }
    });
    view()
        .on_press(move || count.update(|n| *n += 1))
        .child(text(move || format!("Pressed {} times", count.get())))
}
```

```tsx
import { effect, mount, signal, Text, View } from "nsh";
import { Storage } from "nsh";

const count = signal(0);
const loaded = signal(false);
Storage.getItem("count").then((saved) => {
  count.set(Number(saved ?? 0));
  loaded.set(true);
});
effect(() => {
  if (loaded()) Storage.setItem("count", String(count()));
});

mount(() => (
  <View onPress={() => count.update((n) => n + 1)}>
    <Text>Pressed {count()} times</Text>
  </View>
));
```

## host

`host` 用於連接嵌入 NSH 的應用程式：即 `NshAppView` / `NshView` 周圍的原生應用程式，在網頁上則是運行 player 的頁面。應用程式以名稱和 JSON 參數呼叫 host 的函式，並監聽 host 傳送的事件。

```ts
import { host } from "nsh";
```

```ts
const host: {
  readonly params: { readonly [key: string]: unknown };
  post(name: string, data?: unknown): void;
  available(): boolean;
  call(name: string, args?: unknown, options?: { timeout?: number }): Promise<unknown>;
  on(event: string, listener: (payload: unknown) => void): () => void;
};
```

### host.available

```ts
available(): boolean;
```

表示平台是否有會回應呼叫的 host。在網頁上，當頁面為 player 提供 host 時為 `true`（[網頁上的 host](#the-host-on-the-web)）。

```tsx
import { Text } from "nsh";
import { host } from "nsh";

<Text>{host.available() ? "Embedded" : "Standalone"}</Text>;
```

### host.call

```ts
call(name: string, args?: unknown, options?: { timeout?: number }): Promise<unknown>;
```

以 `args`（任何 JSON 值；沒有時為 `null`）呼叫 host 的函式 `name`，並以其回傳值 resolve。`host.call` 永不拋出例外：所有失敗都會 reject promise，並附上 `Error`，其 `code` 說明發生了什麼事（訊息供人閱讀，可能會更改）：

| `code` | 情況 |
|---|---|
| `ERR_NSH_HOST_ERROR` | host 的函式失敗，或 host 沒有函式 `name`；訊息由 host 提供 |
| `ERR_ACCESS_DENIED` | `name` 不在應用程式的 [`host` 權限](#capability) 之內；`permission` 為 `"host"`，`resource` 為該名稱 |
| `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM` | 平台沒有 host：`host.available()` 為 `false` |
| `ETIMEDOUT` | host 未在時限內回應 |
| `ERR_NSH_QUOTA_EXCEEDED` | 已有 1024 個呼叫在等待嵌入應用程式的回應 |

無效的名稱（由 1 至 128 個 `A`–`Z`、`a`–`z`、`0`–`9`、`_`、`.`、`:`、`-` 組成，且不含 `..`）會以 `TypeError` reject。

呼叫會等待 host 回應 30 秒，之後以 `ETIMEDOUT` reject；較遲到達的回應會被丟棄。`options.timeout` 以毫秒設定等待時間，`0` 表示不設限；大到時鐘永遠無法達到的逾時值（約 1e22 ms）同樣表示無限等待，而負數、`NaN` 或無限大的值，則會以 `RangeError` reject 該呼叫。時間以應用程式的時間為準，與其計時器相同：在使用手動時鐘的 `nsh test` 中，時間隨時鐘移動。

```ts
const user = (await host.call("getUserInfo")) as { name: string };
const quote = await host.call("quote.refresh", { symbol: "AAPL" }, { timeout: 5000 }).catch((error) => {
  if (error.code === "ETIMEDOUT" || error.code === "ERR_FEATURE_UNAVAILABLE_ON_PLATFORM") return null;
  throw error;
});
```

### host.on

```ts
on(event: string, listener: (payload: unknown) => void): () => void;
```

平台事件會以 host 傳送的每個 `event` 的 payload 呼叫 `listener`，直到呼叫 `host.on` 所回傳的函式為止；在元件渲染期間加入的 listener 會隨該元件一起停止。host 在應用程式啟動前傳送的事件，會在應用程式啟動後送達，因此在應用程式頂層註冊的 listener 都能收到；在 listener 註冊之前傳送的事件，則不會重播給它。應用程式啟動時的狀態請透過 `host.call` 讀取，其後的變化則以 `host.on` 跟進。

```ts
const stop = host.on("loginChanged", (payload) => console.log(payload));
// later
stop();
```
<a id="capability"></a>

### 權限

應用程式呼叫的每個函式，都必須列在 `nsh.json` 的 `capabilities.host` 中：可以是完整名稱、以 `*` 結尾的前綴，或以 `true` 代表全部。每個應用程式都適用此規則，即使沒有其他 `capabilities` 亦然：host 的函式預設絕不會授予。未獲授權的呼叫會以 `ERR_ACCESS_DENIED` 拒絕並記錄；對嵌入應用程式以此類名稱發出 `host.post` 會拋出相同的錯誤；而建置匯入 `host`（來自 `nsh`）但沒有 `capabilities.host` 的應用程式時會發出警告。事件無需授權。參見[沙箱與權限](https://nsh.dev/translations/zh-HK/docs/sandbox-and-capabilities.md#host)。

```json
{ "capabilities": { "host": ["getUserInfo", "bridge.*"] } }
```

<a id="the-host-on-the-web"></a>

### 網頁上的 host

在網頁上，頁面本身就是 host。`createPlayer` 的 `host` 選項負責回應呼叫，`player.hostEvent(event, payload)` 則發送事件：

```js
const player = await createPlayer(container, {
  wasm: "./nsh.wasm",
  host: { call: (name, args) => (name === "getUserInfo" ? { name: "Ada" } : Promise.reject(new Error(name))) },
});
player.hostEvent("loginChanged", { signedIn: true });
```

已發佈的應用程式在 `nsh.json` 中指定其 host：`web.host` 是應用程式目錄內的 JS 模組（相對於 `nsh.json`），匯出 `createHost(player)`，並回傳帶有 `call` 的物件。`nsh package --target web` 會將它隨網站一併發佈，`nsh run --target web` 會在開發頁面上執行它，`nsh test` 則會為其測試所開啟的應用程式執行它。`web.connect` 列出 host 自身請求可連線的 host，頁面的 Content-Security-Policy 會連同應用程式的 `capabilities.network` 一併允許這些連線。參見 [Web](https://nsh.dev/translations/zh-HK/docs/web.md#the-hosts-functions)。

```json
{ "web": { "host": "./host.js", "connect": ["api.example.com"] } }
```

### host.params

嵌入應用程式在啟動你的應用程式之前設定的參數，可在程式碼第一行同步讀取；若未設定任何參數則為 `{}`。這些參數在你的應用程式執行期間保持不變：之後的請求會以事件形式送達。

```tsx
const conversation = host.params.conversation as string | undefined;
mount(() => (conversation ? <Conversation id={conversation} /> : <Inbox />));
```

<a id="native-sdk-channel"></a>

### Native SDK 通道

Native SDK 的 `send` 會保留事件直至應用程式開始監聽，最多 256 個。若最後一個監聽器在派發期間停止，其餘事件會依次序繼續等待；在關閉期間發送的事件會等待下一個載入的應用程式。`host.post(name, data)` 將資料傳送給 Native SDK 處理器，處理器的 `isPost` 用來區分 post 與 call。

<a id="the-embedding-apps-side"></a>

### 嵌入應用程式一側

每個 view 為其載入的所有應用程式保留一條通道。請在載入前設定參數，可隨時從任何執行緒發送事件，並以名稱註冊處理器。呼叫與 post 需要應用程式的 `capabilities.host` 授權。處理器只會回應一次，可即時或稍後回應；若回應始終沒有出現，該次呼叫便會失敗。

```rust
// Any platform's AppView (GPUI, macOS, Windows, Linux, iOS): its HostChannel.
use serde_json::json;

let host = view.host();
let mut params = serde_json::Map::new();
params.insert("conversation".into(), json!("42"));
host.set_params(params);
// Runs on the engine thread: answer at once, or move the call and answer later from any thread.
host.handle("user.profile", |call| {
    let id = call.data()["id"].clone();
    call.resolve(json!({ "id": id, "name": "Ada" }));
});
host.send("conversation.open", json!({ "id": "43" })).unwrap();
```

```swift
appView.params = ["conversation": "42"]
try appView.loadBundle(at: bundleURL)

// On the main thread; return the answer, or throw to reject the call.
appView.handle("user.profile") { data in
    let id = (data as? [String: Any])?["id"]
    return ["id": id ?? NSNull(), "name": try await profiles.name(of: id)]
}
appView.handle("analytics") { data, _ in analytics.log(data) }
appView.send("conversation.open", ["id": "43"])
```

```java
nsh.setParams(Map.of("conversation", "42"));
nsh.loadAssetBundle("nsh");

// On the UI thread; answer now or later, from any thread.
nsh.handle("user.profile", (data, reply) -> {
    Object id = ((Map<?, ?>) data).get("id");
    profiles.load(id, profile -> reply.resolve(Map.of("id", id, "name", profile.name)),
            error -> reply.reject(error.getMessage()));
});
nsh.send("conversation.open", Map.of("id", "43"));
```

參見 [Embedding on iOS](https://nsh.dev/translations/zh-HK/docs/ios.md#talking-to-the-app)、[Embedding on Android](https://nsh.dev/translations/zh-HK/docs/android.md#talking-to-the-app) 及 [GPUI](https://nsh.dev/translations/zh-HK/docs/gpui.md)。

> [!NOTE]
> Native SDK 的 call 與 post 和平台呼叫一樣，使用同一個 [`host` 權限](https://nsh.dev/translations/zh-HK/docs/sandbox-and-capabilities.md#host)。Rust 應用程式可透過其註冊的函式（[`nativeModule`](#nativemodule) 及 Rust 匯入）直接存取嵌入應用程式的 Rust。

## keymap

keymap 用來綁定應用程式的鍵盤快速鍵：一個按鍵組合（例如 `"mod+k"`）及其執行的函式。Rust 程式碼從 `nsh_core::prelude` 呼叫 `on_key`、`on_key_with` 及 `key_label`；TSX 則從 `nsh` 匯入 `keymap`。兩者接受相同的按鍵組合，並遵循相同的規則。

```rust
use nsh_core::prelude::*; // on_key, on_key_with, KeyOptions, key_label
```

```tsx
import { keymap } from "nsh";
```

```ts
const keymap: {
  bind(keystroke: string, handler: (e: { keystroke: string }) => void, options?: KeyBindingOptions): () => void;
  bind(bindings: Record<string, (e: { keystroke: string }) => void>, options?: KeyBindingOptions): () => void;
  label(keystroke: string): string;
};

interface KeyBindingOptions {
  when?: "always" | "notEditing";
  repeat?: boolean;
}
```

| TS | Rust |
|---|---|
| `keymap.bind(keystroke, handler)` | `on_key(keystroke, handler)` |
| `keymap.bind(keystroke, handler, { when, repeat })` | `on_key_with(keystroke, KeyOptions { when, repeat }, handler)`；`when` 為 `nsh_core::keymap::When`（`Auto`、`Always`、`NotEditing`） |
| `keymap.bind({ … })` | 每個按鍵組合一個 `on_key` |
| `keymap.label(keystroke)` | `key_label(keystroke)` |

Rust 綁定的存續時間與建立它的元件（擁有者）相同；處理器收到的是按原樣書寫的按鍵組合。

快速鍵可用於 macOS、Windows、Linux 及瀏覽器（瀏覽器自身的按鍵，例如 Ctrl+T 或 Cmd+W，永遠不會傳到頁面），亦可在接上實體鍵盤的 iOS 及 Android 上使用：包括 iPad 的鍵盤、執行 iPad 應用程式的 Mac 的鍵盤、平板電腦或 Chromebook 的鍵盤，以及模擬器的鍵盤。同一份程式碼可在各處執行；沒有鍵盤時，任何按鍵都不會觸發綁定。

### keymap.bind

```ts
bind(keystroke: string, handler: (e: { keystroke: string }) => void, options?: KeyBindingOptions): () => void;
```

當按下 `keystroke` 時執行 `handler`，並回傳一個可移除該綁定的函式。處理器會收到 `{ keystroke }`：即你所寫的按鍵組合，大小寫保持原樣。Rust：`on_key(keystroke, handler)`，其處理器收到的是按鍵組合本身。

```rust
use nsh_core::prelude::*;

#[component]
pub fn App() -> impl IntoElement {
    let open = signal(false);
    on_key("mod+k", move |_| open.set(true));
    on_key("escape", move |_| open.set(false));
    text(move || if open.get() { "Search…" } else { "Press ⌘K" })
}
```

```tsx
import { mount, signal, Text } from "nsh";
import { keymap } from "nsh";

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

mount(() => <Text>{open() ? "Search…" : "Press ⌘K"}</Text>);
```

傳入物件即可一次綁定多個按鍵組合；回傳的函式會移除全部綁定。在 Rust 中，每個按鍵組合各為一個 `on_key`：

```rust
on_key("mod+s", move |_| save());
on_key("mod+shift+p", move |_| open_palette());
on_key("f5", move |_| refresh());
```

```tsx
const unbind = keymap.bind({
  "mod+s": save,
  "mod+shift+p": openPalette,
  f5: refresh,
});
```

在元件渲染期間建立的綁定會隨該元件一併移除，因此畫面的快速鍵與畫面同生共滅：

```rust
#[component]
fn Editor() -> impl IntoElement {
    on_key("mod+enter", move |_| send());
    input().multiline(true)
}
```

```tsx
function Editor() {
  keymap.bind("mod+enter", send);
  return <Input multiline />;
}
```

使用開發伺服器時，編輯模組會先移除其頂層程式碼所建立的綁定，然後才再次執行，因此熱重載不會留下過時的快速鍵。

### keymap.label

```ts
label(keystroke: string): string;
```

回傳 `keystroke` 在應用程式執行環境中，選單與提示所顯示的樣式：在 macOS 及 iOS（以及 Mac、iPhone 或 iPad 上的瀏覽器）為 `"⇧⌘L"`，在 Windows、Linux 及其他瀏覽器為 `"Ctrl+Shift+L"`，對象為 `"mod+shift+l"`。Rust：`key_label(keystroke)`。可用它以實際有效的按鍵顯示應用程式的快速鍵：

```rust
text(format!("Search {}", key_label("mod+k")))
```

```tsx
<Text>Search {keymap.label("mod+k")}</Text>
```

對於無效的按鍵組合，它會拋出 `TypeError`，與 `bind` 相同；`key_label` 及 `on_key` 會以相同的訊息 panic。

<a id="keystrokes"></a>

### 按鍵組合

按鍵組合由修飾鍵和一個按鍵以 `+` 連接而成：`"mod+k"`、`"ctrl+shift+p"`、`"alt+enter"`、`"escape"`、`"f5"`、`"cmd+-"`（Command 加減號）。

| 修飾鍵 | macOS | Windows | Linux |
|---|---|---|---|
| `mod`（亦可寫作 `cmdorctrl`） | Command (⌘) | Ctrl | Ctrl |
| `cmd`（亦可寫作 `command`、`meta`、`super`、`win`） | Command (⌘) | Windows 鍵 | Super |
| `ctrl`（亦可寫作 `control`） | Control (⌃) | Ctrl | Ctrl |
| `alt`（亦可寫作 `option`、`opt`） | Option (⌥) | Alt | Alt |
| `shift` | Shift (⇧) | Shift | Shift |

iOS 沿用 macOS 的規則（`mod` 即 Command），Android 則沿用 Windows 與 Linux 的規則（`mod` 即 Ctrl，`cmd` 為 Meta 鍵）。在瀏覽器中，按鍵依瀏覽器所在的平台而定：`mod` 在 Mac、iPhone 或 iPad 上是 Command，在其他平台上則是 Ctrl。

應用程式自身的快速鍵請使用 `mod`：這是各平台應用程式用於此類快速鍵的按鍵（macOS 上為 ⌘K，Windows 與 Linux 上為 Ctrl+K），因此一種寫法即可通用。Windows 鍵與 Super 大多由系統使用，所以在 macOS 以外，`cmd` 很少是合適的選擇。

按鍵可以是字母、數字或標點符號鍵，以其在美式鍵盤上不按 Shift 時所輸入的字元書寫（`k`、`1`、`,`、`/`、`[`；`"shift+/"` 代表 `?`，`"shift+k"` 代表 Shift+K），或者是一個名稱：`plus`（`=` / `+` 鍵，亦可寫作 `=`）、`minus`（亦可寫作 `-`）、`space`、`enter`（`return`）、`escape`（`esc`）、`tab`、`backspace`、`delete`（`del`）、`insert`、方向鍵 `up`、`down`、`left`、`right`（`arrowup`，…）、`home`、`end`、`pageup`、`pagedown`、`f1` 至 `f24`。大小寫無關：`"Mod+Shift+K"` 即 `"mod+shift+k"`，大寫字母與小寫字母是同一個鍵，並不代表 Shift（`"mod+K"` 即 `"mod+k"`）。建議使用小寫，本文件各頁亦採用此寫法。`"mod-k"` 與 `"mod++"` 屬於錯誤寫法，其錯誤訊息會提示正確的寫法。`nsh/desktop` 中的選單快速鍵接受相同的按鍵組合。

快速鍵適用於任何鍵盤配置：在西里爾字母或希臘字母配置下，`mod+k` 是在美式配置下輸入 K 的那個鍵，與選單快速鍵相同。

對於無效的按鍵組合（`keymap.bind: invalid keystroke "cmd+kk": unknown key "kk"`），`bind` 會拋出 `TypeError` 並說明問題所在；同時它會拒絕系統自行保留的按鍵組合，因此應用程式絕不會破壞這些按鍵：

| 平台 | 保留按鍵 |
|---|---|
| macOS、iOS | Cmd+Q（結束）、Cmd+H（隱藏）、Option+Cmd+H（隱藏其他）、Cmd+M（最小化）、Ctrl+Cmd+F（全螢幕） |
| Linux、Windows、Android | Ctrl+Q（結束）、Alt+F4（關閉）、F11（全螢幕） |
| Web | 無（瀏覽器保留自己的按鍵） |

<a id="while-typing"></a>

### 輸入文字時

取得鍵盤焦點的文字輸入框需要使用它的按鍵：輸入文字、移動插入點、刪除，以及全選、剪下、複製、貼上、復原和重做。因此預設情況下，當文字輸入框（或 web view）取得焦點時，若按鍵組合屬於以下其中一種，綁定便不會觸發：單獨的按鍵、只帶 Shift 或 Option 的按鍵、方向鍵、Home、End、Page Up / Down、Return、Tab、Escape、Backspace、Delete，以及 `mod+a`、`mod+c`、`mod+v`、`mod+x`、`mod+z`。其他帶有 Command 或 Control 的按鍵組合及功能鍵，無論焦點在哪裡都會觸發。

`when` 可改變此行為（Rust：`KeyOptions { when: When::Always, .. }`）：

| `when` | 觸發時機 |
|---|---|
| （預設） | 如上所述 |
| `"always"` | 文字輸入框中亦會觸發，且早於輸入框接收按鍵 |
| `"notEditing"` | 文字輸入框取得焦點時從不觸發 |

```rust
use nsh_core::keymap::When;

// Sends from the message field too.
on_key_with("mod+enter", KeyOptions { when: When::Always, ..KeyOptions::default() }, move |_| send());
// Only when the user is not typing.
on_key_with("mod+e", KeyOptions { when: When::NotEditing, ..KeyOptions::default() }, move |_| archive());
```

```tsx
// Sends from the message field too.
keymap.bind("mod+enter", send, { when: "always" });
// Only when the user is not typing.
keymap.bind("mod+e", archive, { when: "notEditing" });
```

在 iOS 及 Android 上，輸入法正在組字所用的按鍵（選擇候選字、確認輸入）仍歸輸入法所有。在 GPUI 後端上，取得鍵盤焦點的原生元件會被視為文字輸入框，因為它可能是文字編輯器。它會最先接收每個按鍵：它自行處理的按鍵永遠不會傳到綁定，而在編輯文字時會觸發的綁定，則會收到它放行的按鍵。

<a id="which-binding-runs"></a>

### 哪個綁定會執行

觸發了綁定的按鍵會被消耗：它不會被輸入，系統不會發出提示音，NSH 自身的按鍵也看不到它（Escape 返回、Tab 移動焦點、方向鍵捲動）。當多個綁定使用相同的按鍵組合時，最新且可觸發的那個會執行，因此對話框可以在畫面的綁定之上綁定 Escape，對話框的綁定移除後，畫面的綁定便會恢復。綁定會在按鍵按下的一刻於原生層檢查；處理器隨後立即在引擎執行緒（Rust）或 JS 執行緒（TSX）上執行。

按住按鍵只會觸發一次綁定，直至該按鍵被放開；使用 `repeat: true` 時，每次自動重複都會再次觸發（例如逐項走過清單）：

```rust
on_key_with("alt+down", KeyOptions { repeat: true, ..KeyOptions::default() }, move |_| next_item());
```

```tsx
keymap.bind("alt+down", nextItem, { repeat: true });
```

選單快速鍵會讓位給相同按鍵組合的綁定：所有桌面平台上的應用程式選單（[`nsh/desktop`](./nsh-desktop)），以及 macOS 上內建選單的快速鍵（Cmd+W、Cmd+C 等）。使用 `nsh run` 開發時，Cmd+R / Ctrl+R 一律會重新載入應用程式，即使它綁定了 `mod+r`；已封裝的應用程式則可以綁定它。

<a id="nativecomponent-and-nativemodule"></a>

## nativeComponent 與 nativeModule

`nativeComponent` 與 `nativeModule` 在執行時以名稱將你的應用程式連接到 host 應用程式所提供的內容。嵌入 NSH 的 iOS 或 Android 應用程式可以按名稱註冊自己的 view（地圖、圖表、相機預覽）；`nativeComponent` 為其中一個提供具型別的元件。由於 NSH 以真正的原生 view 渲染，此類元件會與其他 view 一樣位於 view 階層中：由 NSH 排版，隨其父層一同裁切與捲動，並能正確地重疊。從 Rust 嵌入 NSH 的應用程式亦可註冊函式、類別及 Rust 元件；`nativeModule` 會將它們整合為一個具型別的物件。無論哪種情況，你的應用程式都以標準的 `nsh build` 建置：host 在執行應用程式時才連接它所提供的內容。Rust 程式碼使用 `nsh_core::prelude` 的 `native(name)` 放置已註冊的 view，以 `.prop(name, value)` 設定其 props（JSON 值，或其 signal 或閉包），或以 `.bind_prop(name, &value)` 將其中一個綁定到動畫值，以 `.on_native_event(…)` 接收其事件，並透過 `ElementRef` 呼叫其指令；TSX 則匯入 `nsh`。

```rust
use nsh_core::prelude::*; // native, NativeEvent, element_ref
```

```tsx
import { nativeComponent, nativeModule } from "nsh";
```

關於註冊原生一側，請參見 [Native components](https://nsh.dev/translations/zh-HK/docs/native-components.md)，以及 [Embedding on iOS](https://nsh.dev/translations/zh-HK/docs/ios.md) / [Embedding on Android](https://nsh.dev/translations/zh-HK/docs/android.md)。

### nativeComponent

```ts
function nativeComponent<P extends object = {}, E extends object = {}>(
  name: string,
  spec: NativeComponentSpec<P, E>,
): (props: NativeComponentProps<P, E>) => JSX.Element;

interface NativeComponentSpec<P, E> {
  /** Its own props: sent to the component (any JSON value), each when it changes. */
  props: readonly (keyof P & string)[];
  /** The events it emits. */
  events?: readonly (keyof E & string)[];
}
```

回傳嵌入應用程式以 `name` 註冊的原生元件所對應的元件（Swift `NshAppView.register(component:)`，Java `NshView.registerComponent`）。Rust：`native(name)`，這是一個 view，其 props 為 JSON 值（`.prop("text", json!(…))`），其事件以 `NativeEvent { name, data }` 送達。

- `P` 是其自身 props 的型別。`spec.props` 中列出的每個 prop 在變更時都會以 JSON 傳送到原生一側；將它設為 `undefined` 即會移除該 prop。
- `E` 將事件名稱對應到其負載。事件 `regionChange` 會成為處理器 prop `onRegionChange`。
- 此元件亦接受除 `children` 以外的所有 [view prop](https://nsh.dev/translations/zh-HK/elements/view.md)：`style`、`class`、排版、無障礙、觸控處理器及 `ref`。

```ts
type NativeComponentProps<P, E> = Omit<ViewProps, "children"> & P & NativeEventHandlers<E>;
type NativeEventHandlers<E> = { [K in keyof E & string as `on${Capitalize<K>}`]?: (data: E[K]) => void };
```

<a id="size"></a>

#### 大小

此元件與 view 一樣排版，大小由其樣式決定。樣式未指定某個維度時，會採用原生元件回報的大小（如同圖片採用其自然大小）。

<a id="commands"></a>

#### 指令

它的 `ref` 接收一個 `NativeComponentRef`；`ref.command(name, args)` 以 `args`（任何 JSON 值）呼叫原生元件的指令 `name`。Rust：`ElementRef::command(name, args)`。

```rust
map.command("zoomTo", json!({ "level": 3 }));
```

```tsx
map.command("zoomTo", { level: 3 });
```

<a id="when-nothing-is-registered"></a>

#### 未註冊時

若 `name` 之下沒有註冊任何內容（例如不同的 host 應用程式、開發 host、瀏覽器），元件會渲染一個空的 view，並只記錄一次警告。請確保應用程式在沒有它時仍可使用，或在使用前先檢查你的 host。

<a id="example"></a>

### 範例

這裡的徽章是 host 應用程式的原生 view，註冊為 `embed.badge`。NSH 負責排版、向它發送 `text` 與 `color`、接收其 `tap` 事件，並呼叫其 `pulse` 指令：

```rust
use nsh_core::prelude::*;
use serde_json::json;

#[component]
pub fn App() -> impl IntoElement {
    let taps = signal(0_u64);
    let badge = element_ref();
    view()
        .flex_1()
        .p_5()
        .gap_4()
        .child(
            native("embed.badge")
                .prop("text", json!("Tap the native badge"))
                .prop("color", json!("#8250df"))
                .element_ref(&badge)
                .on_native_event(move |e: &NativeEvent| {
                    if &*e.name == "tap" {
                        taps.set(e.data["count"].as_u64().unwrap_or(0));
                        badge.command("pulse", json!(null));
                    }
                })
                .self_start()
                .rounded(px(8.0))
                .overflow_hidden(),
        )
        .child(text(move || format!("Tapped {}×", taps.get())))
}
```

```tsx
import { mount, type NativeComponentRef, signal, View } from "nsh";
import { nativeComponent } from "nsh";

const Badge = nativeComponent<{ text: string; color?: string }, { tap: { count: number } }>("embed.badge", {
  props: ["text", "color"],
  events: ["tap"],
});

function App() {
  const taps = signal(0);
  let badge: NativeComponentRef | undefined;
  return (
    <View style={{ flex: 1, padding: 20, paddingTop: "calc(env(safe-area-inset-top) + 20px)", gap: 16 }}>
      <Badge
        ref={(node) => (badge = node)}
        text={taps() === 0 ? "Tap the native badge" : `Tapped ${taps()}×`}
        color="#8250df"
        onTap={(e) => {
          taps.set(e.count);
          badge?.command("pulse");
        }}
        style={{ alignSelf: "flex-start", borderRadius: 8, overflow: "hidden" }}
      />
    </View>
  );
}

mount(App);
```
### nativeModule

```ts
function nativeModule<T extends object>(specifier: string): T;
```

傳回嵌入式應用程式以 `specifier` 註冊的集合所匯出的內容：其模組、類別、函式和 Rust 元件，合為一個物件。類型 `T` 由應用程式自行撰寫，因此建置時無需任何有關宿主的資訊。與宿主 crate 一同建置的 Rust 程式碼會以 Rust 方式呼叫這些函式；`nativeModule` 則是 TSX 存取它們的途徑。

```ts
import { nativeModule, signal } from "nsh";

interface TradingApi {
  quote(symbol: string): number;
  placeOrder(symbol: string, quantity: number, side: "Buy" | "Sell"): Promise<Order>;
  on(event: "change", listener: (desk: Desk) => void): () => void;
}

const { Trading } = nativeModule<{ Trading: TradingApi }>("host/trading");

const price = Trading.quote("AAPL");

const desk = signal<Desk | null>(null);
Trading.on("change", (d) => desk.set(d));
```

- 呼叫方式與透過 import 引入集合時相同：一般的 Rust 函式會即時傳回其值，`async` 函式會傳回 promise，而你傳入的函式則成為 Rust 日後可以呼叫的回呼。Rust 會檢查每個引數，遇到錯誤的引數時會拋出 `TypeError`。
- 以相同的 `specifier` 每次呼叫都會傳回同一個物件。
- 若 `specifier` 之下沒有註冊任何內容，`nativeModule` 會拋出指明該名稱的 `ReferenceError`。請在應用程式能夠處理該錯誤的位置呼叫它，或只在其所針對的宿主中呼叫。

宿主在啟動其檢視之前，透過 Rust 以平台 crate 的 `set_ui_sets`（與 NSH 自身的集合並列）註冊集合，或使用 `EngineConfig::ui_sets`：

```rust
use std::{cell::RefCell, rc::Rc};

use nsh_core::prelude::*;

pub struct Trading;

#[module]
impl Trading {
    /// A sample price.
    pub fn quote(symbol: String) -> Result<f64, String> { /* ... */ }

    /// Calls `listener` with the desk now and after every change
    /// (`change`, the one event), until the returned function is called
    /// or what called `on` goes.
    pub fn on(event: String, listener: Callback<Desk>, cx: CallCx<'_>) -> Result<Stop, String> {
        if event != "change" {
            return Err(format!("no Trading event `{event}`"));
        }
        let listener = Rc::new(RefCell::new(Some(listener)));
        let held = listener.clone();
        cx.start(async move {
            let mut desk = DESK.subscribe(); // a tokio watch channel the host keeps
            loop {
                let Some(listener) = held.borrow().clone() else { return };
                listener.post(desk.borrow_and_update().clone());
                if desk.changed().await.is_err() {
                    return;
                }
            }
        });
        Ok(Stop::new(move || drop(listener.take())))
    }
}

let mut trading = Registry::ui_set("host/trading");
trading.module::<Trading>();
nsh_platform_gpui::set_ui_sets(vec![nsh_base::registry(), trading]);
```

`on` 是宿主讓所有應用程式與其自身資料保持同步的方式：它啟動的任務會等待宿主的變更，並將每項變更傳送給應用程式，因此由宿主或由任何應用程式透過其他函式所作的變更，都會傳達至每個正在監聽的應用程式。它傳回的 `Stop` 就是 TSX 中 `Trading.on` 所傳回的函式：它只會執行一次，在應用程式呼叫它時，或在呼叫 `on` 的對象結束時（元件，或熱重載下模組的執行）執行，並在此處移除監聽器，任務隨之結束。

> [!NOTE]
> 屬性、事件和命令引數都以 JSON 形式傳遞至原生端。已註冊的元件和函式屬於宿主應用程式自身的程式碼：NSH 的[沙盒限制與權限](https://nsh.dev/translations/zh-HK/docs/sandbox-and-capabilities.md)並不適用於它們的行為。

<a id="accessibility"></a>

## 無障礙功能

無障礙功能 API 會讀取用戶的無障礙設定（是否有螢幕閱讀器正在執行、用戶是否要求減少動態效果），並與螢幕閱讀器溝通。Rust 程式碼從 `nsh_core::prelude` 呼叫 `screen_reader()`、`reduce_motion()` 和 `announce_for_accessibility()`，並以 `ElementRef` 移動焦點；TSX 則從 `nsh` 匯入 `screenReaderEnabled()`、`reduceMotion()`、`announce()` 和 `setAccessibilityFocus()`。這些設定具響應性：在繫結、`computed` 或 `effect` 中讀取它們時，當用戶更改設定，讀取它們的內容也會隨之更新。元素的無障礙屬性（`accessibilityLabel`、`role` 等）記載於[元素](https://nsh.dev/translations/zh-HK/elements.md)；指南請參閱[無障礙功能](https://nsh.dev/translations/zh-HK/docs/accessibility.md)。

```rust
use nsh_core::prelude::*; // screen_reader, reduce_motion, announce_for_accessibility, element_ref
```

```tsx
import { announce, reduceMotion, screenReaderEnabled, setAccessibilityFocus } from "nsh";
```

### screenReaderEnabled

```ts
const screenReaderEnabled: Accessor<boolean>;
```

是否有螢幕閱讀器（VoiceOver、TalkBack）正在執行。具響應性：當用戶開啟或關閉螢幕閱讀器時，讀取它的內容會隨之更新。Rust：`screen_reader() -> bool`，同樣具響應性。

```rust
when(move || !screen_reader(), || text("Swipe for the next photo"))
    .otherwise(move || text("Next photo").on_press(next))
```

```tsx
<If when={!screenReaderEnabled()} else={<Text onPress={next}>Next photo</Text>}>
  <Text>Swipe for the next photo</Text>
</If>
```

### reduceMotion

```ts
const reduceMotion: Accessor<boolean>;
```

用戶是否要求減少動態效果（iOS 的「減少動態效果」，Android 的「移除動畫」）。具響應性。Rust：`reduce_motion() -> bool`。Tailwind 類別也會透過 `motion-reduce:` 變體跟隨此設定。

```rust
let open = move || {
    if reduce_motion() {
        panel.set(0.0);
    } else {
        let _ = panel.spring(0.0, Spring::default());
    }
};
```

```tsx
const open = () => {
  if (reduceMotion()) panel.set(0);
  else panel.spring(0);
};
```

### announce

```ts
function announce(message: string): void;
```

螢幕閱讀器會朗讀 `message`（VoiceOver 播報、TalkBack）。適用於在焦點沒有移動的情況下出現的結果，例如「已儲存」。Rust：`announce_for_accessibility(message)`。

### setAccessibilityFocus

```ts
function setAccessibilityFocus(node: ViewRef): void;
```

將螢幕閱讀器的焦點移至 `node`（元素的 `ref`）。Rust：`ElementRef::set_accessibility_focus()`。

```rust
use nsh_core::prelude::*;

#[component]
fn Saved() -> impl IntoElement {
    let note = element_ref();
    let save = move || {
        announce_for_accessibility("Saved");
        note.set_accessibility_focus();
    };
    // Runs again when the setting changes, until the component goes.
    effect(|| log::info!("screen reader {}", screen_reader()));
    view()
        .gap_2()
        .child(text("Save").role(AccessibilityRole::Button).on_press(save))
        .child(text("Your changes are kept on this device.").element_ref(&note))
}
```

```tsx
import { effect, Text, View, type ViewRef } from "nsh";
import { announce, screenReaderEnabled, setAccessibilityFocus } from "nsh";

function Saved() {
  let note!: ViewRef;
  const save = () => {
    announce("Saved");
    setAccessibilityFocus(note);
  };
  // Runs again when the setting changes, until the component goes.
  effect(() => console.log("screen reader", screenReaderEnabled()));
  return (
    <View style={{ gap: 8 }}>
      <Text onPress={save} role="button">Save</Text>
      <Text ref={note}>Your changes are kept on this device.</Text>
    </View>
  );
}
```

<a id="types"></a>

## 類型

`nsh` 亦會匯出響應式 API 和宿主元素的類型。最常用的包括：

| 類型 | 說明 |
|---|---|
| `Accessor<T>` | 可讀取的值：`() => T` |
| `Signal<T>` | 具有 `set` 和 `update` 的 `Accessor<T>` |
| `SignalOptions<T>` | `signal` 和 `computed` 的選項（`equals`） |
| `Context<T>` | `context` 的傳回值 |
| `Component<P>` | `(props: P) => JSX.Element` |
| `JSX` | JSX 命名空間（`JSX.Element`） |
| `ViewRef`、`InputRef`、`ScrollRef`、`ListRef`、`WebViewRef`、`VideoRef`、`NativeComponentRef` | `ref` 所接收的內容，即各元素自身的類型（`ViewRef` 及其上層類型）：焦點、捲動及其他命令。`ViewRef` 同時也是元素本身的類型（`JSX.Element`）。請參閱 [ref 方法](https://nsh.dev/translations/zh-HK/elements.md#ref-methods) |
| `ViewProps`、`TextProps`、`ImageProps`、`ScrollProps`、`CommonProps`、`TouchProps` | 宿主元素的屬性 |
| `ViewStyle`、`TextStyle`、`InputStyle`、`ImageStyle`、`LayoutStyle`、`StyleProp<T>` | 樣式物件；請參閱[樣式參考](https://nsh.dev/translations/zh-HK/modules/style-reference.md) |
| `ColorValue`、`DimensionValue`、`LengthValue`、`TransformStep` | 樣式內的值 |
| `GestureResponderEvent`、`MouseEvent`、`MouseButton`、`ContextMenuEvent`、`PressEvent`、`ScrollEvent`、`PanEvent`、`TouchPoint` | 事件負載 |
| `Point`、`Size`、`Bounds`、`Edges`、`Modifiers` | 構成負載和屬性的結構，欄位與 GPUI 一致（`nsh_core::geometry`） |
| `PixelSize` | 圖片或影片本身的像素尺寸（`onLoad`、`Image.getSize`、剪貼簿和選取器的圖片），而 `Size` 則以點（points）為單位 |
| `ImageAsset` | 匯入圖片檔案所得的結果（`{ url, size: Size, scales? }`；`scales` 列出其[像素密度變體](https://nsh.dev/translations/zh-HK/docs/images-and-assets.md#pixel-densities)） |
| `OS` | `"ios" \| "android" \| "macos" \| "windows" \| "linux" \| "web"` |
| `AccessibilityRole`、`AccessibilityState` | 無障礙屬性 |

```tsx
import { type GestureResponderEvent, type InputRef, signal } from "nsh";

let field: InputRef | undefined;
const onPress = (e: GestureResponderEvent) => field?.focus();
```

`StyleProp<T>` 接受一個樣式，或由左至右合併的樣式清單，並會略過 falsy 的項目：

```tsx
<View style={[styles.card, selected() && styles.selected]} />
```
