Эта страница переведена машинным способом и может содержать ошибки. Читать оригинал на английском

API

nsh

nsh — основной модуль: реактивное API (сигналы, вычисляемые значения, эффекты, контекст), mount, token, хост-элементы View, Text, Scroll и Overlay, выделяемые представления, безопасная область и клавиатура, анимация, устройство и его окружение, PanResponder, буфер обмена, выбор фото, распознавание речи, хранилище, канал хоста, сочетания клавиш, нативные компоненты и модули, доступность, а также TypeScript-типы элементов, стилей и событий. Сам реактивный граф работает нативно на Rust; чтение сигнала — это вызов в рантайм, который стоит несколько наносекунд, и в интерпретаторе JavaScript выполняются только ваши собственные замыкания.

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, которые читают сигналы, обновляются на месте при изменении этих сигналов. Повторного рендера компонента и виртуального DOM нет. Руководство см. в разделе Reactivity.

#signal

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);
}

Сигнал хранит значение. Вызов сигнала читает значение и, внутри отслеживающей области (выражения JSX, computed, effect), подписывает эту область на него. set и update записывают значение; всё, что его читало, обновляется.

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, чтобы уведомлять при каждой записи (полезно, когда вы изменяете объект на месте и снова записываете тот же объект), или собственную функцию сравнения:

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

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

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

Значение, производное от сигналов. fn запускается снова, когда меняется прочитанный им сигнал; читатели вычисляемого значения обновляются, только когда меняется его результат (по equals, === по умолчанию). fn получает предыдущий результат.

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

Используйте computed, когда вычисление дорогое или его результат читается в нескольких местах. Для дешёвого выражения обычная функция (const full = () => first() + " " + last()) реактивна ничуть не хуже.

#effect

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

Запускает fn после рендеринга и снова при каждом изменении прочитанного им сигнала. Используйте эффекты для побочных действий: логирования, таймеров, обращений к нативным модулям. Эффект принадлежит компоненту (или эффекту), в котором он создан, и удаляется вместе с ним.

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

Чтобы отменить что-либо перед повторным запуском эффекта (или когда его компонент исчезает), вызовите внутри него cleanup.

#batch

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

Запускает fn и применяет все его записи вместе в конце, так что читатели обновляются один раз, а не по разу на каждую запись. Возвращает то, что возвращает fn.

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

#untracked

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

Запускает fn, не подписывая текущую область на то, что он читает. Используйте это, чтобы прочитать сигнал внутри эффекта или выражения JSX, не завися от него.

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

#cleanup

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

Регистрирует fn для запуска перед очередным запуском текущего эффекта или когда текущий компонент или эффект исчезает.

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

#context

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

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

Значение, которое компонент предоставляет всему, что он рендерит, на любой глубине. Вызов контекста (Theme()) читает значение ближайшего провайдера или defaultValue, если провайдера нет.

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

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

Рендерит app на поверхность (окно или NshAppView / NshView, в которые встроено приложение) и возвращает функцию, которая его убирает. Входной файл каждого приложения заканчивается вызовом mount(App).

mount(App);

#split

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

Разделяет объект props на props, названные в keys, и остальные. Обе части остаются реактивными: props, значения которых меняются, являются геттерами, и split сохраняет их геттерами. Используйте это в компоненте, который часть props обрабатывает сам, а остальные передаёт дальше. Деструктуризация props (const { label } = props) прочитала бы их один раз и потеряла бы обновления.

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

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

Рендерит своих потомков или fallback, как только что-то под ними выбросит исключение: тело компонента, привязка, эффект или обработчик события. Часть, в которой произошёл сбой, удаляется, и на её месте показывается запасной вариант. Функция fallback вызывается как fallback(error, reset) с выброшенным значением и reset, который снова рендерит потомков, независимо от того, какие параметры она объявляет; элемент fallback показывается как есть. Сбой передаётся ближайшей границе выше него, а сбой в запасном варианте — границе выше той. Компоненты на Rust используют catch_error (Error boundaries).

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

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

Токен дизайна name темы приложения ("theme" в nsh.json, поверх токенов shadcn/ui) для отображаемого оформления: цвет — как #rrggbbaa, длина — как число пунктов, шрифт — как название семейства. Имена — это CSS-переменные темы без -- (primary, muted-foreground, radius, font-mono, а также собственные переменные приложения, например up). Чтение реактивно: компонент, memo или эффект, читающий токен, запускается снова при изменении темы или оформления. Tokens перечисляет токены приложения с их типами; NSH записывает его в .nsh/types/nsh/tokens.d.ts на основе темы. Для классов называйте токены напрямую (bg-primary, rounded-(--radius)): Theme tokens.

import { effect, token } from "nsh";

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

#View, Text, Scroll, Overlay

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). Компилятор создаёт нативное представление на месте для тега, который называет один из них через свой импорт (в том числе под псевдонимом), так что импорт ничего не стоит во время выполнения. Это такие же компоненты, как и любые другие, поэтому их можно и передавать (const Tag = inline ? Text : View). nsh точно так же экспортирует другие примитивы: List, Image, Canvas, WebView и Video, у каждого есть своя страница справочника. Их props описаны в разделе Elements. Текстовое поле ввода, Input, поставляется из nsh/base.

#Безопасная область и клавиатура

Переменные окружения CSS удерживают содержимое вне системного UI в padding и margin любого представления (padding, paddingTop, …, те же ключи margin; произвольные значения p* / m* в Tailwind; методы Styled .p_safe_area(), .pt_safe_area_plus(..), .pb_keyboard(), … в Rust). Их значения — EnvValue: env(name), env(name, <length>), calc(env(name) + <length>) и calc(<length> + env(name)).

ПеременнаяЗначение
safe-area-inset-top, -right, -bottom, -leftОтступы корневого представления, в котором рендерится узел: ближайшей страницы (под её заголовком), ближайшего диалога или, иначе, безопасная область поверхности. См. Safe area.
keyboard-inset-heightВысота программной клавиатуры над поверхностью, 0, пока она скрыта; изменения анимируются нативно вместе с клавиатурой. См. Keyboard.
<View style={{ flex: 1, paddingTop: "env(safe-area-inset-top)", paddingBottom: "env(keyboard-inset-height)" }}>…</View>

#safeArea и safeAreaInsets

safeArea() — безопасная область поверхности (safe_area() в Rust); safeAreaInsets() — отступы там, где рендерится компонент (cx.insets() в Rust), то есть значения переменных safe-area-inset-*. Оба являются реактивными Edges ({ top, right, bottom, left } в пунктах). См. safeArea and safeAreaInsets.

#Keyboard и keyboardFrame

Keyboard.dismiss() скрывает программную клавиатуру (dismiss_keyboard() в Rust), а keyboardFrame() сообщает, какую часть экрана она закрывает (keyboard_height() и keyboard_duration() в Rust). См. Hiding the keyboard и The keyboard’s height.

#Выделяемые представления

<View selectable> (view().selectable(true) в Rust) делает так, что тексты внутри него выделяются вместе, как на веб-странице: одно выделение проходит через представления, элементы Text и TextView в порядке чтения. См. Text selection.

#Анимация

API анимации выполняет анимацию в UI-потоке. Вы создаёте анимированное значение, привязываете его (или производные от него значения) к свойству стиля и запускаете анимацию; дальше каждый кадр вычисляется нативно, без запуска кода приложения. Занятый движок или JS-поток не может вызвать подтормаживание анимации, а сама анимация не требует от приложения никакой работы на каждом кадре. Код на Rust использует animated_value(), motion(), Animation, Spring, Timing, KeyframeTiming, MotionPreset и Easing из nsh_core::prelude и привязывает значения методами .bind_* элементов; в TSX их импортируют из nsh.

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

