Esta página foi traduzida automaticamente e pode conter erros. Ler o original em inglês

API

nsh

nsh é o módulo principal: a API reativa (signals, valores computados, effects, context), mount, token, os elementos host View, Text, Scroll e Overlay, views selecionáveis, a área segura e o teclado, animação, o dispositivo e seu ambiente, PanResponder, a área de transferência, o seletor de fotos, o reconhecimento de fala, o armazenamento, o canal do host, atalhos de teclado, componentes e módulos nativos, acessibilidade e os tipos TypeScript de elementos, estilos e eventos. O grafo reativo em si roda nativamente em Rust; ler um signal é uma chamada ao runtime que custa alguns nanossegundos, e apenas as suas próprias closures rodam no interpretador JavaScript.

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

A reatividade do NSH segue o mesmo modelo do SolidJS: uma função de componente roda uma única vez, e as partes da UI que leem signals são atualizadas no lugar quando esses signals mudam. Não há nova renderização do componente nem DOM virtual. Veja Reatividade para o guia.

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

Um signal guarda um valor. Chamá-lo lê o valor e, dentro de um escopo de rastreamento (expressões JSX, computed, effect), inscreve esse escopo nele. set e update o escrevem; tudo o que o leu é atualizado.

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

Por padrão, uma escrita que seja === ao valor atual não faz nada. Passe equals: false para notificar a cada escrita (útil quando você muta um objeto no lugar e atribui o mesmo objeto de novo), ou a sua própria comparação:

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

Um valor derivado de signals. fn roda novamente quando um signal que ele leu muda; os leitores do valor computado só são atualizados quando o resultado muda (por equals, === por padrão). fn recebe o resultado anterior.

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

Use computed quando uma derivação for cara ou for lida em vários lugares. Para uma expressão barata, uma função simples (const full = () => first() + " " + last()) é igualmente reativa.

#effect

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

Executa fn após a renderização, e de novo sempre que um signal que ele leu mudar. Use effects para efeitos colaterais: logs, timers, comunicação com módulos nativos. Um effect pertence ao componente (ou effect) em que foi criado e é descartado junto com ele.

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

Para desfazer algo antes de o effect rodar novamente (ou quando o componente for removido), chame cleanup dentro dele.

#batch

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

Executa fn e aplica todas as suas escritas juntas no final, de modo que os leitores sejam atualizados uma vez, em vez de uma vez por escrita. Retorna o que fn retorna.

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

#untracked

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

Executa fn sem inscrever o escopo atual no que ele lê. Use-o para ler um signal dentro de um effect ou de uma expressão JSX sem depender dele.

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

#cleanup

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

Registra fn para ser executado antes de o effect atual rodar novamente, ou quando o componente ou effect atual for removido.

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

Um valor que um componente fornece a tudo o que ele renderiza, não importa a profundidade. Chamar o context (Theme()) lê o valor do provider mais próximo, ou defaultValue se não houver nenhum.

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;

Renderiza app na superfície (a janela, ou o NshAppView / NshView no qual o app está embutido) e retorna uma função que o remove. O arquivo de entrada de todo app termina com 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]>];

Divide um objeto de props nas props nomeadas em keys e no restante. As duas metades continuam reativas: props cujos valores mudam são getters, e split os mantém como getters. Use-o em um componente que trata algumas props por conta própria e repassa as outras. Desestruturar as props (const { label } = props) as leria uma vez só e perderia as atualizações.

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;

Renderiza seus filhos, ou fallback quando algo abaixo deles lança um erro: o corpo de um componente, um binding, um effect ou um manipulador de eventos. A parte que falhou é removida e o fallback aparece em seu lugar. Uma função fallback é chamada como fallback(error, reset) com o valor lançado e reset, que renderiza os filhos novamente, independentemente dos parâmetros que ela declare; um elemento fallback é exibido como está. Uma falha vai para a boundary mais próxima acima dela, e uma falha em um fallback vai para a boundary acima dessa. Componentes Rust usam catch_error (Limites de erro).

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

O design token name do tema do app ("theme" em nsh.json, sobre os tokens do shadcn/ui) na aparência exibida: uma cor como #rrggbbaa, um comprimento como um número de pontos, uma fonte como o nome da sua família. Os nomes são as variáveis CSS do tema sem -- (primary, muted-foreground, radius, font-mono, e os próprios do app, como up). A leitura é reativa: um componente, memo ou effect que lê um token roda novamente quando o tema ou a aparência muda. Tokens lista os tokens do app com seus tipos; o NSH o escreve em .nsh/types/nsh/tokens.d.ts a partir do tema. Para classes, nomeie os tokens diretamente (bg-primary, rounded-(--radius)): Tokens de tema.

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;

Os elementos host que nsh exporta, escritos como tags: <View>, <Text>, <Scroll>, <Overlay> (seus filhos por cima da janela, posicionados em relação a uma âncora: Overlay). O compilador cria a view nativa no lugar para uma tag que nomeia um deles por meio do seu import (inclusive com alias), então importá-los não custa nada em tempo de execução. Eles são componentes como quaisquer outros, portanto também podem ser passados adiante (const Tag = inline ? Text : View). nsh exporta os demais primitivos da mesma forma: List, Image, Canvas, WebView e Video, cada um com sua própria página de referência. Suas props estão documentadas em Elementos. O campo de entrada de texto, Input, vem de nsh/base.

#Área segura e teclado

As variáveis de ambiente do CSS mantêm o conteúdo longe da UI do sistema, no padding e na margin de qualquer view (padding, paddingTop, …, as mesmas chaves de margin; os valores arbitrários p* / m* do Tailwind; os métodos Styled .p_safe_area(), .pt_safe_area_plus(..), .pb_keyboard(), … em Rust). Seus valores são EnvValues: env(name), env(name, <length>), calc(env(name) + <length>) e calc(<length> + env(name)).

Variável Valor
safe-area-inset-top, -right, -bottom, -left Os insets da view raiz em que o nó é renderizado: os da página mais próxima (abaixo do seu cabeçalho), os do diálogo mais próximo, ou então a área segura da superfície. Veja Área segura.
keyboard-inset-height A altura do teclado virtual sobre a superfície, 0 enquanto ele está oculto; as mudanças são animadas nativamente junto com o teclado. Veja Teclado.
<View style={{ flex: 1, paddingTop: "env(safe-area-inset-top)", paddingBottom: "env(keyboard-inset-height)" }}>…</View>

#safeArea e safeAreaInsets

safeArea() é a área segura da superfície (safe_area() em Rust); safeAreaInsets() são os insets de onde um componente é renderizado (cx.insets() em Rust), os valores das variáveis safe-area-inset-*. Ambos são Edges reativos ({ top, right, bottom, left } em pontos). Veja safeArea e safeAreaInsets.

#Keyboard e keyboardFrame

Keyboard.dismiss() oculta o teclado virtual (dismiss_keyboard() em Rust) e keyboardFrame() informa quanto da tela ele cobre (keyboard_height() e keyboard_duration() em Rust). Veja Ocultando o teclado e A altura do teclado.

#Views selecionáveis

<View selectable> (view().selectable(true) em Rust) faz com que os textos dentro dele sejam selecionados em conjunto, como os de uma página web: uma única seleção percorre views, elementos Text e TextViews, em ordem de leitura. Veja Seleção de texto.

#Animação

A API de animação anima na thread de UI. Você cria um valor animado, o vincula (ou vincula valores derivados dele) a uma propriedade de estilo e inicia uma animação; a partir daí, cada frame é calculado nativamente, sem executar o código do app. Um engine ou uma thread JS ocupados não conseguem fazer a animação travar, e uma animação não custa ao app nenhum trabalho por frame. O código Rust usa animated_value(), motion(), Animation, Spring, Timing, KeyframeTiming, MotionPreset e Easing de nsh_core::prelude e vincula valores com os métodos .bind_* dos elementos; o TSX os importa de nsh.

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

Veja Animação para o guia.

#O que um valor animado pode controlar

Onde Rust TSX
opacity .bind_opacity(&value) style={{ opacity: value }}
backgroundColor .bind_background(&value.interpolate_colors(&[0.0, 1.0], &[white(), black()])) style={{ backgroundColor: value.interpolate({ inputRange: [0, 1], outputRange: ["#fff", "#000"] }) }}
borderColor, color .bind_border_color(&node), .bind_color(&node) O mesmo: um nó de cores. borderColor colore todos os lados; color de um Text colore o texto inteiro
transform .bind_translate_x(&value), .bind_translate_y, .bind_scale, .bind_rotate (graus) translateX, translateY, scale, scaleX, scaleY, rotate, rotateX, rotateY, rotateZ em steps: transform: [{ translateX: value }]
Posição de rolagem .scroll_value(&value) em uma scroll view ou em uma lista <Scroll scrollValue={value}> e <List scrollValue={value}> gravam o deslocamento de rolagem nele a cada frame
Gestos .pan_values(Some(&x), Some(&y)), .pinch_value(&scale), .rotation_value(&angle) pan={{ x, y }}, pinch={{ scale }}, rotation={{ angle }} em qualquer elemento: o gesto grava os valores diretamente (veja Toque e gestos)
Onde as views ficam fixas .sticky().bind_sticky_top(&value) (.bind_sticky_left em uma scroll view horizontal), .bind_sticky_header_offset(&value) em uma lista style={{ position: "sticky", top: value }}, <List stickyHeaderOffset={value}>: views e cabeçalhos fixos ficam a essa distância do início da viewport, acompanhando o valor a cada frame
Canvas um argumento animado de uma chamada de um painter Qualquer argumento numérico ou de cor de uma chamada de desenho de canvas
Shader .uniform(name, &value) Um membro de struct Uniforms de um shader: uniforms={{ name: value }} (números, ou um nó de cores para um vec3f / vec4f)

Outras propriedades de estilo aceitam valores simples; para animar o próprio layout (tamanhos, posições no fluxo), use LayoutAnimation.

#createAnimatedValue

function createAnimatedValue(value?: number): AnimatedValue;

Cria um valor animado começando em value (padrão 0). Rust: animated_value(value), um handle Copy mantido pelo componente que o criou.

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 }>;
}
Método Rust Descrição
set(value) set(value) Salta para value, interrompendo qualquer animação em andamento.
stop() stop(), depois value().await Interrompe a animação em andamento no ponto em que está; resolve com { value }, o valor naquele ponto.
getValue() value().await Resolve com o valor atual (com seu offset), lido na thread de UI.
setOffset(offset) set_offset(offset) Um offset somado ao valor: os bindings enxergam valor + offset; set, as animações e os arrastos movem o valor.
flattenOffset() flatten_offset() Soma o offset ao valor e o zera.
extractOffset() extract_offset() Move o valor para o offset e zera o valor (um arrasto então começa de onde o último terminou).
timing(to, options) timing(to, ms, easing), timing_with(to, Timing { … }) Anima até to ao longo de uma curva de easing. duration em ms (padrão 300), easing (padrão "easeInOut"), delay em ms (padrão 0).
spring(to, options) spring(to, Spring { … }) Faz uma mola até to. stiffness (padrão 170), damping (padrão 26), mass (padrão 1), velocity (velocidade inicial, unidades por segundo).
decay(options) decay(velocity, deceleration), decay_within(velocity, deceleration, (min, max), bounce) Desliza a partir de velocity (unidades por segundo) e desacelera em deceleration por milissegundo (padrão 0.998). Use-o após um fling, com a velocidade de onPanEnd. Com clamp: [min, max] ele para em um limite, ou quica de volta a partir dele mantendo bounce (0..1, padrão 0) da sua velocidade.
keyframes(frames, options) keyframes(frames, KeyframeTiming) Reproduz keyframes como os keyframes CSS: um easing por segmento, iterations vezes em direction.

Cada método de animação retorna uma promise que resolve com { finished }: true quando a animação terminou, false quando foi interrompida (por stop, set ou outra animação do mesmo valor). Em Rust ele retorna um AnimationEnd, que uma task aguarda para obter o mesmo 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";

Um nó do grafo de animação nativo: um AnimatedValue, ou um valor derivado de outros. Os nós derivados são avaliados na thread de UI sempre que suas entradas mudam.

Método Rust Descrição
interpolate(config) interpolate(&input, &output), interpolate_colors(&input, &colors), interpolate_with(&input, output, Interpolation { … }) Mapeia a entrada por meio de intervalos por partes. outputRange contém números, cores ("#ff0000", "rgba(…)") ou ângulos ("45deg", "1rad"). Fora de inputRange ele limita por padrão; extrapolate (ou extrapolateLeft / extrapolateRight) pode ser "extend" (continuar a reta), "clamp" ou "identity" (repassar a entrada). easing molda cada segmento. Rust aceita números, cores do tema (que acompanham a aparência) e ângulos em graus para bind_rotate.
diffClamp(min, max, options?) diff_clamp(min, max), diff_clamp_with(min, max, DiffClamp::snapping()) Acompanha as mudanças da entrada, mas mantém o resultado dentro de min..max: um cabeçalho que se oculta enquanto o conteúdo rola para cima e volta assim que rola para baixo. snap: true o acomoda no limite mais próximo em 200 ms quando sua scroll view para, em max somente depois que a entrada atingiu max.
add, sub, mul, div add, sub, mul, div Aritmética com outro nó ou com uma constante.
mod(b) modulo(b) Módulo, nunca negativo para um b positivo (mantendo ângulos em 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) reproduz um valor por meio de keyframes e resolve com { finished }, indicando se terminaram; Animated.keyframes(value, { keyframes, ...options }) equivale a um passo de uma composição. Rust: value.keyframes(frames, KeyframeTiming) e Animation::keyframes(&value, frames, KeyframeTiming), com Keyframe::new(value) (distribuídos uniformemente), Keyframe::at(offset, value) e .ease(easing), e KeyframeTiming::new(ms) com .delay(ms), .easing(e), .iterations(Some(n)), .infinite() e .direction(PlaybackDirection::…).

Eles são reproduzidos como os keyframes de CSS: um easing molda um segmento (o de um keyframe é o segmento que começa nele; o do timing vale para todos os outros); offsets ausentes nos keyframes são distribuídos uniformemente, o primeiro em 0 e o último em 1; cada iteração começa com um salto para o seu primeiro keyframe, e sem um keyframe em 0 o valor parte de onde está; durante um delay o valor mantém o primeiro keyframe (CSS animation-fill-mode: backwards); iterações para trás executam os segmentos de trás para frente, com as curvas invertidas; o valor permanece onde a última iteração o deixa.

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

Cria um motion: um valor animado para cada canal da opacidade e da transformação de uma view, em repouso (opacidade e escalas 1, os demais 0), e motions para reproduzir nesses canais de forma nativa. Associe-o com style={motion.style}, ou aplique motion.style ou motion.transform com spread em um style; a transformação aplica suas translações, depois suas rotações, depois suas escalas, com scale incorporado em scaleX e scaleY: 7 dos 8 passos de transformação de uma view, o que deixa um para o seu ([{ perspective: 600 }, ...motion.transform] para flips 3D). initial define onde os canais começam ({ opacity: 0 }; os demais começam em repouso). Com enter, os canais que ele move começam onde esse motion começa (em initial) e ele é reproduzido imediatamente, com as demais opções como seu timing (delay, duration, …). Um motion move apenas opacidade e transformação: aplicar motion.style com spread define o opacity da view, então associe apenas motion.transform para manter uma opacidade sua, e mova cores com um valor animado e interpolate. Rust: motion(), um handle Copy mantido pelo componente que o criou, associado com .bind_motion(&m); motion().entering(spec) faz a entrada.

Membro Rust (Motion) Descrição
opacity, translateX, … scaleY value(MotionChannel::Opacity), … Os valores animados dos canais.
style, transform .bind_motion(&m) no elemento As associações.
play(motion, options?) play(spec), play_ignoring_reduce_motion(spec) Reproduz um preset pelo nome ou um MotionSpec nos canais; um novo interrompe o que está em reprodução. options alteram seu timing. Resolve com { finished }: true quando terminou, false quando algo o interrompeu (AnimationEnd em Rust).
animation(motion, options?) animation(spec) O mesmo motion como um CompositeAnimation, não iniciado, para compor: 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)))). Movimento reduzido é decidido no momento em que ele é criado.
stop() stop() Para onde está.
reset() reset() Para e devolve todos os canais ao repouso.

Quando o usuário pede menos movimento (reduceMotion()), play, animation e enter chegam de uma vez ao ponto onde o motion termina, sem se mover, a menos que ignoreReduceMotion esteja definido (nas opções ou no motion).

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

Os motions prontos como dados: motion.play("shake") reproduz motions.shake. MotionName é um entre fadeIn, fadeInUp, fadeInDown, fadeInLeft, fadeInRight, zoomIn, popIn, bounceIn, slideInUp, slideInDown, flipInX, flipInY (entrada); fadeOut, fadeOutUp, fadeOutDown, fadeOutLeft, fadeOutRight, zoomOut, popOut, slideOutDown, slideOutUp (saída); shake, headShake, wiggle, swing, pulse, heartbeat, rubberBand, jello, tada, jump, flash (atenção); spin, float, breathe, sway, bob, glow (loop). Animação descreve cada um. Aplique um preset com spread para alterá-lo: motion.play({ ...motions.fadeInUp, duration: 600 }).

Um MotionSpec é um motion seu: os keyframes de cada canal ({ opacity: [0, 1], translateY: [24, 0] }), ou uma lista de frames como a Web Animations API os escreve ([{ opacity: 0, translateY: 24 }, { opacity: 1, translateY: 0 }], com um frame sem offset posicionado pela sua posição na lista), com o timing de keyframes. Rust: MotionSpec::new(KeyframeTiming) com .values(channel, values) (distribuídos uniformemente), .track(channel, keyframes) e .timing(|t| …); MotionPreset::FadeInUp (ou .spec()) é um preset, e MotionChannel nomeia os canais.

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 monta animações a partir de passos (timing, spring, decay, sequence, parallel, …). Nada é executado até start(); então a árvore inteira é executada de forma nativa, e cada passo começa na thread de UI no exato momento em que o anterior termina (sem intervalo de frame, sem ida e volta ao código do app entre os passos). Rust monta a mesma árvore com Animation.