Руководство см. в разделе Animation.

#Что может управляться анимированным значением

ГдеRustTSX
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 задаёт цвет всех сторон; color у Text задаёт цвет всего текста
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 }} у любого элемента: жест записывает значения напрямую (см. Touch and gestures)
Где прилипают представления.sticky().bind_sticky_top(&value) (.bind_sticky_left в горизонтальном прокручиваемом представлении), .bind_sticky_header_offset(&value) у спискаstyle={{ position: "sticky", top: value }}, <List stickyHeaderOffset={value}>: липкие представления и заголовки прилипают на таком расстоянии от начала области просмотра, следуя за значением на каждом кадре
Canvasанимированный аргумент вызова painterЛюбой числовой или цветовой аргумент вызова рисования canvas
Шейдер.uniform(name, &value)Член struct Uniforms у shader: uniforms={{ name: value }} (числа или узел цветов для vec3f / vec4f)

Остальные свойства стиля принимают обычные значения; чтобы анимировать саму раскладку (размеры, позиции в потоке), используйте LayoutAnimation.

#createAnimatedValue

function createAnimatedValue(value?: number): AnimatedValue;

Создаёт анимированное значение, начинающееся с value (по умолчанию 0). Rust: animated_value(value), дескриптор Copy, который хранит компонент, создавший его.

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))
}

#AnimatedValue

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)Смещение, добавляемое к значению: привязки видят значение + смещение; 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). Используйте после броска со скоростью из onPanEnd. С clamp: [min, max] останавливается на границе или отскакивает от неё, сохраняя bounce (0..1, по умолчанию 0) своей скорости.
keyframes(frames, options)keyframes(frames, KeyframeTiming)Проигрывает keyframes так же, как проигрываются CSS-кейфреймы: своя плавность на каждый сегмент, iterations раз в direction.

Каждый метод анимации возвращает промис, который завершается с { finished }: true, когда анимация закончилась, false, когда она была остановлена (через stop, set или другой анимацией того же значения). В Rust он возвращает AnimationEnd, которого задача ожидает ради того же bool.

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

#AnimatedNode

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, divadd, sub, mul, divАрифметика с другим узлом или константой.
mod(b)modulo(b)Остаток от деления, никогда не отрицательный при положительном b (удерживает углы в диапазоне 0..360).
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)))

#Keyframes

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-кадры анимации: кривая плавности задаёт форму одного сегмента (у ключевого кадра — сегмент, который начинается с него, у timing — все остальные); отсутствующие у ключевых кадров offset распределяются равномерно, первый — в 0, последний — в 1; каждая итерация начинается со скачка к первому ключевому кадру, а без ключевого кадра в 0 значение идёт оттуда, где находится; во время delay значение удерживает первый ключевой кадр (CSS animation-fill-mode: backwards); обратные итерации проходят сегменты в обратном порядке с обращёнными кривыми; значение остаётся там, где его оставила последняя итерация.

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)),
);

#createMotion

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;
}

Создаёт движение: анимированное значение для каждого канала непрозрачности и трансформации представления в состоянии покоя (непрозрачность и масштабы равны 1, остальные 0), а также движения, которые нативно проигрываются на них. Привяжите его через style={motion.style} или разверните motion.style либо motion.transform в стиль; трансформация применяет сначала сдвиги, затем повороты, затем масштабы, причём scale сворачивается в scaleX и scaleY: это 7 из 8 шагов трансформации представления, так что один остаётся для вашего ([{ perspective: 600 }, ...motion.transform] для 3D-переворотов). initial задаёт, откуда стартуют каналы ({ opacity: 0 }; остальные стартуют из состояния покоя). С enter каналы, которые оно двигает, стартуют там, где начинается это движение (по initial), и оно проигрывается сразу, а остальные опции служат его параметрами времени (delay, duration, …). Движение меняет только непрозрачность и трансформацию: разворачивание motion.style задаёт opacity представления, поэтому привязывайте только motion.transform, чтобы сохранить собственную непрозрачность, а цвета двигайте через анимированное значение и interpolate. Rust: motion() — дескриптор Copy, который хранит компонент, создавший его, привязывается через .bind_motion(&m); motion().entering(spec) выполняет вход.

ЭлементRust (Motion)Описание
opacity, translateX, … scaleYvalue(MotionChannel::Opacity), …Анимированные значения каналов.
style, transform.bind_motion(&m) на элементеПривязки.
play(motion, options?)play(spec), play_ignoring_reduce_motion(spec)Проигрывает на каналах готовый пресет по имени или MotionSpec; новое движение прерывает текущее. options меняют его тайминг. Завершается с { finished }: true, если оно доиграло, false, если что-то его остановило (AnimationEnd в Rust).
animation(motion, options?)animation(spec)То же движение в виде CompositeAnimation, не запущенное, для композиции: 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 сразу переходят в конечную точку движения, не двигаясь, если не задан ignoreReduceMotion (в опциях или у движения).

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);
})

#motions

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.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 описывает каждое из них. Разверните пресет, чтобы изменить его: motion.play({ ...motions.fadeInUp, duration: 600 }).

MotionSpec — это ваше собственное движение: ключевые кадры каждого канала ({ opacity: [0, 1], translateY: [24, 0] }) или список кадров в том виде, как их записывает Web Animations API ([{ opacity: 0, translateY: 24 }, { opacity: 1, translateY: 0 }]; кадр без offset размещается по его позиции в списке), с таймингом ключевых кадров. Rust: MotionSpec::new(KeyframeTiming) с .values(channel, values) (равномерно распределённые), .track(channel, keyframes) и .timing(|t| …); MotionPreset::FadeInUp (или .spec()) — пресет, а MotionChannel называет каналы.

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);

#Animated

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, decayAnimation::timing(&value, to, Timing), Animation::spring(&value, to, Spring), Animation::decay(&value, velocity, deceleration)Одна анимация одного значения; те же опции и значения по умолчанию, что у методов 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)Значение по ключевым кадрам.
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, в виде функций.
sampleSpring, sampleDecaynsh_core::element::sample_spring, sample_decayЗначения пружины или затухания по одному на кадр (см. Easing).

CompositeAnimation:

МетодRust (Animation)Описание
start(callback?)start(), AnimationEnd для ожиданияЗапускает его (снова). Один раз для всего дерева завершается и вызывает callback с { finished }: доиграло ли оно до конца, а не было остановлено.
stop()stop()Останавливает его на месте.
reset()reset()Останавливает его и возвращает его значения туда, где они были на момент запуска.
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" }) });

#Easing

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 это enum 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) выдаёт её значения в count равноотстоящих точках от 0 до 1; Animated.sampleSpring и Animated.sampleDecay (sample_spring, sample_decay) выдают значения пружины или затухания по одному на кадр (при fps, по умолчанию 60) от from (0) до её завершения. Все три берутся из нативного кода, на котором выполняются анимации. Везде, где в TSX принимается Easing, можно передать имя ("easeOut"), кубическую кривую Безье в виде четырёх чисел или кривую из объекта Easing.

КриваяRustФорма
quadCurve::Quadt²
cubicCurve::Cubict³
poly(n)Curve::Poly(n)tⁿ (n по умолчанию равен 2)
sinCurve::SinСинусоидальная
circleCurve::CircleКруговая
expCurve::ExpЭкспоненциальная
elastic(bounciness)Curve::Elastic(bounciness)Пружинящий перелёт (bounciness по умолчанию равен 1)
back(s)Curve::Back(s)Откатывается назад перед движением; s — величина перелёта (по умолчанию 1.70158)
bounceCurve::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 это EaseMode у Easing::Curve.

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() });