Função Rust Descrição
timing, spring, decay Animation::timing(&value, to, Timing), Animation::spring(&value, to, Spring), Animation::decay(&value, velocity, deceleration) Uma animação de um valor; mesmas opções e padrões dos métodos de AnimatedValue, com o alvo como toValue.
sequence(list) Animation::sequence(list) Executa as animações uma após a outra.
parallel(list, { stopTogether }) Animation::parallel(list).stop_together(…) Executa-as ao mesmo tempo. Com stopTogether (padrão true), parar uma para todas.
stagger(ms, list, { from }) Animation::stagger(ms, list), Animation::stagger_from(ms, StaggerFrom::Center, list) Executa-as em paralelo, cada uma começando ms depois da anterior, contado a partir de from: "first" (padrão), "last", "center" (a do meio começa de imediato e as outras se espalham a partir dela; com uma quantidade par, os dois itens do meio começam de imediato) ou um índice (StaggerFrom::First, Last, Center, Index(i)).
keyframes(value, { keyframes, ...timing }) Animation::keyframes(&value, frames, KeyframeTiming) Um valor por meio de keyframes.
delay(ms) Animation::delay(ms) Espera; útil dentro de sequence.
loop(animation, { iterations, resetBeforeIteration }) animation.looped(iterations).reset_before_iteration(…) Repete animation por iterations vezes (para sempre por padrão), restaurando seus valores antes de cada execução, a menos que resetBeforeIteration seja false.
add, subtract, multiply, divide, modulo, diffClamp os métodos do nó O mesmo que os métodos de AnimatedNode, como funções.
sampleSpring, sampleDecay nsh_core::element::sample_spring, sample_decay Os valores de uma spring ou de um decay, um por frame (veja Easing).

CompositeAnimation:

Método Rust (Animation) Descrição
start(callback?) start(), um AnimationEnd para aguardar Executa (novamente). Resolve, e chama callback, uma única vez para a árvore inteira com { finished }: se terminou em vez de ter sido interrompida.
stop() stop() Para onde está.
reset() reset() Para e restaura seus valores para onde estavam quando começou.
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;
};

Um easing é dado, não uma função do app: o runtime o avalia de forma nativa. Em Rust, é o enum Easing: Linear, EaseIn, EaseOut, EaseInOut, Bezier(x1, y1, x2, y2), Steps(count, StepPosition) e Curve(curve, mode), com Curve (Quad, Cubic, Poly(n), Sin, Circle, Exp, Elastic(bounciness), Back(s), Bounce) e EaseMode (In, Out, InOut). Para desenhar um, Easing.sample(easing, count) (Rust: nsh_core::element::sample_easing) fornece seus valores em count pontos igualmente espaçados de 0 a 1; Animated.sampleSpring e Animated.sampleDecay (sample_spring, sample_decay) fornecem os valores de uma spring ou de um decay, um por frame (a fps, 60 por padrão) a partir de from (0) até terminar. Os três vêm do código nativo que as animações executam. Onde quer que Easing seja aceito em TSX, você pode passar um nome ("easeOut"), uma Bézier cúbica como quatro números, ou uma curva do objeto Easing.

Curva Rust Forma
quad Curve::Quad t²
cubic Curve::Cubic t³
poly(n) Curve::Poly(n) tⁿ (n é 2 por padrão)
sin Curve::Sin Senoidal
circle Curve::Circle Circular
exp Curve::Exp Exponencial
elastic(bounciness) Curve::Elastic(bounciness) Ultrapassagem elástica (bounciness é 1 por padrão)
back(s) Curve::Back(s) Recua antes de avançar; s é a ultrapassagem (padrão 1.70158)
bounce Curve::Bounce Quica no final

Easing.steps(count, position) equivale ao steps() do CSS: o valor salta entre count níveis em vez de se mover (um sprite quadro a quadro, um ponteiro que marca os segundos). position indica onde os saltos ocorrem: "jump-end" (o padrão, também "end"), "jump-start" ("start"), "jump-none" ou "jump-both"; Rust: Easing::Steps(count, StepPosition::JumpEnd), JumpStart, JumpNone, JumpBoth.

As curvas estão na forma “in” (lentas no início). Easing.out(curve) inverte uma, Easing.inOut(curve) a torna simétrica, Easing.in(curve) a mantém como está; em Rust, isso é o EaseMode de 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) anima de forma nativa as mudanças de layout do próximo commit: views que se movem ou mudam de tamanho deslizam até seus novos frames, novas views aparecem (com fade ou escala de entrada, conforme property) e views removidas desaparecem. Chame logo antes da mudança de estado que altera o layout. Rust: animate_layout(LayoutAnimation), com os presets de nsh_core::element::LayoutAnimation ou suas transições create, update e delete.

Membro Rust Descrição
configureNext(config, onEnd?) animate_layout(config) Anima as mudanças do próximo commit; onEnd é executado ao terminar.
create(duration, type?, property?) LayoutAnimation::all(LayoutTransition::new(curve, duration)) Uma configuração que usa a mesma curva nas três fases.
Presets.easeInEaseOut LayoutAnimation::EASE_IN_EASE_OUT 300 ms, com views aparecendo e sumindo com fade.
Presets.linear LayoutAnimation::LINEAR 500 ms em velocidade constante.
Presets.spring LayoutAnimation::SPRING 700 ms; os frames têm efeito de spring (damping 0.4), as views aparecem e somem com fade linear.
easeInEaseOut(onEnd?), linear(onEnd?), spring(onEnd?) animate_layout(LayoutAnimation::…) Atalhos para configureNext(Presets.…).
Types, Properties LayoutCurve, LayoutProperty Os nomes, como constantes.
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

A API de device informa ao app sobre o dispositivo e seu ambiente. O código Rust chama as funções de nsh_core::prelude (window(), dark(), font_scale(), locale(), lifecycle(), os(), idiom(), pixel_ratio(), set_color_scheme(), …); TSX as importa de nsh. O tamanho da janela, o esquema de cores, a escala de fonte, a razão de pixels, o locale e o estado do app são reativos: leia-os em um binding (uma closure passada a um elemento em Rust, JSX em TSX), computed ou effect, e o que os lê é atualizado quando a plataforma reporta uma mudança (rotação, redimensionamento da janela, troca para o modo escuro, mudança do tamanho do texto ou do idioma, mudança para uma tela de outra densidade, saída do app e retorno).

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

Consulte o guia Dispositivo e ambiente.

#windowSize

const windowSize: Accessor<Size>;

O tamanho da superfície em pontos: a janela, ou o NshAppView / NshView incorporado. Reativo. 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">;

A aparência exibida: a do sistema, a menos que o app tenha definido a sua (nsh.json "appearance", setColorScheme). Reativo. Rust: dark() -> bool; as cores do tema (AdaptiveColor) e a variante de estilo .dark(…) a acompanham sem precisar lê-la. A variante dark: do Tailwind também a acompanha (veja Tailwind).

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

#fontScale

const fontScale: Accessor<number>;

O tamanho de texto preferido do usuário em relação ao padrão (1 é o tamanho padrão; Dynamic Type no iOS, tamanho da fonte no Android), ou o tamanho de texto que o app hospedeiro define no lugar. Reativo. Rust: font_scale() -> f32. O texto já escala com ele, a menos que allowFontScaling={false}; leia-o quando outros tamanhos devam acompanhá-lo, como ícones ao lado de texto.

#locale

const locale: Accessor<string>;

O locale do usuário como uma tag BCP 47 ("en-US", "zh-Hans-CN"), ou o locale que o app hospedeiro define no lugar. Reativo. Rust: locale() -> Locale (locale().as_str() é a tag). Intl formata para ele por padrão; leia-o para escolher as strings das suas próprias traduções (Dispositivo e ambiente).

#lifecycle

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

Se o app está em uso, nos termos do AppState do React Native: "active" em primeiro plano e recebendo entrada, "inactive" enquanto visível sem o foco (outro app ou janela o tem, ou o sistema exibe algo sobre ele, como o seletor de apps ou uma chamada recebida), "background" enquanto não visível (em segundo plano, minimizado, uma aba de navegador oculta). Um app exibido dentro de um app nativo segue o estado desse app. Reativo. Rust: lifecycle() -> Lifecycle (Lifecycle::Active, Inactive, Background).

Leia-o em um effect para pausar trabalho que ninguém vê e retomá-lo quando o app voltar:

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

#setColorScheme

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

Exibe o app em scheme independentemente da aparência do sistema, de imediato: colorScheme(), as classes dark:, o tema dos componentes e os controles da plataforma (alertas, o estilo padrão da barra de status) a acompanham; "system" volta a seguir o sistema. nsh.json "appearance" a define desde o início. 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;
}