#LayoutAnimation

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.easeInEaseOutLayoutAnimation::EASE_IN_EASE_OUT300 мс, представления проявляются и угасают.
Presets.linearLayoutAnimation::LINEAR500 мс с постоянной скоростью.
Presets.springLayoutAnimation::SPRING700 мс; фреймы пружинят (затухание 0.4), представления плавно появляются и исчезают линейно.
easeInEaseOut(onEnd?), linear(onEnd?), spring(onEnd?)animate_layout(LayoutAnimation::…)Сокращения для configureNext(Presets.…).
Types, PropertiesLayoutCurve, LayoutPropertyИмена в виде констант.
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.")))

#Device

API устройства сообщает приложению об устройстве и его окружении. Rust-код вызывает функции nsh_core::prelude (window(), dark(), font_scale(), locale(), lifecycle(), os(), idiom(), pixel_ratio(), set_color_scheme(), …); TSX импортирует их из nsh. Размер окна, цветовая схема, масштаб шрифта, плотность пикселей, локаль и состояние приложения реактивны: читайте их в привязке (замыкание, передаваемое элементу в Rust, JSX в TSX), computed или effect, и то, что их читает, обновляется, когда платформа сообщает об изменении (поворот, изменение размера окна, переключение на тёмную тему, изменение размера текста или языка, переход на экран другой плотности, выход из приложения и возврат в него).

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

См. руководство Device and environment.

#windowSize

const windowSize: Accessor<Size>;

Размер поверхности в пунктах: окна или встроенного NshAppView / NshView. Реактивно. Rust: window() -> Size<Points>.

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

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

#colorScheme

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

Отображаемое оформление: системное, если приложение не задало своё (nsh.json "appearance", setColorScheme). Реактивно. Rust: dark() -> bool; цвета темы (AdaptiveColor) и вариант стиля .dark(…) следуют за ним без его чтения. Вариант dark: из Tailwind тоже следует за ним (см. Tailwind).

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

#fontScale

const fontScale: Accessor<number>;

Предпочитаемый пользователем размер текста относительно размера по умолчанию (1 — размер по умолчанию; Dynamic Type на iOS, размер шрифта на Android) или размер текста, который вместо него задаёт встраивающее приложение. Реактивно. Rust: font_scale() -> f32. Текст уже масштабируется вместе с ним, если не задан allowFontScaling={false}; читайте его, когда за ним должны следовать другие размеры, например значки рядом с текстом.

#locale

const locale: Accessor<string>;

Локаль пользователя в виде тега BCP 47 ("en-US", "zh-Hans-CN") или локаль, которую вместо неё задаёт встраивающее приложение. Реактивно. Rust: locale() -> Locale (locale().as_str() — тег). Intl по умолчанию форматирует под неё; читайте её, чтобы выбирать строки собственных переводов (Device and environment).

#lifecycle

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

Используется ли приложение, в терминах AppState из React Native: "active" — на переднем плане и принимает ввод, "inactive" — видно, но без фокуса (фокус у другого приложения или окна, либо система показывает что-то поверх, например переключатель приложений или входящий звонок), "background" — не видно (в фоне, свёрнуто, скрытая вкладка браузера). Приложение, показываемое внутри нативного приложения, следует состоянию этого приложения. Реактивно. Rust: lifecycle() -> Lifecycle (Lifecycle::Active, Inactive, Background).

Читайте его в эффекте, чтобы приостанавливать работу, которую никто не видит, и возобновлять её, когда приложение возвращается:

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

#setColorScheme

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

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Описание
osos() -> Os (os().name() — строка в TS)Операционная система. iOS, симулятор iOS и Mac Catalyst сообщают "ios", браузеры — "web".
versionos_version() -> (u16, u16)iOS и десктопы: версия ОС ("17.5"); Android: уровень API ("35"); веб: "0.0". Rust отдаёт мажорную и минорную версии, а на Android — уровень API и 0.
idiomidiom() -> IdiomТип устройства: "desktop" ровно там, где desktop равно true; в остальных случаях "phone", "pad" (iPad, планшет на Android, Mac Catalyst в идиоме iPad), "mac" (Mac Catalyst в идиоме Mac из UIKit, мобильная идиома) или "tv" (Apple TV, Android TV).
desktopis_desktop() -> boolПрименяется поведение десктопа: macOS, Windows, Linux и браузеры с мышью и широким окном. Ему следуют props desktop={{…}}, select({ desktop }) и idiom "desktop". Mac Catalyst запускает бэкенд iOS, поэтому он считается мобильным: там применяются props ios={{…}}. isAvailable у nsh/desktop — более узкая проверка для его API окон и меню, которые есть в macOS, Windows и Linux.
catalystis_catalyst() -> boolПриложение работает под Mac Catalyst, в идиоме Mac или iPad (os равно "ios").
select(spec)match os() { … }Значение для этой платформы (см. ниже).
let columns = if is_desktop() { 3 } else { 1 };

#platform.select

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, …), иначе desktop или mobile (по platform.desktop), иначе native (все платформы, кроме веба), иначе default, иначе undefined. Код на Rust использует match по os() (и is_desktop() для mobile / desktop).

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,
};

#pixelRatio

const pixelRatio: Accessor<number>;

Количество физических пикселей устройства на пункт (например, 3 на большинстве iPhone). Значение реактивное: оно меняется, когда окно переезжает на экран с другой плотностью (на Mac Catalyst, десктопах или внешнем дисплее) и когда настройка размера экрана в Android меняет плотность. Rust: pixel_ratio() -> f32 и round_to_nearest_pixel(points) для длины в целых физических пикселях; в TSX эта длина — Math.round(x * pixelRatio()) / pixelRatio().

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

#openUrl

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

interface OpenUrlResult {
  opened: boolean;
}