A plataforma em que o app é executado, constante enquanto ele roda.

Membro Rust Descrição
os os() -> Os (os().name() é a string do TS) O sistema operacional. iOS, o simulador de iOS e o Mac Catalyst reportam "ios", e os navegadores reportam "web".
version os_version() -> (u16, u16) iOS e desktops: a versão do SO ("17.5"); Android: o nível da API ("35"); web: "0.0". O Rust fornece as versões principal e secundária, ou o nível da API e 0 no Android.
idiom idiom() -> Idiom O tipo de dispositivo: "desktop" exatamente onde desktop é true; nos demais casos, "phone", "pad" (um iPad, um tablet Android, o Mac Catalyst no idioma iPad), "mac" (o Mac Catalyst no idioma Mac do UIKit, um idioma mobile) ou "tv" (Apple TV, Android TV).
desktop is_desktop() -> bool Aplica-se o comportamento de desktop: macOS, Windows, Linux e navegadores com mouse e janela larga. As props desktop={{…}}, select({ desktop }) e idiom "desktop" seguem todas esse valor. O Mac Catalyst executa o backend de iOS, então é mobile: as props ios={{…}} se aplicam nele. O isAvailable de nsh/desktop é a verificação mais restrita para as APIs de janela e menu, que existem no macOS, no Windows e no Linux.
catalyst is_catalyst() -> bool O app é executado no Mac Catalyst, no idioma Mac ou iPad (os é "ios").
select(spec) match os() { … } O valor para esta plataforma (veja abaixo).
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;
}

Retorna o valor para o SO atual (ios, android, web, …); senão desktop ou mobile (conforme platform.desktop); senão native (todas as plataformas, exceto a web); senão default; senão undefined. O código Rust usa match em os() (e em is_desktop() para 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>;

Pixels do dispositivo por ponto (p. ex. 3 na maioria dos iPhones). É reativo: muda quando a janela vai para uma tela de outra densidade (no Mac Catalyst, nos desktops ou em um monitor externo) e quando a configuração de tamanho de exibição do Android altera a densidade. Rust: pixel_ratio() -> f32, e round_to_nearest_pixel(points) para um comprimento em pixels inteiros do dispositivo; em TSX, esse comprimento é Math.round(x * pixelRatio()) / pixelRatio().

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

#openUrl

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

interface OpenUrlResult {
  opened: boolean;
}

Abre url fora do app: uma página web no navegador, um link mailto: no app de e-mail, um link tel: no app de telefone e qualquer esquema que outro app tenha registrado (maps:, os links lb:// próprios de um app host). Resolve com { opened }, indicando se algo o abriu: false quando nenhum app trata o esquema. Rust: open_url(url) de nsh_core::prelude, uma resposta para aguardar em uma task (ou descartar), o mesmo bool.

button()
    .child(text("Contact us"))
    .on_press(|| {
        spawn(async {
            if !open_url("mailto:help@example.com").await {
                log::warn!("no mail app");
            }
        });
    })
  • A URL é absoluta (tem um esquema). URLs que executariam código ou acessariam os dados do próprio app, javascript:, data:, blob:, file:, about: e asset:, rejeitam a promise com um TypeError, assim como endereços relativos. openUrl nunca lança exceção: toda falha rejeita.
  • Um app que declara capabilities precisa de openUrl: "openUrl": true para todos os esquemas, ou "openUrl": { "schemes": ["https", "mailto"] } para os listados; as demais URLs rejeitam com um Error cujo code é "ERR_ACCESS_DENIED", permission "openUrl" e resource a URL (veja Sandbox e capabilities). Em Rust, open_url responde false nesses casos e registra o motivo no log.
  • O app que embute o Native Shell vê a URL primeiro e pode encaminhá-la ao seu próprio roteador (Swift NshAppView.onOpenURL, Java NshView.setOpenUrlHandler, Rust AppView::host().handle_open_url; veja iOS e Android). opened então é true.
  • Cada plataforma a abre com seu próprio mecanismo: UIApplication.open no iOS (o application do app host quando embutido), uma intent ACTION_VIEW no Android, NSWorkspace no macOS, ShellExecuteW no Windows, GtkUriLauncher no Linux, o open_url do GPUI e uma nova aba na web. nsh test registra as URLs em vez de abri-las.
  • Um TextView abre seus links dessa forma com links="open".

#Direção do layout

A direção do layout segue o idioma do usuário: em um idioma da direita para a esquerda (árabe, hebraico, …), o app é disposto da direita para a esquerda, as linhas partem da direita, e as propriedades de estilo left / right (marginLeft, paddingRight, …) significam início e fim. As mudanças se aplicam imediatamente, sem reiniciar.

O estilo direction de uma view ("ltr", "rtl" ou "inherit", o padrão) define a direção da sua própria subárvore; em Rust, .direction(LayoutDirection::..).

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

#Relacionados

  • Os safe area insets (Rust safe_area(); TSX safeArea(), safeAreaInsets()) e o teclado virtual (Rust keyboard_height(); TSX keyboardFrame()) estão em Safe area e teclado.
  • As configurações de leitor de tela e de redução de movimento estão em Acessibilidade.

#PanResponder

Pan responders são gestos de arrastar construídos sobre o sistema de gesture responder: o elemento reivindica os toques de um gesto e passa a recebê-los sozinho, junto com o estado do gesto, que acompanha para você o centroide dos toques, a distância percorrida e a velocidade. O código Rust os reivindica com .claim_touches(TouchClaim::…) e os escuta com listeners .on_responder_* cujo ResponderEvent carrega um PanGesture, tudo em nsh_core::prelude; o TSX importa PanResponder de nsh, cujos callbacks onPanResponder… recebem o evento de toque e o estado do gesto.

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

#PanResponder.create

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

interface PanResponderInstance {
  panHandlers: TouchProps;
}

Cria um pan responder. Espalhe seu panHandlers na view que deve receber o gesto. Em Rust, a view reivindica os toques e escuta por conta própria:

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

#Callbacks

Todo callback recebe o evento de toque (GestureResponderEvent, de nsh) e o estado do gesto. Em Rust, uma reivindicação decide com .ask(|request: &ClaimRequest| …), chamando request.claim() para assumir os toques (sem ask ela sempre os assume), e cada listener recebe um ResponderEvent cujo gesture é o estado.

type PanHandler<R = void> = (e: GestureResponderEvent, gestureState: PanResponderGestureState) => R;
Callback Rust Retorna Chamado quando
onStartShouldSetPanResponder .claim_touches(TouchClaim::on_start()) boolean Um dedo toca a tela: retorne true para se tornar o responder. Consultado da view tocada para fora.
onStartShouldSetPanResponderCapture TouchClaim::on_start().capture() boolean O mesmo, consultado de fora para dentro antes do de bubbling (um pai tomando o gesto de seus filhos).
onMoveShouldSetPanResponder TouchClaim::on_move().ask(…) boolean Um dedo se move: retorne true para se tornar o responder (p. ex. somente depois de Math.abs(g.dx) > 10).
onMoveShouldSetPanResponderCapture TouchClaim::on_move().capture() boolean O mesmo, na fase de captura.
onPanResponderGrant .on_responder_grant(…) Esta view agora é o responder: o gesto começa.
onPanResponderReject .on_responder_reject(…) Outra view é o responder e não quis ceder.
onPanResponderStart .on_responder_start(…) Um dedo tocou a tela enquanto a view respondia.
onPanResponderMove .on_responder_move(…) Os dedos se moveram.
onPanResponderEnd .on_responder_end(…) Um dedo foi levantado enquanto a view respondia.
onPanResponderRelease .on_responder_release(…) O último dedo foi levantado: o gesto terminou.
onPanResponderTerminate .on_responder_terminate(…) Outra view (ou um gesto nativo, como uma scroll view) assumiu os toques.
onPanResponderTerminationRequest .keep_touches(true) mantém boolean Outra view quer os toques: retorne false para mantê-los (padrão true).
onShouldBlockNativeResponder TouchClaim::block_native(…) boolean Indica se as scroll views nativas devem ser impedidas de assumir os toques enquanto a view responde (padrão 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, com pontos como Point<Points>.

Campo Rust Descrição
stateID Identifica o gesto.
moveX, moveY position Última posição do centroide dos toques, em pontos da superfície.
x0, y0 origin Onde o centroide estava quando o responder foi concedido.
dx, dy delta Distância percorrida desde a concessão.
vx, vy vx, vy Velocidade em pontos por segundo.
numberActiveTouches active_touches Dedos na tela.

#Dentro de scroll views

Enquanto um pan responder é o responder, as scroll views nativas ao redor dele não rolam (onShouldBlockNativeResponder tem como padrão true). Para que um deslize horizontal conviva com a rolagem vertical, torne-se o responder apenas em movimentos horizontais:

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

Para um pan que a plataforma reconhece, TouchClaim::on_pan(PanActivation::horizontal(10.0, 10.0)) decide na thread principal conforme os dedos se movem. As props de responder subjacentes (onStartShouldSetResponder, onResponderGrant, onTouchStart, …) também podem ser usadas diretamente em qualquer elemento; veja Elementos.

#Clipboard

A API Clipboard lê e grava a área de transferência do sistema: texto em todo lugar (UIPasteboard no iOS, ClipboardManager no Android, NSPasteboard, GDK e a área de transferência do Win32 nos desktops, navigator.clipboard na web), imagens no iOS, nos desktops e na web, e os arquivos copiados em um gerenciador de arquivos nos desktops e no Mac Catalyst. O código Rust chama as funções de nsh_core::prelude (set_clipboard, clipboard_string, …); o TSX importa Clipboard de 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
}
TS Rust (nsh_core::prelude)
await Clipboard.writeText(text) set_clipboard(text)
await Clipboard.readText() clipboard_string().await -> String
await Clipboard.hasText() clipboard_has_string().await -> bool
await Clipboard.writeImage(src) set_clipboard_image(src)
await Clipboard.readImage() clipboard_image().await -> Option<ClipboardImage>
await Clipboard.hasImage() clipboard_has_image().await -> bool
await Clipboard.readFiles() clipboard_files().await -> Vec<String>

As leituras em Rust retornam respostas para aguardar em uma task (spawn, ou uma função de módulo async); ClipboardImage é nsh_core::element::ClipboardImage, com url e size (um Size<DevicePixels>).

As imagens passam pela área de transferência no iOS, nos desktops e na web. No Android, a área de transferência carrega texto: writeImage e readImage rejeitam com um Error cujo code é "ERR_FEATURE_UNAVAILABLE_ON_PLATFORM", e hasImage resolve false. Em Rust, set_clipboard_image deixa a área de transferência como está nesse caso e clipboard_image responde None.

O usuário também pode copiar por conta própria de um texto selecionável; a API Clipboard serve para copiar a partir do código, como em um botão Copiar.

#Clipboard.writeText

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

Coloca text na área de transferência, substituindo o que havia nela. 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>;

Resolve com o texto da área de transferência, ou "" quando ela não contém nenhum. Rust: clipboard_string(). No iOS, ler a área de transferência pode exibir o aviso de colagem do sistema (“App colou de …”), e no Android 10 e posteriores somente o app em primeiro plano pode lê-la. Leia-a quando o usuário pedir para colar, não em toda tela.

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

#Clipboard.hasText

hasText(): Promise<boolean>;

Resolve com a informação de se a área de transferência contém texto, sem lê-la: sem aviso de colagem no iOS. Rust: clipboard_has_string(). Use-o para decidir se deve exibir um botão Colar. Na web, os navegadores só informam isso lendo o conteúdo, então pode ser solicitada permissão ao usuário.

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

#Clipboard.readImage

readImage(): Promise<ClipboardImage | null>;

Resolve com a imagem da área de transferência, ou null quando ela não contém nenhuma: uma captura de tela colada, ou uma imagem copiada em um navegador ou em um editor de imagens. Rust: clipboard_image(), None quando não contém nenhuma. url é uma URL da imagem (PNG, ou o JPEG, GIF, WebP ou BMP da própria área de transferência) que uma Image exibe como seu src e que fetch lê: fetch(url) fornece seus bytes, para um upload. size (width e height) é seu tamanho em pixels, como decodificada. Assim como em readText, leia-a quando o usuário pedir para colar. Imagens de até 48 MB (codificadas) são lidas, de modo que a imagem caiba na memória do app; uma imagem maior rejeita com um Error cujo code é "ERR_NSH_QUOTA_EXCEEDED" (sua mensagem informa o tamanho da imagem e o limite), enquanto hasImage resolve true. Rust: clipboard_image() responde None nesse caso.

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
}

Um app no estilo chat em que os usuários colam capturas de tela em um campo de composição associa o atalho de colar enquanto seu input tem o foco e pega a imagem quando houver uma:

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

Resolve com a informação de se a área de transferência contém uma imagem, sem lê-la: sem aviso de colagem no iOS. Rust: clipboard_has_image(). No Android, resolve false.

#Clipboard.writeImage

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

Coloca na área de transferência a imagem de src, uma URL data: em base64 ou a url que readImage forneceu, substituindo o que ela contém. Rust: set_clipboard_image(src). No Windows, aceita um PNG, o formato que navegadores, o Office e a Ferramenta de Captura leem. No Android, rejeita com "ERR_FEATURE_UNAVAILABLE_ON_PLATFORM".

set_clipboard_image(image.url.clone());

#Clipboard.readFiles

readFiles(): Promise<string[]>;

Resolve com as URLs file: dos arquivos e pastas copiados em um gerenciador de arquivos (Finder, Explorer, Files), na mesma ordem, ou []: as mesmas URLs que um drop de arquivos fornece. Rust: clipboard_files(). Faça o parse de uma URL para obter seu caminho.

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

#Picker

A API de picker mostra o seletor de fotos do sistema e entrega ao app as imagens escolhidas pelo usuário: o Photos picker no iOS (PHPickerViewController), o Photo Picker no Android, um painel de abertura ou diálogo de arquivo nos desktops e um file input em um navegador. No iOS e no Android o seletor roda fora do app e entrega apenas o que o usuário escolhe, então não precisa de permissão para a biblioteca de fotos. Cada imagem escolhida chega como um File (TSX) ou um PickedFile (Rust) cujos bytes permanecem nativos: mostre-a com URL.createObjectURL(file) em um Image, envie-a com fetch e FormData. O código Rust chama pick_images de nsh_core::prelude; o TSX importa Picker de 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
}
TS Rust
await Picker.pickImages(options) pick_images(request).await -> Vec<PickedFile>
multiple, limit PickRequest::new(multiple, limit) (limit: 0 para até 100)
maxDimension request.max_dimension = Some(DevicePixels(2048))
quality request.quality = Some(Percentage(0.8))
format request.format = PickFormat::Current
image.file.name, .type, .size file.name, file.mime, file.data.len()
image.size file.size (Size<DevicePixels>)