Открывает url вне приложения: веб-страницу в браузере, ссылку mailto: в почтовом приложении, ссылку tel: в приложении телефона и любую схему, зарегистрированную другим приложением (maps:, собственные ссылки lb:// хост-приложения). Промис завершается значением { opened } — открылось ли что-нибудь: false, если схему не обрабатывает ни одно приложение. Rust: open_url(url) от nsh_core::prelude — ответ, который нужно ожидать в задаче (или отбросить), тот же bool.

button()
    .child(text("Contact us"))
    .on_press(|| {
        spawn(async {
            if !open_url("mailto:help@example.com").await {
                log::warn!("no mail app");
            }
        });
    })
  • URL должен быть абсолютным (содержать схему). URL, которые выполнили бы код или обратились бы к данным самого приложения, — javascript:, data:, blob:, file:, about: и asset: — отклоняют промис с TypeError, как и относительные адреса. openUrl никогда не бросает исключение: любая ошибка приводит к отклонению промиса.
  • Приложению, которое объявляет capabilities, нужно openUrl: "openUrl": true для любой схемы или "openUrl": { "schemes": ["https", "mailto"] } для перечисленных; остальные URL отклоняются с Error, у которой code равно "ERR_ACCESS_DENIED", permission "openUrl" и resource — URL (см. Песочница и capabilities). В Rust open_url отвечает false для них и пишет причину в лог.
  • Сначала URL видит встраивающее приложение и может направить его в собственный роутер (Swift NshAppView.onOpenURL, Java NshView.setOpenUrlHandler, Rust AppView::host().handle_open_url; см. iOS и Android). Тогда opened равно true.
  • Каждая платформа открывает URL собственным средством: UIApplication.open на iOS (приложение-хост при встраивании), intent ACTION_VIEW на Android, NSWorkspace на macOS, ShellExecuteW на Windows, GtkUriLauncher на Linux, open_url из GPUI, новая вкладка в вебе. nsh test записывает URL вместо того, чтобы открывать их.
  • TextView открывает свои ссылки таким образом с помощью links="open".

#Направление раскладки

Направление раскладки следует языку пользователя: в языке с письмом справа налево (арабском, иврите и т. д.) приложение раскладывается справа налево, строки идут от правого края, а свойства стиля left / right (marginLeft, paddingRight, …) означают начало и конец. Изменения применяются сразу, без перезапуска.

Стиль direction у представления ("ltr", "rtl" или "inherit", по умолчанию) задаёт направление его собственного поддерева; в Rust — .direction(LayoutDirection::..).

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

#См. также

  • Safe area insets (Rust safe_area(); TSX safeArea(), safeAreaInsets()) и программная клавиатура (Rust keyboard_height(); TSX keyboardFrame()) описаны в разделе Safe area и клавиатура.
  • Настройки screen reader и уменьшения анимации описаны в разделе Accessibility.

#PanResponder

Pan responders — это жесты перетаскивания поверх системы gesture responder: элемент захватывает касания жеста, а затем получает только их вместе с состоянием жеста, которое само отслеживает центроид касаний, пройденное расстояние и скорость. Код на Rust захватывает касания с помощью .claim_touches(TouchClaim::…) и получает их через слушатели .on_responder_*, чей ResponderEvent несёт PanGesture, всё это в nsh_core::prelude; TSX импортирует PanResponder из nsh, колбэки onPanResponder… которого получают событие касания и состояние жеста.

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

#PanResponder.create

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

interface PanResponderInstance {
  panHandlers: TouchProps;
}

Создаёт pan responder. Передайте его panHandlers через spread представлению, которое должно получать жест. В 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);
            }),
    )
}

#Колбэки

Каждый колбэк получает событие касания (GestureResponderEvent, из nsh) и состояние жеста. В Rust захват решает с помощью .ask(|request: &ClaimRequest| …), вызывая request.claim(), чтобы забрать касания (без ask он забирает их всегда), а каждый слушатель получает ResponderEvent, у которого gesture — это состояние.

type PanHandler<R = void> = (e: GestureResponderEvent, gestureState: PanResponderGestureState) => R;
КолбэкRustВозвращаетКогда вызывается
onStartShouldSetPanResponder.claim_touches(TouchClaim::on_start())booleanПалец коснулся экрана: верните true, чтобы стать responder. Опрашивается от затронутого представления наружу.
onStartShouldSetPanResponderCaptureTouchClaim::on_start().capture()booleanТо же, но опрашивается снаружи внутрь, раньше всплывающего варианта (родитель забирает жест у потомков).
onMoveShouldSetPanResponderTouchClaim::on_move().ask(…)booleanПалец движется: верните true, чтобы стать responder (например, только после Math.abs(g.dx) > 10).
onMoveShouldSetPanResponderCaptureTouchClaim::on_move().capture()booleanТо же, фаза capture.
onPanResponderGrant.on_responder_grant(…)Это представление теперь responder: жест начинается.
onPanResponderReject.on_responder_reject(…)Другое представление является responder и не отпускает касания.
onPanResponderStart.on_responder_start(…)Палец коснулся экрана, пока представление было responder.
onPanResponderMove.on_responder_move(…)Пальцы сдвинулись.
onPanResponderEnd.on_responder_end(…)Палец поднялся, пока представление было responder.
onPanResponderRelease.on_responder_release(…)Поднялся последний палец: жест завершён.
onPanResponderTerminate.on_responder_terminate(…)Другое представление (или нативный жест, например scroll view) забрало касания.
onPanResponderTerminationRequest.keep_touches(true) оставляет их себеbooleanДругое представление хочет забрать касания: верните false, чтобы оставить их себе (по умолчанию true).
onShouldBlockNativeResponderTouchClaim::block_native(…)booleanНе давать ли нативным scroll view забирать касания, пока представление является responder (по умолчанию true).

#PanResponderGestureState

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, moveYpositionПоследняя позиция центроида касаний, в пунктах поверхности.
x0, y0originГде находился центроид, когда был предоставлен responder.
dx, dydeltaПройденное расстояние с момента предоставления.
vx, vyvx, vyСкорость в пунктах в секунду.
numberActiveTouchesactive_touchesКоличество пальцев на экране.

#Внутри scroll view

Пока pan responder является responder, окружающие его нативные scroll view не прокручиваются (onShouldBlockNativeResponder по умолчанию равно true). Чтобы горизонтальный свайп уживался с вертикальной прокруткой, становитесь responder только при горизонтальных движениях:

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))

Для pan, который платформа распознаёт сама, TouchClaim::on_pan(PanActivation::horizontal(10.0, 10.0)) принимает решение в главном потоке по мере движения пальцев. Базовые props responder (onStartShouldSetResponder, onResponderGrant, onTouchStart, …) также можно использовать напрямую на любом элементе; см. Элементы.

#Clipboard

API буфера обмена читает и записывает системный буфер обмена: текст — везде (UIPasteboard на iOS, ClipboardManager на Android, NSPasteboard, GDK и буфер обмена Win32 на десктопах, navigator.clipboard в вебе), изображения — на iOS, десктопах и в вебе, а файлы, скопированные в файловом менеджере, — на десктопах и Mac Catalyst. Код на Rust вызывает функции nsh_core::prelude (set_clipboard, clipboard_string, …); TSX импортирует Clipboard из nsh.

use nsh_core::prelude::*; // set_clipboard, clipboard_string, clipboard_image, ...
use nsh_core::element::ClipboardImage;
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
}
TSRust (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 возвращают ответы, которые нужно ожидать в задаче (spawn или функция модуля async); ClipboardImage — это nsh_core::element::ClipboardImage с url и size (Size<DevicePixels>).

Изображения проходят через буфер обмена на iOS, десктопах и в вебе. На Android буфер обмена переносит текст: writeImage и readImage отклоняются с Error, у которой code равно "ERR_FEATURE_UNAVAILABLE_ON_PLATFORM", а hasImage завершается false. В Rust set_clipboard_image оставляет буфер обмена как есть, а clipboard_image отвечает None.

Пользователь также может сам скопировать текст из выделяемого текста; API буфера обмена предназначен для копирования из кода, например по кнопке «Копировать».

#Clipboard.writeText

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

Помещает text в буфер обмена, заменяя прежнее содержимое. Rust: set_clipboard(text).

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" }))
}

#Clipboard.readText

readText(): Promise<string>;

Завершается текстом из буфера обмена или "", если текста в нём нет. Rust: clipboard_string(). На iOS чтение буфера обмена может показать системное уведомление о вставке («App pasted from …»), а на Android 10 и новее читать его может только приложение на переднем плане. Читайте его, когда пользователь просит вставить, а не на каждом экране.

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

#Clipboard.hasText

hasText(): Promise<boolean>;

Завершается значением, содержит ли буфер обмена текст, без его чтения: на iOS уведомления о вставке нет. Rust: clipboard_has_string(). Используйте это, чтобы решить, показывать ли кнопку «Вставить». В вебе браузеры могут определить это только чтением, поэтому может быть запрошено разрешение пользователя.

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

#Clipboard.readImage

readImage(): Promise<ClipboardImage | null>;

Завершается изображением из буфера обмена или null, если изображения в нём нет: вставленным скриншотом либо изображением, скопированным в браузере или графическом редакторе. Rust: clipboard_image(), None, если изображения нет. url — это URL изображения (PNG или собственные JPEG, GIF, WebP или BMP из буфера обмена), который Image показывает как свой src и который читает fetch: fetch(url) отдаёт его байты, например для загрузки на сервер. size (width и height) — его размер в пикселях после декодирования. Как и в случае readText, читайте его, когда пользователь просит вставить. Читаются изображения размером до 48 МБ (в закодированном виде), чтобы изображение помещалось в память приложения; более крупное отклоняется с Error, у которой code равно "ERR_NSH_QUOTA_EXCEEDED" (в сообщении указаны размер изображения и лимит), тогда как hasImage завершается true. Rust: clipboard_image() отвечает None для такого случая.

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
}

Приложение в стиле чата, где пользователи вставляют скриншоты в поле ввода сообщения, привязывает сочетание клавиш вставки, пока поле ввода в фокусе, и забирает изображение, если оно есть:

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);
        }
    });
});

#Clipboard.hasImage

hasImage(): Promise<boolean>;

Завершается значением, содержит ли буфер обмена изображение, без его чтения: на iOS уведомления о вставке нет. Rust: clipboard_has_image(). На Android завершается false.

#Clipboard.writeImage

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

Помещает в буфер обмена изображение из src — URL data: в base64 или url, который дал readImage, — заменяя прежнее содержимое. Rust: set_clipboard_image(src). На Windows принимается PNG — формат, который читают браузеры, Office и Snipping Tool. На Android отклоняется с "ERR_FEATURE_UNAVAILABLE_ON_PLATFORM".

set_clipboard_image(image.url.clone());

#Clipboard.readFiles

readFiles(): Promise<string[]>;

Завершается URL file: файлов и папок, скопированных в файловом менеджере (Finder, Explorer, Files), в их порядке, или []: теми же URL, которые даёт file drop. Rust: clipboard_files(). Разберите URL, чтобы получить его путь.

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

#Picker

API picker показывает системный выбор фотографий и передаёт приложению изображения, выбранные пользователем: Photos picker на iOS (PHPickerViewController), Photo Picker на Android, панель открытия или файловый диалог на десктопах и file input в браузере. На iOS и Android picker работает вне приложения и передаёт только то, что выбрал пользователь, поэтому разрешение на доступ к фототеке не требуется. Каждое выбранное изображение приходит как File (TSX) или PickedFile (Rust), байты которого остаются нативными: показывайте его с помощью URL.createObjectURL(file) в Image, загружайте с помощью fetch и FormData. Код на Rust вызывает pick_images из nsh_core::prelude; TSX импортирует Picker из nsh.

use nsh_core::picker::{PickRequest, PickedFile};
use nsh_core::prelude::*; // pick_images
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
}
TSRust
await Picker.pickImages(options)pick_images(request).await -> Vec<PickedFile>
multiple, limitPickRequest::new(multiple, limit) (limit: 0 означает до 100)
maxDimensionrequest.max_dimension = Some(DevicePixels(2048))
qualityrequest.quality = Some(Percentage(0.8))
formatrequest.format = PickFormat::Current
image.file.name, .type, .sizefile.name, file.mime, file.data.len()
image.sizefile.size (Size<DevicePixels>)

#Picker.pickImages

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

Показывает picker и завершается с изображениями, которые выбрал пользователь, в порядке выбора, или с [], если пользователь отменил выбор. Rust: pick_images(request) — ответ, которого нужно дождаться в задаче (spawn или функция модуля async). pickImages никогда не выбрасывает исключение: параметры вне допустимого диапазона (limit: 0, quality вне интервала от 0 до 1) отклоняют промис с RangeError, а выбор, который не разрешён возможностями (capabilities) приложения, отклоняется с ERR_ACCESS_DENIED (см. ниже). Отмена — это ответ пользователя, а не сбой: она завершается с [].

Изображения приходят готовыми к отправке:

  • Форматы, которые принимают все браузеры и серверы. JPEG, PNG, GIF и WebP приходят как есть; фотография, сохранённая в другом формате (HEIC на iPhone, HEIF, TIFF), приходит как JPEG. format: "current" сохраняет исходный формат.
  • Нативное уменьшение. С maxDimension изображение, у которого длинная сторона больше заданной, уменьшается до этого размера, поворачивается в правильную ориентацию и кодируется заново: PNG как PNG, всё остальное как JPEG с quality. Фотографии с телефона имеют от 12 до 48 мегапикселей; уменьшение до 2048 пикселей перед загрузкой делает их размером в несколько сотен килобайт. quality тоже заново кодирует JPEG.
  • Что отображается. size — размер изображения в пикселях в том виде, в каком оно отображается, с учётом ориентации камеры. Изображение, которое NSH кодирует заново, не содержит метаданных (в том числе местоположения); изображение, пришедшее как есть, сохраняет свои.

В Windows, Linux и в окнах GPUI диалог предлагает форматы, которые принимает запрос (с "compatible": JPEG, PNG, GIF и WebP), а файлы приходят как хранятся, в своём исходном размере.

Редактор сообщений в чате прикрепляет фотографию, показывает её во время набора сообщения и загружает при отправке:

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
}

Несколько фотографий сразу:

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

#Встраивающие приложения

Приложение, которое встраивает NSH и имеет собственный picker (например, со своими альбомами), обрабатывает выбор само: в Rust PickerBackend (nsh_core::picker), переданный движку (EngineConfig::picker или AppView::set_picker в приложениях GPUI и GPUI Kit), получает PickRequest каждого выбора и возвращает файлы, а системный picker не показывается. Возвращённые им файлы дорабатываются так же, как файлы платформы: обрезаются до лимита запроса, а их размеры, типы и расширения определяются по их байтам.

#Тесты

nsh test отвечает на выбор в headless-запусках: app.onPick(handler) возвращает файлы, которые выбирает пользователь ({ name, type, data }), а без обработчика выбор отменяется. См. Testing.

#Speech

API speech превращает сказанное пользователем в текст (диктовка): приложение запускает сессию, платформа слушает микрофон, а транскрипция приходит по мере роста. macOS и iOS распознают речь системным фреймворком Speech; система один раз запрашивает у пользователя доступ к микрофону и к распознаванию речи. На других платформах сессия сразу завершается с ошибкой "unavailable", поэтому проверяйте Speech.available() перед показом кнопки микрофона. Код на Rust вызывает recognize_speech из nsh_core::prelude; TSX импортирует Speech из nsh.

use nsh_core::speech::{SpeechEvent, SpeechRequest};
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";
TSXRust
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() (его удаление прерывает сессию)

#Speech.start

Запускает сессию и сразу возвращает её. Сессия сообщает о событиях в таком порядке: start, когда микрофон включён и распознаватель слушает, result при каждом изменении транскрипции (text — всегда вся транскрипция на данный момент, поэтому приложение показывает её как есть) и один end, после которого ничего не приходит. stop() прекращает прослушивание и позволяет распознавателю закончить обработку услышанного: последний результат с isFinal: true, затем end. abort() сразу завершает сессию без ошибки и отбрасывает то, о чём не было сообщено. Одновременно работает одна сессия: запуск другой прерывает текущую.

end содержит последнюю транскрипцию и, если сессия завершилась сбоем, error, названный так же, как в Web Speech API:

errorКогда
"not-allowed"Пользователь или система не разрешает доступ к микрофону или распознаванию речи.
"no-speech"Ничего не было сказано.
"audio-capture"Не удалось использовать микрофон.
"network"Распознавателю нужна сеть, но подключиться к ней не удалось.
"language-not-supported"Распознаватель не знает lang.
"unavailable"Эта платформа не распознаёт речь или не может сделать этого сейчас.
"failed"Распознаватель дал сбой по другой причине.