#Picker.pickImages

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

Mostra o seletor e resolve com as imagens escolhidas pelo usuário, na ordem em que foram escolhidas, ou com [] quando o usuário cancela. Rust: pick_images(request), uma resposta para aguardar em uma task (spawn, ou uma função de módulo de async). pickImages nunca lança exceção: opções fora do intervalo (limit: 0, um quality fora de 0 a 1) rejeitam a promise com um RangeError, e uma escolha que as capabilities do app não permitem rejeita com ERR_ACCESS_DENIED (abaixo). Um cancelamento é a resposta do usuário, não uma falha: ele resolve [].

As imagens chegam prontas para envio:

  • Formatos que todo navegador e servidor aceitam. JPEG, PNG, GIF e WebP chegam como estão; uma foto armazenada em outro formato (HEIC em iPhones, HEIF, TIFF) chega como JPEG. format: "current" mantém o formato armazenado.
  • Reduzidas nativamente. Com maxDimension, uma imagem cujo lado maior é maior que esse valor é reduzida para caber nele, na vertical, e codificada novamente: um PNG como PNG, qualquer outro como JPEG de quality. Fotos de celular têm de 12 a 48 megapixels; reduzi-las para 2048 pixels antes de um upload as deixa com algumas centenas de kilobytes. Um quality também recodifica JPEGs.
  • O que ela exibe. size é o tamanho da imagem em pixels como ela aparece, com a orientação da câmera aplicada. Uma imagem que o NSH recodifica não carrega metadados (nem localização); uma que chega como está mantém os seus.

No Windows, no Linux e em janelas GPUI, o diálogo oferece os formatos que a requisição aceita (com "compatible": JPEG, PNG, GIF e WebP) e os arquivos chegam como estão armazenados, no tamanho original.

Um compositor de chat anexa uma foto, a exibe enquanto a mensagem é escrita e a envia quando a mensagem é enviada:

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
}

Várias fotos de uma vez:

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

#Apps que incorporam o NSH

Um app que incorpora o NSH e tem um seletor próprio (com os álbuns do próprio app, por exemplo) responde às escolhas por conta própria: em Rust, um PickerBackend (nsh_core::picker) entregue ao engine (EngineConfig::picker, ou AppView::set_picker em apps GPUI e GPUI Kit) recebe o PickRequest de cada escolha e devolve os arquivos, e o seletor do sistema não é mostrado. Os arquivos que ele devolve são completados como os da plataforma: cortados no limite da requisição, com tamanhos, tipos e extensões lidos a partir de seus bytes.

#Testes

nsh test responde às escolhas em execuções headless: app.onPick(handler) devolve os arquivos que o usuário escolhe ({ name, type, data }), e sem um handler a escolha é cancelada. Veja Testes.

#Speech