start выбрасывает TypeError при параметрах неверных типов и RangeError, если lang не является языковым тегом.

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);
  });
}
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

Завершается с true там, где может работать сессия: на macOS и iOS, а также везде, где встраивающее приложение распознаёт речь само.

#Info.plist

На macOS и iOS система показывает, зачем приложению нужны микрофон и распознавание речи. nsh package и nsh run записывают NSMicrophoneUsageDescription и NSSpeechRecognitionUsageDescription в Info.plist приложения, если приложение может использовать речь (его разрешение capabilities speech или оно не объявляет никаких).

#Встраивающие приложения

Приложение, которое встраивает NSH и распознаёт речь по-своему (собственным движком или сервисом), обрабатывает сессии само: в Rust SpeechBackend (nsh_core::speech), переданный движку (EngineConfig::speech), получает SpeechRequest каждой сессии и SpeechSink, в который он сообщает из любого потока, и возвращает SpeechControl, с которым ядро останавливает и прерывает сессию. Распознаватель платформы при этом не используется.

#Тесты

nsh test запускает сессии на поддельном распознавателе в headless-запусках: сессия стартует сразу, app.hear(text) сообщает text как транскрипцию на данный момент, app.endSpeech(error?) завершает её, а app.speechRequests() перечисляет сессии, которые запустило приложение. См. Testing.

#Storage

API storage хранит данные приложения на устройстве: строки по строковому ключу, которые остаются и при следующем запуске приложения. Используйте его для настроек, черновиков, последней открытой пользователем вкладки, кэша того, что прислал сервер, — любого состояния, которое должно пережить процесс приложения. Код на Rust использует nsh_core::storage::Storage, TSX импортирует Storage из nsh; оба читают и записывают одни и те же записи, поэтому приложение с Rust-частью и TSX-частью видит один набор данных.

use nsh_core::storage::Storage;
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>;
};
TSRust (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 синхронные, так как записи находятся в памяти: вызывайте их там, где выполняются компоненты (в их телах, обработчиках и задачах).

У каждого приложения своё хранилище объёмом до 5 МБ для ключей и значений. Вызовы выполняются в том порядке, в котором их делает приложение, поэтому getItem после setItem видит новое значение, не дожидаясь записи. Чтение происходит из памяти; запись достигает диска в фоне, небольшими порциями.

ПлатформаГде хранятся данные
iOSLibrary/Application Support/nsh/<app id>/storage.json в контейнере приложения
Androidfiles/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/…)
WeblocalStorage браузера, под ключами, названными по id приложения (nsh:com.example.todo:…)

Повреждённый storage.json сохраняется рядом как storage.json.corrupt, и приложение запускается с пустым хранилищем; если файл не удаётся прочитать, изменения приложения остаются в памяти, поэтому файл никогда не заменяется.

Id приложения — это id в nsh.json, и он путешествует вместе с приложением: nsh build и nsh package записывают его рядом с бандлом, dev-сервер его отправляет, а подписанный пакет несёт тот id, за который ручается сертификат его ключа выпуска. Поэтому каждое приложение хранит свои данные без каких-либо настроек, где бы оно ни работало; приложение без id хранит их под default. Встраивающее приложение может выбрать другое место (см. Embedding on iOS и Embedding on Android).

#Storage.getItem

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

Завершается со значением key или с null, если значения нет. Rust: Storage::get_item(key), None, если значения нет.

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

#Storage.setItem

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

Устанавливает для key значение value, заменяя прежнее. Отклоняется с Error, у которого code равен "ERR_NSH_QUOTA_EXCEEDED", если хранилище вырастет больше 5 МБ; значение при этом не сохраняется. Rust: Storage::set_item(key, value), который в этом случае возвращает Err(StorageFull). Значения — строки: храните структурированные данные как JSON (serde_json::to_string в Rust, JSON.stringify в TSX) и читайте их обратно (serde_json::from_str, JSON.parse).

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}");
}

#Storage.removeItem

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

Удаляет key и его значение. Удаление ключа, у которого нет значения, ничего не делает. Rust: Storage::remove_item(key).

#Storage.keys

keys(): Promise<string[]>;

Завершается со всеми ключами по порядку. Rust: Storage::get_all_keys().

#Storage.clear

clear(): Promise<void>;

Удаляет все ключи приложения, например когда пользователь выходит из аккаунта. Rust: Storage::clear().

#Хранение сигнала на устройстве

Прочитайте сохранённое значение при запуске приложения, а затем записывайте его обратно при каждом изменении сигнала:

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())))
}

#host

host обращается к приложению, которое встраивает NSH: нативному приложению вокруг NshAppView / NshView или, в вебе, к странице, на которой работает плеер. Приложение вызывает функции хоста по имени с аргументами в JSON и слушает события, которые отправляет хост.

import { host } from "nsh";
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

available(): boolean;

Показывает, есть ли на платформе хост, отвечающий на вызовы. В вебе значение равно true, когда страница передаёт плееру хост (The host on the web).

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

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

#host.call

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

Вызывает функцию хоста name с args (любое значение JSON; null, если его нет) и завершается с тем, что она возвращает. host.call никогда не выбрасывает исключение: любой сбой отклоняет промис с Error, у которого code сообщает, что произошло (сообщение предназначено для людей и может меняться):

codeКогда
ERR_NSH_HOST_ERRORФункция хоста завершилась сбоем или у хоста нет функции name; сообщение принадлежит хосту
ERR_ACCESS_DENIEDname нет в host capability приложения; permission равен "host", resource — имя
ERR_FEATURE_UNAVAILABLE_ON_PLATFORMУ платформы нет хоста: host.available() равен false
ETIMEDOUTХост не ответил вовремя
ERR_NSH_QUOTA_EXCEEDED1024 вызова уже ждут ответов встраивающего приложения

Недопустимое имя (от 1 до 128 символов из A–Z, a–z, 0–9, _, ., :, -, без ..) отклоняется с TypeError.

Вызов ждёт ответа хоста 30 секунд, затем отклоняется с ETIMEDOUT; ответ, пришедший позже, отбрасывается. options.timeout задаёт ожидание в миллисекундах, 0 — без ограничения; тайм-аут настолько большой, что часы до него никогда не дойдут (около 1e22 мс), тоже означает ожидание без ограничения, а отрицательный, NaN или бесконечный отклоняет вызов с RangeError. Время считается по часам приложения, как и его таймеры: в nsh test с ручными часами оно движется вместе с часами.

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

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

События платформы вызывают listener с полезной нагрузкой каждого event, который отправляет хост, пока не будет вызвана функция, возвращаемая host.on; слушатель, добавленный во время рендеринга компонента, прекращает работу вместе с компонентом. События, которые хост отправляет до запуска приложения, приходят, как только оно запускается, поэтому слушатели, зарегистрированные на верхнем уровне приложения, получают их; событие, отправленное до регистрации слушателя, ему повторно не доставляется. Читайте состояние, с которым запускается приложение, через host.call, а его изменения отслеживайте через host.on.

const stop = host.on("loginChanged", (payload) => console.log(payload));
// later
stop();

#Разрешения

Каждая функция, которую вызывает приложение, должна быть указана в capabilities.host из nsh.json: это точные имена или префиксы, оканчивающиеся на *, либо true для всех сразу. Это верно для любого приложения, в том числе без других capabilities: функции хоста никогда не разрешены по умолчанию. Вызов без разрешения отклоняется с ERR_ACCESS_DENIED и записывается в журнал, host.post с таким именем во встраивающее приложение выбрасывает ту же ошибку, а сборка приложения, которое импортирует host из nsh без capabilities.host, выдаёт предупреждение. События не требуют разрешения. См. Песочница и разрешения.

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

#Хост в вебе

В вебе хостом является страница. Опция host из createPlayer отвечает на вызовы, а player.hostEvent(event, payload) отправляет события:

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: web.host — это JS-модуль в каталоге приложения, путь к которому задаётся относительно nsh.json; он экспортирует createHost(player), возвращающую объект с call. nsh package --target web поставляет его вместе с сайтом, nsh run --target web запускает его на странице разработки, а nsh test запускает его для приложений, которые открывают тесты. web.connect перечисляет хосты, до которых доходят собственные запросы хоста; Content-Security-Policy страницы разрешает их наряду с capabilities.network приложения. См. Web.

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

#host.params

Параметры, которые встраивающее приложение задало до запуска вашего приложения; они читаются синхронно с первой строки вашего кода, а если ничего не задано, значением будет {}. Пока приложение работает, они не меняются: последующие запросы приходят как события.

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

#Канал Native SDK

send Native SDK хранит события, пока приложение не начнёт их слушать, до 256 штук. Когда последний слушатель останавливается во время доставки, оставшиеся события продолжают ждать в прежнем порядке; события, отправленные во время завершения работы, ждут следующего загруженного приложения. host.post(name, data) доставляет данные обработчику Native SDK, а его isPost отличает отправки (post) от вызовов (call).

#Сторона встраивающего приложения

Каждое представление держит один канал для всех приложений, которые оно загружает. Задавайте параметры до загрузки, отправляйте события в любое время из любого потока и регистрируйте обработчики по имени. Для вызовов и отправок нужно разрешение capabilities.host у приложения. Обработчик отвечает один раз, сразу или позже; если ответ так и не пришёл, вызов завершается ошибкой.

// 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();

См. Встраивание на iOS, Встраивание на Android и GPUI.

#keymap

Keymap привязывает к клавиатурным сочетаниям приложения функции, которые они запускают: нажатие, например "mod+k", и функцию, которую оно выполняет. Код на Rust вызывает on_key, on_key_with и key_label из nsh_core::prelude; TSX импортирует keymap из nsh. Оба принимают одинаковые нажатия и подчиняются одним и тем же правилам.

use nsh_core::prelude::*; // on_key, on_key_with, KeyOptions, key_label
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;
}
TSRust
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, у Mac с запущенным приложением для iPad, у планшета или Chromebook, в симуляторе и в эмуляторе. Один и тот же код работает везде; без клавиатуры ни одна клавиша не запускает привязки.

#keymap.bind

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

Запускает handler, когда нажато keystroke, и возвращает функцию, которая удаляет привязку. Обработчик получает { keystroke }: нажатие в том виде и регистре, в каком вы его написали. В Rust: on_key(keystroke, handler), обработчик которой получает само нажатие.

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" })
}

Передайте объект, чтобы привязать сразу несколько нажатий; возвращённая функция удаляет их все. В Rust каждое нажатие задаётся отдельным on_key:

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

Привязка, созданная во время рендера компонента, удаляется вместе с ним, поэтому сочетания экрана живут столько же, сколько сам экран:

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

При работе с сервером разработки правка модуля удаляет привязки, созданные его кодом верхнего уровня, прежде чем этот код выполнится снова, так что горячая перезагрузка не оставляет устаревших сочетаний.

#keymap.label

label(keystroke: string): string;

Возвращает keystroke в том виде, в каком его показывают меню и подсказки там, где работает приложение: "⇧⌘L" в macOS и iOS (а также в браузере на Mac, iPhone или iPad), "Ctrl+Shift+L" в Windows, Linux и других браузерах, для "mod+shift+l". В Rust: key_label(keystroke). Используйте её, чтобы показывать сочетания приложения теми клавишами, которые действительно работают:

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

Для недопустимого нажатия она выбрасывает TypeError, как и bind; key_label и on_key паникуют с тем же сообщением.

#Нажатия

Нажатие — это модификаторы и клавиша, соединённые через +: "mod+k", "ctrl+shift+p", "alt+enter", "escape", "f5", "cmd+-" (Command и минус).

МодификаторmacOSWindowsLinux
mod (также cmdorctrl)Command (⌘)CtrlCtrl
cmd (также command, meta, super, win)Command (⌘)клавиша WindowsSuper
ctrl (также control)Control (⌃)CtrlCtrl
alt (также option, opt)Option (⌥)AltAlt
shiftShift (⇧)ShiftShift

iOS следует macOS (mod — это Command), а Android следует Windows и Linux (mod — это Ctrl, cmd — клавиша Meta). В браузере клавиши следуют платформе, на которой он запущен: mod — это Command на Mac, iPhone или iPad и Ctrl в остальных случаях.

Используйте mod для собственных сочетаний приложения: именно эту клавишу приложения каждой платформы используют для них (⌘K в macOS, Ctrl+K в Windows и Linux), поэтому одна запись подходит везде. Клавиша Windows и Super в основном принадлежат системе, так что cmd редко уместна вне macOS.

Клавиша — это буква, цифра или знак препинания, записанные как символ, который она вводит на американской раскладке без 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 на американской раскладке, как и у сочетаний меню.

bind выбрасывает TypeError с описанием того, что не так с недопустимым нажатием (keymap.bind: invalid keystroke "cmd+kk": unknown key "kk"), и отказывается принимать нажатия, которые система оставляет за собой, так что приложение никогда не сможет их нарушить:

ПлатформаЗарезервировано
macOS, iOSCmd+Q (выход), Cmd+H (скрыть), Option+Cmd+H (скрыть остальные), Cmd+M (свернуть), Ctrl+Cmd+F (полный экран)
Linux, Windows, AndroidCtrl+Q (выход), Alt+F4 (закрыть), F11 (полный экран)
WebНет (браузер оставляет себе свои клавиши)

#Во время набора текста

Текстовому полю с фокусом клавиатуры нужны его клавиши: набор, перемещение курсора, удаление, а также выделить всё, вырезать, копировать, вставить, отменить и повторить. Поэтому по умолчанию привязка не срабатывает, пока фокус у текстового поля (или веб-представления), если её нажатие относится к этим: обычные клавиши и клавиши только с 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"Никогда, пока фокус у текстового поля
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());

В iOS и Android клавиша, с которой работает метод ввода (выбор кандидата, подтверждение), остаётся за методом ввода. В бэкенде GPUI нативный компонент с фокусом клавиатуры считается текстовым полем, так как может быть текстовым редактором. Он видит каждую клавишу первым: клавиша, которую он обрабатывает сам, до привязок не доходит, а привязки, срабатывающие во время редактирования текста, получают те клавиши, которые он пропускает.

#Какая привязка запускается

Клавиша, запустившая привязку, поглощается: она не вводится, система не издаёт звукового сигнала, а собственные клавиши NSH её не видят (Escape для возврата назад, Tab для перемещения фокуса, стрелки для прокрутки). Если у нескольких привязок одинаковое нажатие, запускается самая новая из тех, которым разрешено сработать, поэтому диалог может привязать Escape поверх привязки экрана, а привязка экрана вернётся, когда привязка диалога исчезнет. Привязки проверяются нативно в момент нажатия клавиши; обработчик запускается сразу после этого в потоке движка (Rust) или в потоке JS (TSX).

Удержание клавиши запускает её привязку один раз, до отпускания клавиши; с repeat: true она срабатывает снова при каждом автоповторе (например, чтобы переходить по списку):

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