A API de speech transforma o que o usuário diz em texto (ditado): o app inicia uma sessão, a plataforma escuta o microfone e a transcrição chega conforme cresce. O macOS e o iOS reconhecem a fala com o Speech framework do sistema; o sistema pede ao usuário, uma única vez, acesso ao microfone e ao reconhecimento de fala. Em outras plataformas, uma sessão termina imediatamente com o erro "unavailable", então verifique Speech.available() antes de mostrar um botão de microfone. O código Rust chama recognize_speech de nsh_core::prelude; o TSX importa Speech de 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";
TSX Rust
await Speech.available() speech_available().await -> bool
Speech.start(options) recognize_speech(request) -> SpeechRecognition
session.on("result", ...) recognition.next().await -> Option<SpeechEvent>
session.stop() / abort() recognition.stop() / abort() (descartá-lo aborta)

#Speech.start

Inicia uma sessão e a devolve imediatamente. Uma sessão informa, nesta ordem: start quando o microfone está ligado e o reconhecedor escuta, result cada vez que a transcrição muda (text é sempre a transcrição inteira até o momento, de modo que o app a mostra como está) e um único end, depois do qual nada mais chega. stop() para de escutar e deixa o reconhecedor terminar o que ouviu: um último resultado com isFinal: true, depois end. abort() encerra a sessão imediatamente, sem erro, e descarta o que não foi informado. Uma sessão roda por vez: iniciar outra aborta a que estiver rodando.

end carrega a última transcrição e, quando a sessão falhou, error, nomeado como na Web Speech API:

error Quando
"not-allowed" O usuário ou o sistema não permite o microfone ou o reconhecimento de fala.
"no-speech" Nada foi dito.
"audio-capture" Não foi possível usar o microfone.
"network" O reconhecedor precisa da rede e não conseguiu acessá-la.
"language-not-supported" O reconhecedor não conhece lang.
"unavailable" Esta plataforma não reconhece fala, ou não neste momento.
"failed" O reconhecedor falhou por outro motivo.

start lança um TypeError para opções de tipos errados e um RangeError para um lang que não é uma tag de idioma.

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

Resolve true onde uma sessão pode rodar: no macOS e no iOS, e onde quer que o app que incorpora o NSH reconheça fala por conta própria.

#Info.plist

No macOS e no iOS, o sistema mostra por que o app quer o microfone e o reconhecimento de fala. nsh package e nsh run escrevem NSMicrophoneUsageDescription e NSSpeechRecognitionUsageDescription no Info.plist do app quando o app pode usar speech (sua concessão de capabilities speech, ou ele não declara nenhuma).

#Apps que incorporam o NSH

Um app que incorpora o NSH e reconhece fala à sua maneira (com seu próprio engine ou serviço) responde às sessões por conta própria: em Rust, um SpeechBackend (nsh_core::speech) entregue ao engine (EngineConfig::speech) recebe o SpeechRequest de cada sessão e um SpeechSink ao qual ele informa a partir de qualquer thread, e devolve o SpeechControl com o qual o core encerra e aborta a sessão. O reconhecedor da plataforma não é usado nesse caso.

#Testes

nsh test executa sessões em um reconhecedor falso em execuções headless: uma sessão começa imediatamente, app.hear(text) informa text como a transcrição até o momento, app.endSpeech(error?) a encerra e app.speechRequests() lista as sessões que o app iniciou. Veja Testes.

#Storage

A API de storage mantém os dados do app no dispositivo: strings por chave de string, que continuam lá na próxima vez que o app iniciar. Use-a para configurações, rascunhos, a última aba que o usuário tinha aberta, um cache do que o servidor enviou — qualquer estado que deva sobreviver ao processo do app. O código Rust usa nsh_core::storage::Storage, o TSX importa Storage de nsh; ambos leem e escrevem as mesmas entradas, então um app com uma entrada em Rust e uma em TSX vê um único conjunto de dados.

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>;
};
TS Rust (nsh_core::storage::Storage)
await Storage.getItem(key) Storage::get_item(key) -> Option<String>
await Storage.setItem(key, value) Storage::set_item(key, value) -> Result<(), StorageFull>
await Storage.removeItem(key) Storage::remove_item(key)
await Storage.keys() Storage::get_all_keys() -> Vec<String>
await Storage.clear() Storage::clear()

As funções Rust são síncronas, pois as entradas ficam em memória: chame-as onde os componentes rodam (em seus corpos, handlers e tasks).

Cada app tem um storage próprio, de até 5 MB de chaves e valores. As chamadas rodam na ordem em que o app as faz, então um getItem depois de um setItem vê o novo valor sem esperar pela escrita. As leituras vêm da memória; as escritas chegam ao disco em segundo plano, algumas por vez.

Plataforma Onde os dados ficam
iOS Library/Application Support/nsh/<app id>/storage.json no contêiner do app
Android files/nsh/<app id>/storage.json no diretório privado do app
macOS ~/Library/Application Support/<app id>/storage.json
Windows %APPDATA%\<app id>\storage.json
Linux $XDG_DATA_HOME/<app id>/storage.json (~/.local/share/…)
Web O localStorage do navegador, sob chaves nomeadas com o id do app (nsh:com.example.todo:…)

Um storage.json danificado é mantido como storage.json.corrupt ao lado dele, e o app inicia com um storage vazio; um que não pode ser lido deixa as alterações do app em memória, de modo que o arquivo nunca é substituído.

O id do app é o id em nsh.json, e ele viaja com o app: nsh build e nsh package o escrevem junto ao bundle, o dev server o envia e um pacote assinado carrega o id que o certificado de sua chave de release garante. Assim, todo app mantém seus próprios dados sem nenhuma configuração, onde quer que rode; um app sem id os mantém em default. Um app que incorpora o NSH pode escolher outro local (veja Incorporação no iOS e Incorporação no Android).

#Storage.getItem

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

Resolve com o valor de key, ou com null quando ela não tem valor. Rust: Storage::get_item(key), None quando ela não tem valor.

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

#Storage.setItem

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

Define key como value, substituindo o que ela continha. Rejeita com um Error cujo code é "ERR_NSH_QUOTA_EXCEEDED" quando o storage passaria de 5 MB; o valor não é mantido nesse caso. Rust: Storage::set_item(key, value), que nesse caso devolve Err(StorageFull). Os valores são strings: mantenha dados estruturados como JSON (serde_json::to_string em Rust, JSON.stringify em TSX) e leia-os de volta (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>;

Remove key e seu valor. Remover uma chave que não tem valor não faz nada. Rust: Storage::remove_item(key).

#Storage.keys

keys(): Promise<string[]>;

Resolve com todas as chaves, em ordem. Rust: Storage::get_all_keys().

#Storage.clear

clear(): Promise<void>;

Remove todas as chaves do app, como quando o usuário sai da conta. Rust: Storage::clear().

#Mantendo um signal no dispositivo

Leia o valor armazenado quando o app inicia e depois o escreva de volta sempre que o signal mudar:

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 alcança o app que incorpora o NSH: o app nativo em torno de um NshAppView / NshView ou, na web, a página que executa o player. O app chama as funções do host pelo nome com argumentos JSON e escuta os eventos que o host envia.

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;

Indica se a plataforma tem um host que responde a chamadas. Na web, é true quando a página dá um host ao player (O host na 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>;

Chama a função name do host com args (qualquer valor JSON; null sem um) e resolve com o que ela devolve. host.call nunca lança exceção: toda falha rejeita a promise, com um Error cujo code diz o que aconteceu (a mensagem é para pessoas e pode mudar):

code Quando
ERR_NSH_HOST_ERROR A função do host falhou, ou o host não tem uma função name; a mensagem é a do host
ERR_ACCESS_DENIED name não está na capability host do app; permission é "host", resource o nome
ERR_FEATURE_UNAVAILABLE_ON_PLATFORM A plataforma não tem host: host.available() é false
ETIMEDOUT O host não respondeu a tempo
ERR_NSH_QUOTA_EXCEEDED 1024 chamadas já aguardam as respostas do app que incorpora o NSH

Um nome que não é válido (de 1 a 128 de A–Z, a–z, 0–9, _, ., :, -, sem ..) rejeita com um TypeError.

Uma chamada espera 30 segundos pela resposta do host e depois rejeita com ETIMEDOUT; uma resposta que chega depois é descartada. options.timeout define a espera em milissegundos, 0 para nenhum limite; um timeout tão grande que o relógio nunca chega lá (cerca de 1e22 ms) também espera sem limite, e um valor negativo, NaN ou infinito rejeita a chamada com um RangeError. O tempo é o do app, como o de seus timers: em nsh test com um relógio manual, ele avança com o relógio.

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;

Eventos da plataforma chamam listener com o payload de cada event que o host envia, até que a função que host.on devolve seja chamada; um listener adicionado enquanto um componente renderiza é encerrado junto com o componente. Os eventos que o host envia antes de o app iniciar chegam assim que ele inicia, então os listeners registrados no nível superior do app os recebem; um evento enviado antes de um listener se registrar não é reenviado a ele. Leia o estado com o qual o app inicia por meio de host.call e acompanhe suas mudanças com host.on.

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

#Capacidade

Toda função que o app chama deve estar listada no capabilities.host de nsh.json: nomes exatos ou prefixos terminados em *, ou true para todas. Isso vale para todo app, inclusive um sem outros capabilities: as funções do host nunca são concedidas por padrão. Uma chamada não concedida é rejeitada com ERR_ACCESS_DENIED e registrada em log, um host.post para um nome assim em um app hospedeiro lança o mesmo erro, e compilar um app que importa host de nsh sem capabilities.host gera um aviso. Eventos não precisam de concessão. Veja Sandbox e capacidades.

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

#O host na web

Na web, a página é o host. A opção host de createPlayer responde às chamadas, e player.hostEvent(event, payload) envia eventos:

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

Um app publicado nomeia seu host em nsh.json: web.host é um módulo JS no diretório do app, relativo a nsh.json, que exporta createHost(player), a qual retorna o objeto com call. nsh package --target web o publica junto com o site, nsh run --target web o executa na página de desenvolvimento, e nsh test o executa para os apps que seus testes abrem. web.connect lista os hosts que as requisições do próprio host alcançam, os quais a Content-Security-Policy da página permite junto com o capabilities.network do app. Veja Web.

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

#host.params

Os parâmetros que o app hospedeiro definiu antes de iniciar o seu app, lidos de forma síncrona desde a primeira linha do seu código; {} quando ele não definiu nenhum. Eles permanecem os mesmos enquanto o app executa: pedidos posteriores chegam como eventos.

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

#Canal do Native SDK

O send do Native SDK mantém os eventos até que o app escute, até 256. Quando o último listener para durante uma entrega, os eventos restantes continuam aguardando em ordem; eventos enviados durante o encerramento aguardam o próximo app carregado. host.post(name, data) entrega dados ao handler do Native SDK, cujo isPost distingue posts de chamadas.

#O lado do app hospedeiro

Cada view mantém um canal para todos os apps que carrega. Defina os parâmetros antes de carregar, envie eventos a qualquer momento de qualquer thread e registre handlers por nome. Chamadas e posts exigem a concessão capabilities.host do app. Os handlers respondem uma vez, agora ou depois; uma resposta que nunca chega faz a chamada falhar.

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

Veja Embedding no iOS, Embedding no Android e GPUI.

#keymap

O keymap vincula os atalhos de teclado do app: uma combinação de teclas como "mod+k" e a função que ela executa. O código Rust chama on_key, on_key_with e key_label de nsh_core::prelude; o TSX importa keymap de nsh. Ambos aceitam as mesmas combinações de teclas e seguem as mesmas regras.

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;
}
TS Rust
keymap.bind(keystroke, handler) on_key(keystroke, handler)
keymap.bind(keystroke, handler, { when, repeat }) on_key_with(keystroke, KeyOptions { when, repeat }, handler); when é nsh_core::keymap::When (Auto, Always, NotEditing)
keymap.bind({ … }) um on_key por combinação de teclas
keymap.label(keystroke) key_label(keystroke)

Um vínculo em Rust vive tanto quanto o componente (o proprietário) que o criou; o handler recebe a combinação de teclas como foi escrita.

Os atalhos funcionam em macOS, Windows, Linux e em navegadores (as teclas do próprio navegador, como Ctrl+T ou Cmd+W, nunca chegam à página), e em iOS e Android com teclado físico: o de um iPad, o de um Mac executando um app de iPad, o de um tablet ou Chromebook, o do simulador e o do emulador. O mesmo código executa em todos os lugares; sem teclado, nenhuma tecla dispara os vínculos.

#keymap.bind

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

Executa handler quando keystroke é pressionada e retorna uma função que remove o vínculo. O handler recebe { keystroke }: a combinação de teclas como você a escreveu, com a mesma capitalização. Rust: on_key(keystroke, handler), cujo handler recebe a própria combinação de teclas.

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

Passe um objeto para vincular várias combinações de teclas de uma vez; a função retornada remove todas. Em Rust, cada combinação de teclas é um on_key:

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

Um vínculo criado enquanto um componente renderiza é removido junto com o componente, de modo que os atalhos de uma tela duram tanto quanto a tela:

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

Com o servidor de desenvolvimento, editar um módulo remove os vínculos criados pelo código de nível superior dele antes de executá-lo novamente, de modo que o hot reload nunca deixa atalhos obsoletos para trás.

#keymap.label

label(keystroke: string): string;

Retorna keystroke como menus e dicas o exibem onde o app executa: "⇧⌘L" em macOS e iOS (e em um navegador em um Mac, iPhone ou iPad), "Ctrl+Shift+L" em Windows, Linux e outros navegadores, para "mod+shift+l". Rust: key_label(keystroke). Use-o para mostrar os atalhos de um app com as teclas que funcionam:

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

Ele lança um TypeError para uma combinação de teclas inválida, assim como bind; key_label e on_key entram em panic com a mesma mensagem.

#Combinações de teclas

Uma combinação de teclas é formada por modificadores e uma tecla unidos por +: "mod+k", "ctrl+shift+p", "alt+enter", "escape", "f5", "cmd+-" (Command e menos).

Modificador macOS Windows Linux
mod (também cmdorctrl) Command (⌘) Ctrl Ctrl
cmd (também command, meta, super, win) Command (⌘) Tecla Windows Super
ctrl (também control) Control (⌃) Ctrl Ctrl
alt (também option, opt) Option (⌥) Alt Alt
shift Shift (⇧) Shift Shift

O iOS segue o macOS (mod é Command) e o Android segue o Windows e o Linux (mod é Ctrl, cmd a tecla Meta). Em um navegador, as teclas seguem a plataforma em que o navegador executa: mod é Command em um Mac, iPhone ou iPad e Ctrl nos demais.

Use mod para os atalhos do próprio app: é a tecla que os apps de cada plataforma usam para eles (⌘K no macOS, Ctrl+K no Windows e no Linux), então uma única grafia serve para todos. A tecla Windows e Super pertencem em grande parte ao sistema, portanto cmd raramente é a escolha certa fora do macOS.

A tecla é uma letra, dígito ou tecla de pontuação, escrita como o caractere que ela digita em um teclado US sem Shift (k, 1, ,, /, [; "shift+/" para ?, "shift+k" para Shift+K), ou um nome: plus (a tecla = / +, também escrita =), minus (também -), space, enter (return), escape (esc), tab, backspace, delete (del), insert, as setas up, down, left, right (arrowup, …), home, end, pageup, pagedown, f1 a f24. A capitalização não importa: "Mod+Shift+K" é "mod+shift+k", e uma letra maiúscula é a mesma tecla, não Shift ("mod+K" é "mod+k"). Minúsculas são o estilo recomendado, usado nestas páginas. "mod-k" e "mod++" são erros cujas mensagens indicam a grafia correta. Os atalhos de menu em nsh/desktop aceitam as mesmas combinações de teclas.

Os atalhos funcionam com qualquer layout de teclado: em um layout cirílico ou grego, mod+k é a tecla que digita K em um layout US, assim como nos atalhos de menu.

bind lança um TypeError que diz o que há de errado com uma combinação de teclas inválida (keymap.bind: invalid keystroke "cmd+kk": unknown key "kk") e recusa as combinações que o sistema reserva para si, de modo que um app nunca possa quebrá-las:

Plataforma Reservado
macOS, iOS Cmd+Q (sair), Cmd+H (ocultar), Option+Cmd+H (ocultar outros), Cmd+M (minimizar), Ctrl+Cmd+F (tela cheia)
Linux, Windows, Android Ctrl+Q (sair), Alt+F4 (fechar), F11 (tela cheia)
Web Nenhum (o navegador mantém suas próprias teclas)

#Ao digitar

Um campo de texto com o foco do teclado precisa de suas teclas: digitar, mover o cursor, apagar, e selecionar tudo, recortar, copiar, colar, desfazer e refazer. Por isso, por padrão, um vínculo não dispara enquanto um campo de texto (ou uma web view) tem o foco se sua combinação de teclas for uma destas: teclas simples e teclas com apenas Shift ou Option, as setas, Home, End, Page Up / Down, Return, Tab, Escape, Backspace, Delete, e mod+a, mod+c, mod+v, mod+x, mod+z. As demais combinações com Command ou Control, e as teclas de função, disparam onde quer que esteja o foco.

when muda isso (Rust: KeyOptions { when: When::Always, .. }):

when Dispara
(padrão) Como acima
"always" Também em campos de texto, antes de o campo ver a tecla
"notEditing" Nunca enquanto um campo de texto tem o foco
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());

No iOS e no Android, uma tecla com a qual um método de entrada está compondo (escolhendo um candidato, confirmando) continua pertencendo ao método de entrada. No backend GPUI, um componente nativo com o foco do teclado conta como um campo de texto, já que pode ser um editor de texto. Ele vê cada tecla primeiro: uma tecla que ele mesmo trata nunca chega aos vínculos, e os vínculos que disparam durante a edição de texto recebem as teclas que ele deixa passar.

#Qual vínculo é executado

Uma tecla que dispara um vínculo é consumida: ela não é digitada, o sistema não emite um sinal sonoro, e as teclas do próprio NSH não a veem (Escape voltando, Tab movendo o foco, as setas rolando). Quando vários vínculos têm a mesma combinação de teclas, executa o mais recente que pode disparar, de modo que um diálogo pode vincular Escape por cima do vínculo da tela, e o da tela volta quando o do diálogo sai. Os vínculos são verificados nativamente no instante em que a tecla é pressionada; o handler executa logo em seguida, na thread do engine (Rust) ou na thread JS (TSX).

Manter uma tecla pressionada dispara seu vínculo uma vez, até que a tecla seja solta; com repeat: true ele dispara novamente a cada repetição automática (para percorrer uma lista, por exemplo):

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

Os atalhos de menu cedem a um vínculo da mesma combinação de teclas: os do menu do aplicativo (nsh/desktop) em todos os desktops e, no macOS, também os dos menus internos (Cmd+W, Cmd+C, …). Enquanto você desenvolve com nsh run, Cmd+R / Ctrl+R sempre recarrega o app, mesmo que ele vincule mod+r; um app empacotado pode vinculá-la.

#nativeComponent e nativeModule

nativeComponent e nativeModule conectam seu app ao que o app hospedeiro fornece, por nome, em tempo de execução. Um app iOS ou Android que embute o NSH pode registrar suas próprias views (um mapa, um gráfico, uma prévia da câmera) por nome; nativeComponent dá a você um componente tipado para uma delas. Como o NSH renderiza com views nativas reais, tal componente fica na hierarquia de views como qualquer outra view: é posicionado pelo NSH, é recortado e rola junto com seus pais e se sobrepõe corretamente. Um app que embute o NSH a partir de Rust também pode registrar funções, classes e componentes Rust; nativeModule entrega tudo isso como um único objeto tipado. Seu app compila com o nsh build padrão em ambos os casos: o host conecta o que fornece quando executa o app. O código Rust posiciona uma view registrada com native(name) de nsh_core::prelude, define suas props com .prop(name, value) (um valor JSON, ou um signal ou closure de um), ou vincula uma a um valor animado com .bind_prop(name, &value), escuta seus eventos com .on_native_event(…) e chama seus comandos por meio de um ElementRef; o TSX importa nsh.

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

Veja Componentes nativos para registrar o lado nativo, e Embedding no iOS / Embedding no 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)[];
}