Сочетания меню уступают привязке с тем же нажатием: сочетания меню приложения (nsh/desktop) на всех настольных платформах, а в macOS ещё и сочетания встроенных меню (Cmd+W, Cmd+C, …). Пока вы разрабатываете с nsh run, Cmd+R / Ctrl+R всегда перезагружает приложение, даже если оно привязывает mod+r; упакованное приложение может её привязать.

#nativeComponent и nativeModule

nativeComponent и nativeModule соединяют ваше приложение с тем, что предоставляет приложение-хост, по имени и во время выполнения. Приложение для iOS или Android, встраивающее NSH, может зарегистрировать собственные представления (карту, график, предпросмотр камеры) по имени; nativeComponent даёт вам типизированный компонент для одного из них. Поскольку NSH рендерит с помощью настоящих нативных представлений, такой компонент находится в иерархии представлений наравне с любым другим: NSH размещает его, он обрезается и прокручивается вместе с родителями и корректно перекрывается. Приложение, которое встраивает NSH из Rust, может также регистрировать функции, классы и компоненты Rust; nativeModule отдаёт их вам как один типизированный объект. В любом случае ваше приложение собирается стандартным nsh build: хост подключает то, что предоставляет, когда запускает приложение. Код на Rust размещает зарегистрированное представление с помощью native(name) из nsh_core::prelude, задаёт его свойства через .prop(name, value) (значение JSON либо сигнал или замыкание, которое его возвращает) или привязывает одно из них к анимируемому значению через .bind_prop(name, &value), слушает его события через .on_native_event(…) и вызывает его команды через ElementRef; TSX импортирует nsh.

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

О регистрации нативной стороны см. Нативные компоненты, а также Встраивание на iOS / Встраивание на Android.

#nativeComponent

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) — представление, свойства которого являются значениями JSON (.prop("text", json!(…))), а события приходят как NativeEvent { name, data }.

  • P — тип его собственных свойств. Каждое свойство, перечисленное в spec.props, отправляется на нативную сторону как JSON при изменении; установка его в undefined удаляет его.
  • E сопоставляет имена событий с их данными. Событие regionChange становится свойством-обработчиком onRegionChange.
  • Компонент также принимает все свойства представления, кроме children: style, class, раскладку, доступность, обработчики касаний и ref.
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 };

#Размер

Компонент размещается как представление, с тем размером, который задаёт его стиль. Если стиль оставляет размер по какому-то измерению открытым, используется размер, который сообщает нативный компонент (как изображение использует свой естественный размер).

#Команды

Его ref получает NativeComponentRef; ref.command(name, args) вызывает команду name нативного компонента с args (любое значение JSON). В Rust: ElementRef::command(name, args).

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

#Когда ничего не зарегистрировано

Если под name ничего не зарегистрировано (другое приложение-хост, хост разработки, браузер), компонент отображает пустое представление, а предупреждение записывается в журнал один раз. Сделайте так, чтобы приложение оставалось пригодным к использованию без него, или проверяйте свой хост, прежде чем его использовать.

#Пример

Значок здесь — нативное представление приложения-хоста, зарегистрированное как embed.badge. NSH размещает его, отправляет ему text и color, получает его события tap и вызывает его команду pulse:

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())))
}

#nativeModule

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

Возвращает экспорты набора, который встраивающее приложение зарегистрировало как specifier: его модули, классы, функции и Rust-компоненты, одним объектом. Тип T приложение пишет само, поэтому при его сборке ничего о хосте знать не нужно. Rust-код, собранный вместе с крейтом хоста, вызывает эти функции как Rust; nativeModule — это способ, которым к ним обращается TSX.

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));
  • Вызовы работают так же, как при импорте набора: обычная Rust-функция сразу возвращает значение, async-функция возвращает промис, а переданная вами функция становится колбэком, который Rust может вызвать позже. Rust проверяет каждый аргумент и выбрасывает TypeError при неверном.
  • Каждый вызов с одним и тем же specifier возвращает один и тот же объект.
  • Если под specifier ничего не зарегистрировано, nativeModule выбрасывает ReferenceError с этим именем. Вызывайте его там, где приложение может это обработать, или только в том хосте, для которого оно сделано.

Хост регистрирует набор из Rust с помощью set_ui_sets крейта платформы (рядом с собственными наборами NSH) до запуска своих представлений, либо EngineConfig::ui_sets:

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 (компонент или запуск модуля при горячей перезагрузке), и здесь удаляет слушатель, после чего задача завершается.

#Доступность

API доступности читает настройки доступности пользователя (запущен ли screen reader, просил ли он уменьшить анимацию) и взаимодействует со screen reader. Rust-код вызывает screen_reader(), reduce_motion() и announce_for_accessibility() из nsh_core::prelude и перемещает фокус с помощью ElementRef; TSX импортирует screenReaderEnabled(), reduceMotion(), announce() и setAccessibilityFocus() из nsh. Настройки реактивны: читайте их в binding, computed или effect, и то, что их читает, обновляется, когда пользователь их меняет. Props доступности элементов (accessibilityLabel, role, …) описаны в разделе Elements; руководство — Accessibility.

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

#screenReaderEnabled

const screenReaderEnabled: Accessor<boolean>;

Запущен ли screen reader (VoiceOver, TalkBack). Реактивно: то, что его читает, обновляется, когда пользователь включает или выключает screen reader. Rust: screen_reader() -> bool, реактивно так же.

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

#reduceMotion

const reduceMotion: Accessor<boolean>;

Просил ли пользователь уменьшить анимацию (Reduce Motion в iOS, Remove animations в Android). Реактивно. Rust: reduce_motion() -> bool. Классы Tailwind тоже следуют этой настройке с помощью варианта motion-reduce:.

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

#announce

function announce(message: string): void;

Screen reader зачитывает message (объявление VoiceOver, TalkBack). Используйте для результатов, которые появляются без перемещения фокуса, например «Сохранено». Rust: announce_for_accessibility(message).

#setAccessibilityFocus

function setAccessibilityFocus(node: ViewRef): void;

Перемещает фокус screen reader на node (ref элемента). Rust: ElementRef::set_accessibility_focus().

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))
}

#Типы

nsh также экспортирует типы реактивного API и host-элементов. Наиболее часто используемые:

ТипЧто это
Accessor<T>Читаемое значение: () => T
Signal<T>Accessor<T> с set и update
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
ViewProps, TextProps, ImageProps, ScrollProps, CommonProps, TouchPropsProps host-элементов
ViewStyle, TextStyle, InputStyle, ImageStyle, LayoutStyle, StyleProp<T>Объекты стилей; см. справочник по стилям
ColorValue, DimensionValue, LengthValue, TransformStepЗначения внутри стилей
GestureResponderEvent, MouseEvent, MouseButton, ContextMenuEvent, PressEvent, ScrollEvent, PanEvent, TouchPointПолезная нагрузка событий
Point, Size, Bounds, Edges, ModifiersСтруктуры, из которых состоят полезные нагрузки и props, с полями GPUI (nsh_core::geometry)
PixelSizeСобственный размер изображения или видео в пикселях (onLoad, Image.getSize, изображения буфера обмена и средства выбора), тогда как Size задаётся в points
ImageAssetЧто даёт импорт файла изображения ({ url, size: Size, scales? }; scales перечисляет его варианты для разной плотности пикселей)
OS"ios" | "android" | "macos" | "windows" | "linux" | "web"
AccessibilityRole, AccessibilityStateProps доступности
import { type GestureResponderEvent, type InputRef, signal } from "nsh";

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

StyleProp<T> принимает один стиль или список, объединяемый слева направо, с пропуском ложных элементов:

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