Retorna um componente para o componente nativo que o app hospedeiro registrou como name (Swift NshAppView.register(component:), Java NshView.registerComponent). Rust: native(name), uma view cujas props são valores JSON (.prop("text", json!(…))) e cujos eventos chegam como NativeEvent { name, data }.

  • P é o tipo das suas próprias props. Cada prop listada em spec.props é enviada ao lado nativo como JSON quando muda; defini-la como undefined a remove.
  • E mapeia nomes de eventos para seus payloads. Um evento regionChange vira a prop de handler onRegionChange.
  • O componente também aceita todas as props de view exceto children: style, class, layout, acessibilidade, handlers de toque e 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 };

#Tamanho

O componente é posicionado como uma view, no tamanho que seu estilo lhe dá. Onde o estilo deixa uma dimensão em aberto, ele usa o tamanho que o componente nativo informa (como uma imagem usa seu tamanho natural).

#Comandos

Seu ref recebe um NativeComponentRef; ref.command(name, args) chama o comando name do componente nativo com args (qualquer valor JSON). Rust: ElementRef::command(name, args).

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

#Quando nada está registrado

Onde nada está registrado sob name (outro app hospedeiro, o host de desenvolvimento, o navegador), o componente renderiza uma view vazia e um aviso é registrado uma vez. Mantenha o app utilizável sem ele, ou verifique seu host antes de usá-lo.

#Exemplo

O badge aqui é uma view nativa do app hospedeiro, registrada como embed.badge. O NSH o posiciona, envia a ele text e color, recebe seus eventos tap e chama seu comando 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;

Retorna as exportações do conjunto que o app host registrou como specifier: seus módulos, classes, funções e componentes Rust, em um único objeto. O app escreve o tipo T por conta própria, então nada sobre o host é necessário ao compilá-lo. O código Rust compilado junto com o crate do host chama essas funções como Rust; nativeModule é como o TSX chega até elas.

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));
  • As chamadas funcionam como em um import de um conjunto: uma função Rust simples retorna seu valor imediatamente, uma função async retorna uma promise, e uma função que você passa é um callback que o Rust pode chamar depois. O Rust verifica cada argumento e lança um TypeError quando um argumento é inválido.
  • Cada chamada com o mesmo specifier retorna o mesmo objeto.
  • Quando nada está registrado em specifier, nativeModule lança um ReferenceError que o nomeia. Chame-a onde o app possa tratar o erro, ou apenas no host para o qual ela foi criada.

O host registra o conjunto a partir do Rust, com o set_ui_sets do crate da plataforma (junto aos conjuntos do próprio NSH) antes de iniciar suas views, ou 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 é como o host mantém todos os apps em sincronia com seus próprios dados: a tarefa que ele inicia aguarda as mudanças do host e envia cada uma ao app, de modo que uma mudança feita pelo host, ou por qualquer app por meio de outra função, chega a todos os apps que estão escutando. O Stop que ele retorna é a função que Trading.on retorna no TSX: ela é executada uma vez, quando o app a chama ou quando aquilo que chamou on termina (um componente, ou a execução do módulo sob hot reload), e aqui remove o listener, encerrando a tarefa.

#Acessibilidade

A API de acessibilidade lê as configurações de acessibilidade do usuário (se um leitor de tela está em execução, se ele pediu menos movimento) e se comunica com o leitor de tela. O código Rust chama screen_reader(), reduce_motion() e announce_for_accessibility() a partir de nsh_core::prelude e move o foco com um ElementRef; o TSX importa screenReaderEnabled(), reduceMotion(), announce() e setAccessibilityFocus() de nsh. As configurações são reativas: leia-as em um binding, computed ou effect, e o que as lê é atualizado quando o usuário as altera. As props de acessibilidade dos elementos (accessibilityLabel, role, …) estão documentadas em Elementos; o guia é Acessibilidade.

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

#screenReaderEnabled

const screenReaderEnabled: Accessor<boolean>;

Indica se um leitor de tela (VoiceOver, TalkBack) está em execução. Reativo: o que o lê é atualizado quando o usuário liga ou desliga o leitor de tela. Rust: screen_reader() -> bool, reativo da mesma forma.

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

#reduceMotion

const reduceMotion: Accessor<boolean>;

Indica se o usuário pediu menos movimento (Reduzir Movimento no iOS, Remover animações no Android). Reativo. Rust: reduce_motion() -> bool. As classes do Tailwind também o seguem, com a variante 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;

O leitor de tela lê message (um anúncio do VoiceOver, do TalkBack). Use para resultados que aparecem sem que o foco se mova, como “Salvo”. Rust: announce_for_accessibility(message).

#setAccessibilityFocus

function setAccessibilityFocus(node: ViewRef): void;

Move o foco do leitor de tela para node (o ref de um elemento). 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))
}

#Tipos

nsh também exporta os tipos da API reativa e dos elementos host. Os mais usados:

Tipo O que é
Accessor<T> Um valor legível: () => T
Signal<T> Um Accessor<T> com set e update
SignalOptions<T> Opções de signal e computed (equals)
Context<T> O que context retorna
Component<P> (props: P) => JSX.Element
JSX O namespace JSX (JSX.Element)
ViewRef, InputRef, ScrollRef, ListRef, WebViewRef, VideoRef, NativeComponentRef O que um ref recebe, o tipo próprio de cada elemento (ViewRef e os tipos sobre ele): foco, rolagem e outros comandos. ViewRef também é o que um elemento é (JSX.Element). Veja métodos de ref
ViewProps, TextProps, ImageProps, ScrollProps, CommonProps, TouchProps Props dos elementos host
ViewStyle, TextStyle, InputStyle, ImageStyle, LayoutStyle, StyleProp<T> Objetos de estilo; veja a referência de estilos
ColorValue, DimensionValue, LengthValue, TransformStep Valores dentro de estilos
GestureResponderEvent, MouseEvent, MouseButton, ContextMenuEvent, PressEvent, ScrollEvent, PanEvent, TouchPoint Payloads de eventos
Point, Size, Bounds, Edges, Modifiers Os formatos de que são feitos os payloads e as props, com os campos do GPUI (nsh_core::geometry)
PixelSize O tamanho próprio de uma imagem ou de um vídeo em pixels (onLoad, Image.getSize, as imagens da área de transferência e do seletor), em que Size é em pontos
ImageAsset O que a importação de um arquivo de imagem retorna ({ url, size: Size, scales? }; scales lista suas variantes de densidade de pixels)
OS "ios" | "android" | "macos" | "windows" | "linux" | "web"
AccessibilityRole, AccessibilityState Props de acessibilidade
import { type GestureResponderEvent, type InputRef, signal } from "nsh";

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

StyleProp<T> aceita um estilo ou uma lista mesclada da esquerda para a direita, ignorando entradas falsy:

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